single-file-cli 2.5.0 → 2.6.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/.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 +62 -12
- 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/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
|
@@ -62,6 +62,10 @@ 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";
|
|
65
69
|
|
|
66
70
|
let browserOptions, relaunchBrowserPromise;
|
|
67
71
|
|
|
@@ -271,7 +275,7 @@ async function setupNetworkInterception({ Browser, Emulation, Fetch, Network },
|
|
|
271
275
|
setupProxyAuth({ Fetch }, { options, debugMessages });
|
|
272
276
|
}
|
|
273
277
|
setupRequestInterception({ Fetch }, { options, debugMessages, httpInfo, blockedURLPatterns });
|
|
274
|
-
if (options.httpHeaders) {
|
|
278
|
+
if (options.httpHeaders && !hasUnsafeHttpHeaders(options)) {
|
|
275
279
|
await setupHttpHeaders({ Network }, { options, debugMessages });
|
|
276
280
|
}
|
|
277
281
|
if (options.emulateMediaFeatures) {
|
|
@@ -287,12 +291,33 @@ function getInterceptionOptions(options) {
|
|
|
287
291
|
const REQUEST_STAGE = "Request";
|
|
288
292
|
const RESPONSE_STAGE = "Response";
|
|
289
293
|
const handleAuthRequests = Boolean(options.httpProxyUsername);
|
|
290
|
-
|
|
294
|
+
// a request paused on the response stage has already been sent and answered,
|
|
295
|
+
// so a blocked URL is only left uncontacted when the request stage is asked for
|
|
296
|
+
const patterns = handleAuthRequests || hasUnsafeHttpHeaders(options) || hasBlockedURLPatterns(options) ?
|
|
291
297
|
[{ requestStage: REQUEST_STAGE }, { requestStage: RESPONSE_STAGE }] :
|
|
292
298
|
[{ requestStage: RESPONSE_STAGE }];
|
|
293
299
|
return { handleAuthRequests, patterns };
|
|
294
300
|
}
|
|
295
301
|
|
|
302
|
+
function hasBlockedURLPatterns(options) {
|
|
303
|
+
return Boolean(options.blockedURLPatterns && options.blockedURLPatterns.length);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// headers set on the network layer are part of the CORS decision: one that is not
|
|
307
|
+
// safelisted makes every cross-origin fetch of the page send a preflight, and the
|
|
308
|
+
// servers that do not answer preflights lose the resource to the backend fetch.
|
|
309
|
+
// Such headers are injected on the paused request instead, below that decision
|
|
310
|
+
function hasUnsafeHttpHeaders(options) {
|
|
311
|
+
return Boolean(options.httpHeaders) &&
|
|
312
|
+
Object.entries(options.httpHeaders).some(([name, value]) => !isSafelistedHeader(name, value));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function isSafelistedHeader(name, value) {
|
|
316
|
+
return CORS_SAFELISTED_HEADER_NAMES.includes(name.toLowerCase()) &&
|
|
317
|
+
value.length <= CORS_SAFELISTED_HEADER_VALUE_MAX_LENGTH &&
|
|
318
|
+
!CORS_UNSAFE_HEADER_VALUE.test(value);
|
|
319
|
+
}
|
|
320
|
+
|
|
296
321
|
function setupProxyAuth({ Fetch }, { options, debugMessages }) {
|
|
297
322
|
const AUTH_REQUIRED_EVENT_TYPE = "authRequired";
|
|
298
323
|
const PROVIDE_CREDENTIALS_RESPONSE = "ProvideCredentials";
|
|
@@ -313,8 +338,12 @@ function setupRequestInterception({ Fetch }, { options, debugMessages, httpInfo,
|
|
|
313
338
|
const REQUEST_PAUSED_EVENT_TYPE = "requestPaused";
|
|
314
339
|
const ABORTED_ERROR_REASON = "Aborted";
|
|
315
340
|
const urlState = { url: options.url, alternativeUrl: getAlternativeUrl(options.url) };
|
|
341
|
+
const injectedHeaders = hasUnsafeHttpHeaders(options) ? options.httpHeaders : undefined;
|
|
316
342
|
Fetch.addEventListener(REQUEST_PAUSED_EVENT_TYPE, ignoringErrors(async ({ params, sessionId }) => {
|
|
317
|
-
const { requestId, request } = params;
|
|
343
|
+
const { requestId, request, responseStatusCode, responseErrorReason } = params;
|
|
344
|
+
// the request pauses twice when both stages are intercepted, and the
|
|
345
|
+
// headers can only be replaced on the first pause
|
|
346
|
+
const requestStage = responseStatusCode === undefined && responseErrorReason === undefined;
|
|
318
347
|
// the request is always resumed below, otherwise the page waits for it
|
|
319
348
|
// until the load timeout expires
|
|
320
349
|
let blocked = false;
|
|
@@ -335,7 +364,8 @@ function setupRequestInterception({ Fetch }, { options, debugMessages, httpInfo,
|
|
|
335
364
|
}
|
|
336
365
|
}
|
|
337
366
|
try {
|
|
338
|
-
|
|
367
|
+
const headers = requestStage && injectedHeaders ? getMergedHeaders(request.headers, injectedHeaders) : undefined;
|
|
368
|
+
await Fetch.continueRequest(headers ? { requestId, headers } : { requestId }, sessionId);
|
|
339
369
|
} catch {
|
|
340
370
|
// ignored
|
|
341
371
|
}
|
|
@@ -351,6 +381,15 @@ function setupRequestInterception({ Fetch }, { options, debugMessages, httpInfo,
|
|
|
351
381
|
}
|
|
352
382
|
}
|
|
353
383
|
|
|
384
|
+
// continueRequest replaces the headers of the request instead of adding to them,
|
|
385
|
+
// so the ones the browser prepared are kept and the extra ones merged into them
|
|
386
|
+
function getMergedHeaders(requestHeaders, extraHeaders) {
|
|
387
|
+
const headers = new Map();
|
|
388
|
+
Object.entries(requestHeaders).forEach(([name, value]) => headers.set(name.toLowerCase(), { name, value }));
|
|
389
|
+
Object.entries(extraHeaders).forEach(([name, value]) => headers.set(name.toLowerCase(), { name, value }));
|
|
390
|
+
return Array.from(headers.values());
|
|
391
|
+
}
|
|
392
|
+
|
|
354
393
|
function captureHttpInfo(params, urlState, { options, debugMessages, httpInfo }) {
|
|
355
394
|
const REDIRECT_STATUS_CODES = [301, 302, 303, 307, 308];
|
|
356
395
|
const DOCUMENT_RESOURCE_TYPE = "Document";
|
|
@@ -424,8 +463,9 @@ async function setupScriptInjection(cdp, { options, debugMessages }) {
|
|
|
424
463
|
await setupFrameScriptInjection(cdp, scriptSource, { options, debugMessages });
|
|
425
464
|
}
|
|
426
465
|
|
|
427
|
-
// out-of-process frames are separate targets, so the
|
|
428
|
-
//
|
|
466
|
+
// out-of-process frames and service workers are separate targets, so neither the
|
|
467
|
+
// scripts registered above nor the network options reach them; they are attached
|
|
468
|
+
// paused, set up and resumed instead
|
|
429
469
|
async function setupFrameScriptInjection(cdp, scriptSource, { options, debugMessages }) {
|
|
430
470
|
const { Page, Runtime, Target } = cdp;
|
|
431
471
|
const ATTACHED_TO_TARGET_EVENT_TYPE = "attachedToTarget";
|
|
@@ -434,7 +474,13 @@ async function setupFrameScriptInjection(cdp, scriptSource, { options, debugMess
|
|
|
434
474
|
Target.addEventListener(ATTACHED_TO_TARGET_EVENT_TYPE, ignoringErrors(async ({ params }) => {
|
|
435
475
|
const { sessionId, targetInfo, waitingForDebugger } = params;
|
|
436
476
|
try {
|
|
437
|
-
if (targetInfo.type ===
|
|
477
|
+
if (targetInfo.type === SERVICE_WORKER_TARGET_TYPE) {
|
|
478
|
+
logData(["Applying network options to service worker", targetInfo.url], { options, debugMessages });
|
|
479
|
+
// the requests a worker makes on behalf of the page are answered
|
|
480
|
+
// for by its own target, and it is attached paused, so the options
|
|
481
|
+
// are in place before it can serve a single one
|
|
482
|
+
await setupNetworkSession(cdp, sessionId, { options, debugMessages });
|
|
483
|
+
} else if (targetInfo.type === IFRAME_TARGET_TYPE) {
|
|
438
484
|
logData(["Injecting scripts into frame", targetInfo.url], { options, debugMessages });
|
|
439
485
|
// the scripts registered below only run when the Page domain is
|
|
440
486
|
// enabled on the session
|
|
@@ -463,17 +509,21 @@ async function setupFrameScriptInjection(cdp, scriptSource, { options, debugMess
|
|
|
463
509
|
await Target.setAutoAttach(autoAttachOptions);
|
|
464
510
|
}
|
|
465
511
|
|
|
466
|
-
async function
|
|
512
|
+
async function setupNetworkSession({ Fetch, Network }, sessionId, { options, debugMessages }) {
|
|
467
513
|
const { handleAuthRequests, patterns } = getInterceptionOptions(options);
|
|
468
|
-
if (handleAuthRequests || (options
|
|
514
|
+
if (handleAuthRequests || hasUnsafeHttpHeaders(options) || hasBlockedURLPatterns(options)) {
|
|
469
515
|
// the requestPaused and authRequired listeners are registered once on
|
|
470
516
|
// the connection and receive the events of every session
|
|
471
517
|
await Fetch.enable({ handleAuthRequests, patterns }, sessionId);
|
|
472
518
|
}
|
|
473
|
-
|
|
474
|
-
if (options.httpHeaders) {
|
|
519
|
+
if (options.httpHeaders && !hasUnsafeHttpHeaders(options)) {
|
|
475
520
|
await setupHttpHeaders({ Network }, { options, debugMessages }, sessionId);
|
|
476
521
|
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function setupFrameSession({ Browser, Emulation, Fetch, Network, Security }, sessionId, { options, debugMessages }) {
|
|
525
|
+
await setupNetworkSession({ Fetch, Network }, sessionId, { options, debugMessages });
|
|
526
|
+
await setupSecurity({ Security }, { options, debugMessages }, sessionId);
|
|
477
527
|
if (options.emulateMediaFeatures) {
|
|
478
528
|
await setupMediaFeatures({ Emulation }, { options, debugMessages }, sessionId);
|
|
479
529
|
}
|
|
@@ -808,7 +858,7 @@ async function disableCdpDomains({ Console, Fetch, Network, Page, Runtime }, { o
|
|
|
808
858
|
await Fetch.disable();
|
|
809
859
|
await Runtime.disable();
|
|
810
860
|
await Page.disable();
|
|
811
|
-
if (options.httpHeaders) {
|
|
861
|
+
if (options.httpHeaders && !hasUnsafeHttpHeaders(options)) {
|
|
812
862
|
await Network.disable();
|
|
813
863
|
}
|
|
814
864
|
if (options.consoleMessagesFile) {
|