@ultimat3/scraping 30.9.0 → 31.1.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
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 {
|
|
37
|
-
export type { Deadline
|
|
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 {
|
|
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 {
|
|
44
|
+
export { fakeBrowser, fakePage } from './driver-fake';
|
|
56
45
|
export type { FixtureBrowserOptions } from './driver-recorded';
|
|
57
|
-
export {
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
190
|
+
export type { WedgeGuard } from './watchdog';
|
|
245
191
|
export { DEFAULT_GRACE_MS } from './watchdog';
|
package/src/launch-args.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/page-over-target.ts
CHANGED
|
@@ -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:
|
|
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
|
+
}
|
package/src/pdf-fence.ts
ADDED
|
@@ -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
|
+
}
|