single-file-cli 2.4.1 → 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.
@@ -12,10 +12,10 @@ jobs:
12
12
  steps:
13
13
  -
14
14
  name: Checkout sources
15
- uses: actions/checkout@v4
15
+ uses: actions/checkout@v7
16
16
  -
17
17
  name: Set up Node.js
18
- uses: actions/setup-node@v4
18
+ uses: actions/setup-node@v7
19
19
  with:
20
20
  node-version: 24
21
21
  cache: npm
@@ -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@v5
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@v5
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@v5
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@v3
124
+ uses: docker/setup-qemu-action@v4
125
125
  -
126
126
  name: Set up Docker Buildx
127
- uses: docker/setup-buildx-action@v3
127
+ uses: docker/setup-buildx-action@v4
128
128
  -
129
129
  name: Login to Docker Hub
130
- uses: docker/login-action@v3
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@v6
136
+ uses: docker/build-push-action@v7
137
137
  with:
138
138
  push: true
139
139
  build-args: |
package/build.sh CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  set -e
4
4
 
5
- CORE_PACKAGE="npm:single-file-core@1.5.107"
5
+ CORE_PACKAGE="npm:single-file-core@1.5.113"
6
6
  ESBUILD_PACKAGE="npm:esbuild@0.27.7"
7
7
 
8
8
  project_dir=$(pwd)
package/deno.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@single-file/single-file-cli",
3
- "version": "2.4.1",
3
+ "version": "2.6.0",
4
4
  "description": "SingleFile CLI",
5
5
  "exports": {
6
6
  ".": "./single-file-cli-api.js"
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
- const patterns = handleAuthRequests ?
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
- await Fetch.continueRequest({ requestId }, sessionId);
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 scripts registered above
428
- // do not reach them; they are attached paused, injected and resumed instead
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 === IFRAME_TARGET_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 setupFrameSession({ Browser, Emulation, Fetch, Network, Security }, sessionId, { options, debugMessages }) {
512
+ async function setupNetworkSession({ Fetch, Network }, sessionId, { options, debugMessages }) {
467
513
  const { handleAuthRequests, patterns } = getInterceptionOptions(options);
468
- if (handleAuthRequests || (options.blockedURLPatterns && options.blockedURLPatterns.length)) {
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
- await setupSecurity({ Security }, { options, debugMessages }, sessionId);
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) {