single-file-cli 2.5.0 → 2.6.1
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/.github/workflows/ci.yml +2 -2
- package/.github/workflows/publish.yml +7 -7
- package/build.sh +1 -1
- package/deno.json +1 -1
- package/lib/browser.js +23 -2
- package/lib/cdp-client.js +91 -30
- package/lib/single-file-archive.js +5 -5
- package/lib/single-file-bundle.js +1 -1
- package/lib/version.js +1 -1
- package/options.js +26 -12
- package/package.json +1 -1
- package/single-file-cli-api.js +18 -3
- package/single-file-launcher.js +13 -1
- package/test/e2e/backend-fetch.test.js +92 -0
- package/test/e2e/blocked-url-pattern.test.js +54 -0
- package/test/e2e/http-header.test.js +61 -0
- package/test/e2e/service-worker.test.js +89 -0
- package/test/unit/browser-profile.test.js +38 -1
- package/test/unit/cli-usage.test.js +19 -0
- package/test/unit/options.test.js +5 -0
- /package/{dev-build.sh → build-dev.sh} +0 -0
package/.github/workflows/ci.yml
CHANGED
|
@@ -22,7 +22,7 @@ jobs:
|
|
|
22
22
|
steps:
|
|
23
23
|
# only used to resolve the tag to a commit, so it runs no code from the repository and keeps no
|
|
24
24
|
# git credentials behind
|
|
25
|
-
- uses: actions/checkout@
|
|
25
|
+
- uses: actions/checkout@v7
|
|
26
26
|
with:
|
|
27
27
|
ref: ${{ github.event.release.tag_name }}
|
|
28
28
|
persist-credentials: false
|
|
@@ -86,11 +86,11 @@ jobs:
|
|
|
86
86
|
needs: check_tests
|
|
87
87
|
runs-on: ubuntu-latest
|
|
88
88
|
steps:
|
|
89
|
-
- uses: actions/checkout@
|
|
89
|
+
- uses: actions/checkout@v7
|
|
90
90
|
with:
|
|
91
91
|
ref: ${{ github.event.release.tag_name }}
|
|
92
92
|
# Setup .npmrc file to publish to npm
|
|
93
|
-
- uses: actions/setup-node@
|
|
93
|
+
- uses: actions/setup-node@v7
|
|
94
94
|
with:
|
|
95
95
|
node-version: '24'
|
|
96
96
|
registry-url: 'https://registry.npmjs.org'
|
|
@@ -121,19 +121,19 @@ jobs:
|
|
|
121
121
|
exit 1
|
|
122
122
|
-
|
|
123
123
|
name: Set up QEMU
|
|
124
|
-
uses: docker/setup-qemu-action@
|
|
124
|
+
uses: docker/setup-qemu-action@v4
|
|
125
125
|
-
|
|
126
126
|
name: Set up Docker Buildx
|
|
127
|
-
uses: docker/setup-buildx-action@
|
|
127
|
+
uses: docker/setup-buildx-action@v4
|
|
128
128
|
-
|
|
129
129
|
name: Login to Docker Hub
|
|
130
|
-
uses: docker/login-action@
|
|
130
|
+
uses: docker/login-action@v4
|
|
131
131
|
with:
|
|
132
132
|
username: ${{ secrets.DOCKER_USERNAME }}
|
|
133
133
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
134
134
|
-
|
|
135
135
|
name: Build and push
|
|
136
|
-
uses: docker/build-push-action@
|
|
136
|
+
uses: docker/build-push-action@v7
|
|
137
137
|
with:
|
|
138
138
|
push: true
|
|
139
139
|
build-args: |
|
package/build.sh
CHANGED
package/deno.json
CHANGED
package/lib/browser.js
CHANGED
|
@@ -62,7 +62,7 @@ const PROFILE_INCOMPATIBLE_ARGS = [
|
|
|
62
62
|
const { build, makeTempDir, readDir, copyFile, mkdir, Command, errors, remove } = Deno;
|
|
63
63
|
const { join } = path;
|
|
64
64
|
let child, profilePath, childExited, keepProfilePath, browserDebugPort;
|
|
65
|
-
export { launchBrowser, createBrowserProfile, getBrowserOptions, copyProfile, closeBrowser, browserExited };
|
|
65
|
+
export { launchBrowser, createBrowserProfile, getBrowserOptions, copyProfile, pruneProfile, closeBrowser, browserExited };
|
|
66
66
|
|
|
67
67
|
function getBrowserOptions(options) {
|
|
68
68
|
return {
|
|
@@ -71,7 +71,6 @@ function getBrowserOptions(options) {
|
|
|
71
71
|
executablePath: options.browserExecutablePath,
|
|
72
72
|
debug: options.browserDebug,
|
|
73
73
|
singleProcess: options.browserSingleProcess,
|
|
74
|
-
disableWebSecurity: options.browserDisableWebSecurity,
|
|
75
74
|
width: options.browserWidth,
|
|
76
75
|
height: options.browserHeight,
|
|
77
76
|
userAgent: options.userAgent,
|
|
@@ -241,6 +240,7 @@ async function closeBrowser() {
|
|
|
241
240
|
if (closedProfilePath !== undefined) {
|
|
242
241
|
if (keepProfilePath) {
|
|
243
242
|
await removeProfileLockEntries(closedProfilePath).catch(() => { });
|
|
243
|
+
await pruneProfile(closedProfilePath).catch(() => { });
|
|
244
244
|
} else {
|
|
245
245
|
try {
|
|
246
246
|
await remove(closedProfilePath, { recursive: true });
|
|
@@ -298,6 +298,27 @@ async function copyProfileEntries(entries, sourcePath, destinationPath) {
|
|
|
298
298
|
}
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
// the caches the browser fills during the session are the bulk of the profile and
|
|
302
|
+
// none of them is ever copied to the capture profile, so they are dropped rather
|
|
303
|
+
// than kept and copied over at every run
|
|
304
|
+
async function pruneProfile(profileDirectory) {
|
|
305
|
+
await pruneProfileEntries(await readDir(profileDirectory), profileDirectory);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function pruneProfileEntries(entries, profileDirectory) {
|
|
309
|
+
for (const entry of entries) {
|
|
310
|
+
if (entry.isSymlink) {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const entryPath = join(profileDirectory, entry.name);
|
|
314
|
+
if (PROFILE_IGNORED_ENTRY_NAMES.includes(entry.name)) {
|
|
315
|
+
await remove(entryPath, { recursive: true }).catch(() => { });
|
|
316
|
+
} else if (entry.isDirectory) {
|
|
317
|
+
await pruneProfileEntries(await readDir(entryPath), entryPath);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
301
322
|
async function removeProfileLockEntries(profileDirectory) {
|
|
302
323
|
const entries = await readDir(profileDirectory);
|
|
303
324
|
for (const entry of entries) {
|
package/lib/cdp-client.js
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
} from "./browser.js";
|
|
32
32
|
import {
|
|
33
33
|
CDP,
|
|
34
|
-
options
|
|
34
|
+
options as cdpOptions
|
|
35
35
|
} from "simple-cdp";
|
|
36
36
|
import {
|
|
37
37
|
FETCH_FUNCTION_NAME,
|
|
@@ -62,6 +62,11 @@ const BINDING_CALLED_EVENT_TYPE = "bindingCalled";
|
|
|
62
62
|
const LOCALHOST = "http://localhost:";
|
|
63
63
|
const HEADLESS_USER_AGENT_TOKEN = "Headless";
|
|
64
64
|
const BROWSER_EXITED_MAX_DELAY = 2000;
|
|
65
|
+
const CORS_SAFELISTED_HEADER_NAMES = ["accept", "accept-language", "content-language"];
|
|
66
|
+
const CORS_UNSAFE_HEADER_VALUE = /[^\t\x20-\x7e]|[():<>?@[\\\]{}]/;
|
|
67
|
+
const CORS_SAFELISTED_HEADER_VALUE_MAX_LENGTH = 128;
|
|
68
|
+
const SERVICE_WORKER_TARGET_TYPE = "service_worker";
|
|
69
|
+
const USER_AGENT_HEADER_NAME = "user-agent";
|
|
65
70
|
|
|
66
71
|
let browserOptions, relaunchBrowserPromise;
|
|
67
72
|
|
|
@@ -73,10 +78,10 @@ export {
|
|
|
73
78
|
|
|
74
79
|
async function initialize(singleFileOptions) {
|
|
75
80
|
if (singleFileOptions.browserServer) {
|
|
76
|
-
|
|
81
|
+
cdpOptions.apiUrl = singleFileOptions.browserServer;
|
|
77
82
|
} else {
|
|
78
83
|
browserOptions = getBrowserOptions(singleFileOptions);
|
|
79
|
-
|
|
84
|
+
cdpOptions.apiUrl = LOCALHOST + (await launchBrowser(browserOptions));
|
|
80
85
|
}
|
|
81
86
|
}
|
|
82
87
|
|
|
@@ -86,7 +91,7 @@ function relaunchBrowser() {
|
|
|
86
91
|
relaunchBrowserPromise = (async () => {
|
|
87
92
|
await closeBrowser();
|
|
88
93
|
browserOptions.singleProcess = false;
|
|
89
|
-
|
|
94
|
+
cdpOptions.apiUrl = LOCALHOST + (await launchBrowser(browserOptions));
|
|
90
95
|
})();
|
|
91
96
|
}
|
|
92
97
|
return relaunchBrowserPromise;
|
|
@@ -97,7 +102,7 @@ async function getPageData(options) {
|
|
|
97
102
|
// compiled here so that an invalid pattern is reported before the page is
|
|
98
103
|
// loaded, instead of throwing for every request that is intercepted
|
|
99
104
|
const blockedURLPatterns = (options.blockedURLPatterns || []).map(pattern => new RegExp(pattern));
|
|
100
|
-
const pageContext = { options, consoleMessages: [], debugMessages: [], httpInfo: {}, blockedURLPatterns, fetchAbortController: new AbortController() };
|
|
105
|
+
const pageContext = { options, consoleMessages: [], debugMessages: [], httpInfo: {}, browserInfo: {}, blockedURLPatterns, fetchAbortController: new AbortController() };
|
|
101
106
|
let targetInfo, cdp;
|
|
102
107
|
try {
|
|
103
108
|
logData(["Loading page", EMPTY_PAGE_URL], pageContext);
|
|
@@ -107,7 +112,8 @@ async function getPageData(options) {
|
|
|
107
112
|
await setupBrowserWindow(cdp, targetInfo.id, pageContext);
|
|
108
113
|
await setupSecurity(cdp, pageContext);
|
|
109
114
|
await setupDeviceEmulation(cdp, pageContext);
|
|
110
|
-
await
|
|
115
|
+
await setupNetwork(cdp, pageContext);
|
|
116
|
+
await setupDownloadBehavior(cdp);
|
|
111
117
|
await setupScriptInjection(cdp, pageContext);
|
|
112
118
|
const contextId = await getContextId(cdp, pageContext);
|
|
113
119
|
const pageDataPromise = setupPageDataCapture(cdp, pageContext);
|
|
@@ -207,13 +213,18 @@ async function setupSecurity({ Security }, { options, debugMessages }, sessionId
|
|
|
207
213
|
}
|
|
208
214
|
}
|
|
209
215
|
|
|
210
|
-
async function setupDeviceEmulation({ Browser, Emulation, Runtime }, { options, debugMessages }) {
|
|
216
|
+
async function setupDeviceEmulation({ Browser, Emulation, Runtime }, { options, debugMessages, browserInfo }) {
|
|
211
217
|
const needsDeviceMetrics = options.browserMobileEmulation || options.browserDeviceWidth ||
|
|
212
218
|
options.browserDeviceHeight || options.browserDeviceScaleFactor;
|
|
213
219
|
if (needsDeviceMetrics) {
|
|
214
220
|
await setupDeviceMetrics({ Emulation, Runtime }, { options, debugMessages });
|
|
215
221
|
}
|
|
216
|
-
|
|
222
|
+
if (options.emulateMediaFeatures) {
|
|
223
|
+
await setupMediaFeatures({ Emulation }, { options, debugMessages });
|
|
224
|
+
}
|
|
225
|
+
// kept so that the resources fetched outside the browser present the same
|
|
226
|
+
// identity as the browser itself
|
|
227
|
+
browserInfo.userAgent = await setupUserAgent({ Browser, Emulation }, { options, debugMessages });
|
|
217
228
|
}
|
|
218
229
|
|
|
219
230
|
function needsUserAgentOverride(options) {
|
|
@@ -257,29 +268,30 @@ async function setupUserAgent({ Browser, Emulation }, { options, debugMessages }
|
|
|
257
268
|
logData(["Emulating user agent", JSON.stringify(agentOptions)], { options, debugMessages });
|
|
258
269
|
await Emulation.setUserAgentOverride(agentOptions, sessionId);
|
|
259
270
|
}
|
|
271
|
+
return agentOptions.userAgent;
|
|
260
272
|
}
|
|
261
273
|
|
|
262
274
|
function removeHeadlessToken(userAgent) {
|
|
263
275
|
return userAgent.replace(HEADLESS_USER_AGENT_TOKEN, "");
|
|
264
276
|
}
|
|
265
277
|
|
|
266
|
-
async function
|
|
267
|
-
const DENY_BEHAVIOR = "deny";
|
|
278
|
+
async function setupNetwork({ Fetch, Network }, { options, debugMessages, httpInfo, blockedURLPatterns }) {
|
|
268
279
|
const { handleAuthRequests, patterns } = getInterceptionOptions(options);
|
|
269
280
|
await Fetch.enable({ handleAuthRequests, patterns });
|
|
270
281
|
if (handleAuthRequests) {
|
|
271
282
|
setupProxyAuth({ Fetch }, { options, debugMessages });
|
|
272
283
|
}
|
|
273
284
|
setupRequestInterception({ Fetch }, { options, debugMessages, httpInfo, blockedURLPatterns });
|
|
274
|
-
if (options.httpHeaders) {
|
|
285
|
+
if (options.httpHeaders && !hasUnsafeHttpHeaders(options)) {
|
|
275
286
|
await setupHttpHeaders({ Network }, { options, debugMessages });
|
|
276
287
|
}
|
|
277
|
-
if (options.emulateMediaFeatures) {
|
|
278
|
-
await setupMediaFeatures({ Emulation }, { options, debugMessages });
|
|
279
|
-
}
|
|
280
288
|
if (options.browserCookies && options.browserCookies.length) {
|
|
281
289
|
await setupCookies({ Network }, { options, debugMessages });
|
|
282
290
|
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function setupDownloadBehavior({ Browser }) {
|
|
294
|
+
const DENY_BEHAVIOR = "deny";
|
|
283
295
|
await Browser.setDownloadBehavior({ behavior: DENY_BEHAVIOR });
|
|
284
296
|
}
|
|
285
297
|
|
|
@@ -287,12 +299,33 @@ function getInterceptionOptions(options) {
|
|
|
287
299
|
const REQUEST_STAGE = "Request";
|
|
288
300
|
const RESPONSE_STAGE = "Response";
|
|
289
301
|
const handleAuthRequests = Boolean(options.httpProxyUsername);
|
|
290
|
-
|
|
302
|
+
// a request paused on the response stage has already been sent and answered,
|
|
303
|
+
// so a blocked URL is only left uncontacted when the request stage is asked for
|
|
304
|
+
const patterns = handleAuthRequests || hasUnsafeHttpHeaders(options) || hasBlockedURLPatterns(options) ?
|
|
291
305
|
[{ requestStage: REQUEST_STAGE }, { requestStage: RESPONSE_STAGE }] :
|
|
292
306
|
[{ requestStage: RESPONSE_STAGE }];
|
|
293
307
|
return { handleAuthRequests, patterns };
|
|
294
308
|
}
|
|
295
309
|
|
|
310
|
+
function hasBlockedURLPatterns(options) {
|
|
311
|
+
return Boolean(options.blockedURLPatterns && options.blockedURLPatterns.length);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// headers set on the network layer are part of the CORS decision: one that is not
|
|
315
|
+
// safelisted makes every cross-origin fetch of the page send a preflight, and the
|
|
316
|
+
// servers that do not answer preflights lose the resource to the backend fetch.
|
|
317
|
+
// Such headers are injected on the paused request instead, below that decision
|
|
318
|
+
function hasUnsafeHttpHeaders(options) {
|
|
319
|
+
return Boolean(options.httpHeaders) &&
|
|
320
|
+
Object.entries(options.httpHeaders).some(([name, value]) => !isSafelistedHeader(name, value));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function isSafelistedHeader(name, value) {
|
|
324
|
+
return CORS_SAFELISTED_HEADER_NAMES.includes(name.toLowerCase()) &&
|
|
325
|
+
value.length <= CORS_SAFELISTED_HEADER_VALUE_MAX_LENGTH &&
|
|
326
|
+
!CORS_UNSAFE_HEADER_VALUE.test(value);
|
|
327
|
+
}
|
|
328
|
+
|
|
296
329
|
function setupProxyAuth({ Fetch }, { options, debugMessages }) {
|
|
297
330
|
const AUTH_REQUIRED_EVENT_TYPE = "authRequired";
|
|
298
331
|
const PROVIDE_CREDENTIALS_RESPONSE = "ProvideCredentials";
|
|
@@ -313,8 +346,12 @@ function setupRequestInterception({ Fetch }, { options, debugMessages, httpInfo,
|
|
|
313
346
|
const REQUEST_PAUSED_EVENT_TYPE = "requestPaused";
|
|
314
347
|
const ABORTED_ERROR_REASON = "Aborted";
|
|
315
348
|
const urlState = { url: options.url, alternativeUrl: getAlternativeUrl(options.url) };
|
|
349
|
+
const injectedHeaders = hasUnsafeHttpHeaders(options) ? options.httpHeaders : undefined;
|
|
316
350
|
Fetch.addEventListener(REQUEST_PAUSED_EVENT_TYPE, ignoringErrors(async ({ params, sessionId }) => {
|
|
317
|
-
const { requestId, request } = params;
|
|
351
|
+
const { requestId, request, responseStatusCode, responseErrorReason } = params;
|
|
352
|
+
// the request pauses twice when both stages are intercepted, and the
|
|
353
|
+
// headers can only be replaced on the first pause
|
|
354
|
+
const requestStage = responseStatusCode === undefined && responseErrorReason === undefined;
|
|
318
355
|
// the request is always resumed below, otherwise the page waits for it
|
|
319
356
|
// until the load timeout expires
|
|
320
357
|
let blocked = false;
|
|
@@ -335,7 +372,8 @@ function setupRequestInterception({ Fetch }, { options, debugMessages, httpInfo,
|
|
|
335
372
|
}
|
|
336
373
|
}
|
|
337
374
|
try {
|
|
338
|
-
|
|
375
|
+
const headers = requestStage && injectedHeaders ? getMergedHeaders(request.headers, injectedHeaders) : undefined;
|
|
376
|
+
await Fetch.continueRequest(headers ? { requestId, headers } : { requestId }, sessionId);
|
|
339
377
|
} catch {
|
|
340
378
|
// ignored
|
|
341
379
|
}
|
|
@@ -351,6 +389,15 @@ function setupRequestInterception({ Fetch }, { options, debugMessages, httpInfo,
|
|
|
351
389
|
}
|
|
352
390
|
}
|
|
353
391
|
|
|
392
|
+
// continueRequest replaces the headers of the request instead of adding to them,
|
|
393
|
+
// so the ones the browser prepared are kept and the extra ones merged into them
|
|
394
|
+
function getMergedHeaders(requestHeaders, extraHeaders) {
|
|
395
|
+
const headers = new Map();
|
|
396
|
+
Object.entries(requestHeaders).forEach(([name, value]) => headers.set(name.toLowerCase(), { name, value }));
|
|
397
|
+
Object.entries(extraHeaders).forEach(([name, value]) => headers.set(name.toLowerCase(), { name, value }));
|
|
398
|
+
return Array.from(headers.values());
|
|
399
|
+
}
|
|
400
|
+
|
|
354
401
|
function captureHttpInfo(params, urlState, { options, debugMessages, httpInfo }) {
|
|
355
402
|
const REDIRECT_STATUS_CODES = [301, 302, 303, 307, 308];
|
|
356
403
|
const DOCUMENT_RESOURCE_TYPE = "Document";
|
|
@@ -424,8 +471,9 @@ async function setupScriptInjection(cdp, { options, debugMessages }) {
|
|
|
424
471
|
await setupFrameScriptInjection(cdp, scriptSource, { options, debugMessages });
|
|
425
472
|
}
|
|
426
473
|
|
|
427
|
-
// out-of-process frames are separate targets, so the
|
|
428
|
-
//
|
|
474
|
+
// out-of-process frames and service workers are separate targets, so neither the
|
|
475
|
+
// scripts registered above nor the network options reach them; they are attached
|
|
476
|
+
// paused, set up and resumed instead
|
|
429
477
|
async function setupFrameScriptInjection(cdp, scriptSource, { options, debugMessages }) {
|
|
430
478
|
const { Page, Runtime, Target } = cdp;
|
|
431
479
|
const ATTACHED_TO_TARGET_EVENT_TYPE = "attachedToTarget";
|
|
@@ -434,7 +482,13 @@ async function setupFrameScriptInjection(cdp, scriptSource, { options, debugMess
|
|
|
434
482
|
Target.addEventListener(ATTACHED_TO_TARGET_EVENT_TYPE, ignoringErrors(async ({ params }) => {
|
|
435
483
|
const { sessionId, targetInfo, waitingForDebugger } = params;
|
|
436
484
|
try {
|
|
437
|
-
if (targetInfo.type ===
|
|
485
|
+
if (targetInfo.type === SERVICE_WORKER_TARGET_TYPE) {
|
|
486
|
+
logData(["Applying network options to service worker", targetInfo.url], { options, debugMessages });
|
|
487
|
+
// the requests a worker makes on behalf of the page are answered
|
|
488
|
+
// for by its own target, and it is attached paused, so the options
|
|
489
|
+
// are in place before it can serve a single one
|
|
490
|
+
await setupNetworkSession(cdp, sessionId, { options, debugMessages });
|
|
491
|
+
} else if (targetInfo.type === IFRAME_TARGET_TYPE) {
|
|
438
492
|
logData(["Injecting scripts into frame", targetInfo.url], { options, debugMessages });
|
|
439
493
|
// the scripts registered below only run when the Page domain is
|
|
440
494
|
// enabled on the session
|
|
@@ -463,17 +517,21 @@ async function setupFrameScriptInjection(cdp, scriptSource, { options, debugMess
|
|
|
463
517
|
await Target.setAutoAttach(autoAttachOptions);
|
|
464
518
|
}
|
|
465
519
|
|
|
466
|
-
async function
|
|
520
|
+
async function setupNetworkSession({ Fetch, Network }, sessionId, { options, debugMessages }) {
|
|
467
521
|
const { handleAuthRequests, patterns } = getInterceptionOptions(options);
|
|
468
|
-
if (handleAuthRequests || (options
|
|
522
|
+
if (handleAuthRequests || hasUnsafeHttpHeaders(options) || hasBlockedURLPatterns(options)) {
|
|
469
523
|
// the requestPaused and authRequired listeners are registered once on
|
|
470
524
|
// the connection and receive the events of every session
|
|
471
525
|
await Fetch.enable({ handleAuthRequests, patterns }, sessionId);
|
|
472
526
|
}
|
|
473
|
-
|
|
474
|
-
if (options.httpHeaders) {
|
|
527
|
+
if (options.httpHeaders && !hasUnsafeHttpHeaders(options)) {
|
|
475
528
|
await setupHttpHeaders({ Network }, { options, debugMessages }, sessionId);
|
|
476
529
|
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async function setupFrameSession({ Browser, Emulation, Fetch, Network, Security }, sessionId, { options, debugMessages }) {
|
|
533
|
+
await setupNetworkSession({ Fetch, Network }, sessionId, { options, debugMessages });
|
|
534
|
+
await setupSecurity({ Security }, { options, debugMessages }, sessionId);
|
|
477
535
|
if (options.emulateMediaFeatures) {
|
|
478
536
|
await setupMediaFeatures({ Emulation }, { options, debugMessages }, sessionId);
|
|
479
537
|
}
|
|
@@ -643,7 +701,7 @@ function setupPageDataCapture({ Runtime }, { options, debugMessages }) {
|
|
|
643
701
|
});
|
|
644
702
|
}
|
|
645
703
|
|
|
646
|
-
async function setupBindings({ Page, Runtime }, { options, debugMessages, blockedURLPatterns, fetchAbortController }) {
|
|
704
|
+
async function setupBindings({ Page, Runtime }, { options, debugMessages, browserInfo, blockedURLPatterns, fetchAbortController }) {
|
|
647
705
|
await Runtime.addBinding({ name: SET_PAGE_DATA_FUNCTION_NAME, executionContextName: SINGLE_FILE_WORLD_NAME });
|
|
648
706
|
if (options.embedScreenshot && options.compressContent) {
|
|
649
707
|
await setupScreenshotCapture({ Page, Runtime }, { options, debugMessages });
|
|
@@ -654,7 +712,7 @@ async function setupBindings({ Page, Runtime }, { options, debugMessages, blocke
|
|
|
654
712
|
await Runtime.addBinding({ name: FETCH_FUNCTION_NAME, executionContextName: SINGLE_FILE_WORLD_NAME });
|
|
655
713
|
Runtime.addEventListener(BINDING_CALLED_EVENT_TYPE, ignoringErrors(async ({ params, sessionId }) => {
|
|
656
714
|
if (params.name === FETCH_FUNCTION_NAME) {
|
|
657
|
-
await handleFetchRequest({ Runtime }, params, { options, debugMessages, blockedURLPatterns, fetchAbortController }, sessionId);
|
|
715
|
+
await handleFetchRequest({ Runtime }, params, { options, debugMessages, browserInfo, blockedURLPatterns, fetchAbortController }, sessionId);
|
|
658
716
|
}
|
|
659
717
|
}, { options, debugMessages }));
|
|
660
718
|
}
|
|
@@ -717,7 +775,7 @@ async function setupPdfCapture({ Page, Runtime }, { options, debugMessages }) {
|
|
|
717
775
|
}
|
|
718
776
|
}
|
|
719
777
|
|
|
720
|
-
async function handleFetchRequest({ Runtime }, params, { options, debugMessages, blockedURLPatterns, fetchAbortController }, sessionId) {
|
|
778
|
+
async function handleFetchRequest({ Runtime }, params, { options, debugMessages, browserInfo, blockedURLPatterns, fetchAbortController }, sessionId) {
|
|
721
779
|
const BLOCKED_URL_ERROR_MESSAGE = "Blocked URL";
|
|
722
780
|
const { executionContextId: contextId, payload } = params;
|
|
723
781
|
const { requestId, url, options: fetchOptions } = JSON.parse(payload);
|
|
@@ -729,9 +787,12 @@ async function handleFetchRequest({ Runtime }, params, { options, debugMessages,
|
|
|
729
787
|
logData(["Blocking request", url], { options, debugMessages });
|
|
730
788
|
throw new Error(BLOCKED_URL_ERROR_MESSAGE);
|
|
731
789
|
}
|
|
790
|
+
// this fetch runs outside the browser, so without the user agent below it
|
|
791
|
+
// would reach the server under the runtime's own identity while every
|
|
792
|
+
// request the browser made presented another one
|
|
732
793
|
const headers = Object.assign({}, fetchOptions.headers, options.httpHeaders);
|
|
733
|
-
if (
|
|
734
|
-
headers[
|
|
794
|
+
if (browserInfo.userAgent && !Object.keys(headers).some(name => name.toLowerCase() == USER_AGENT_HEADER_NAME)) {
|
|
795
|
+
headers[USER_AGENT_HEADER_NAME] = browserInfo.userAgent;
|
|
735
796
|
}
|
|
736
797
|
const response = await fetch(url, Object.assign({}, fetchOptions, { headers, signal: fetchAbortController.signal }));
|
|
737
798
|
const arrayBuffer = await response.arrayBuffer();
|
|
@@ -808,7 +869,7 @@ async function disableCdpDomains({ Console, Fetch, Network, Page, Runtime }, { o
|
|
|
808
869
|
await Fetch.disable();
|
|
809
870
|
await Runtime.disable();
|
|
810
871
|
await Page.disable();
|
|
811
|
-
if (options.httpHeaders) {
|
|
872
|
+
if (options.httpHeaders && !hasUnsafeHttpHeaders(options)) {
|
|
812
873
|
await Network.disable();
|
|
813
874
|
}
|
|
814
875
|
if (options.consoleMessagesFile) {
|