single-file-cli 2.6.0 → 2.6.2

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.
@@ -28,3 +28,27 @@ jobs:
28
28
  -
29
29
  name: Run tests
30
30
  run: npm test
31
+
32
+ # lib/ holds generated code that is committed, so nothing at runtime notices when it falls behind
33
+ # the core release it was built from — a stale bundle just silently runs old code, and a dev build
34
+ # (build-dev.sh, unminified, built from a local core checkout) silently replaces the released one.
35
+ # build.sh is reproducible: it pins both the core package and esbuild, so rebuilding here and
36
+ # diffing catches either case before it ships.
37
+ build-output:
38
+ runs-on: ubuntu-latest
39
+ timeout-minutes: 15
40
+ steps:
41
+ -
42
+ name: Checkout sources
43
+ uses: actions/checkout@v7
44
+ -
45
+ name: Set up Deno
46
+ uses: denoland/setup-deno@v2
47
+ with:
48
+ deno-version: v2.x
49
+ -
50
+ name: Rebuild the released artifacts
51
+ run: bash build.sh
52
+ -
53
+ name: Check lib/ matches the pinned core release
54
+ run: git diff --exit-code --stat -- lib/
package/build-dev.sh CHANGED
@@ -16,7 +16,7 @@ await build({
16
16
  ],
17
17
  bundle: true,
18
18
  globalName: 'singlefile',
19
- outdir: 'lib/',
19
+ outdir: 'lib-dev/',
20
20
  platform: 'browser',
21
21
  sourcemap: false,
22
22
  minify: false,
@@ -30,7 +30,7 @@ await build({
30
30
  ],
31
31
  bundle: true,
32
32
  globalName: 'singlefileBootstrap',
33
- outdir: 'lib/',
33
+ outdir: 'lib-dev/',
34
34
  platform: 'browser',
35
35
  sourcemap: false,
36
36
  minify: false,
@@ -43,7 +43,7 @@ await build({
43
43
  '../single-file-core/single-file-hooks-frames.js'
44
44
  ],
45
45
  bundle: true,
46
- outdir: 'lib/',
46
+ outdir: 'lib-dev/',
47
47
  platform: 'browser',
48
48
  sourcemap: false,
49
49
  minify: false,
@@ -57,7 +57,7 @@ await build({
57
57
  ],
58
58
  bundle: true,
59
59
  globalName: 'zip',
60
- outdir: 'lib/',
60
+ outdir: 'lib-dev/',
61
61
  platform: 'browser',
62
62
  sourcemap: false,
63
63
  minify: false,
@@ -70,7 +70,7 @@ await build({
70
70
  '../single-file-core/single-file-archive.js'
71
71
  ],
72
72
  bundle: true,
73
- outfile: 'lib/single-file-archive.js',
73
+ outfile: 'lib-dev/single-file-archive.js',
74
74
  platform: 'neutral',
75
75
  sourcemap: false,
76
76
  minify: false,
@@ -79,23 +79,44 @@ await build({
79
79
  });
80
80
 
81
81
  const SCRIPTS = [
82
- 'lib/single-file.js',
83
- 'lib/single-file-bootstrap.js',
84
- 'lib/zip.min.js'
82
+ 'lib-dev/single-file.js',
83
+ 'lib-dev/single-file-bootstrap.js',
84
+ 'lib-dev/zip.min.js'
85
85
  ];
86
86
 
87
87
  let script = '';
88
88
  const scripts = SCRIPTS.map(script => Deno.readTextFile(script));
89
89
  const sources = await Promise.all(scripts);
90
90
  script += 'const script = ' + JSON.stringify(sources.join(';')) + ';';
91
- const hookScript = await Deno.readTextFile('lib/single-file-hooks-frames.js');
91
+ const hookScript = await Deno.readTextFile('lib-dev/single-file-hooks-frames.js');
92
92
  script += 'const hookScript = ' + JSON.stringify(hookScript) + ';';
93
- const zipScript = await Deno.readTextFile('lib/zip.min.js');
93
+ const zipScript = await Deno.readTextFile('lib-dev/zip.min.js');
94
94
  script += 'const zipScript = ' + JSON.stringify(zipScript) + ';';
95
95
  script += 'export { script, zipScript, hookScript };';
96
- await Deno.writeTextFile('lib/single-file-bundle.js', script)
96
+ await Deno.writeTextFile('lib-dev/single-file-bundle.js', script)
97
97
  await Promise.all(SCRIPTS.map(script => Deno.remove(script)));
98
- await Deno.remove('lib/single-file-hooks-frames.js');
98
+ await Deno.remove('lib-dev/single-file-hooks-frames.js');
99
99
  const version = JSON.parse(await Deno.readTextFile('./deno.json')).version;
100
- await Deno.writeTextFile('lib/version.js', 'export const version = ' + JSON.stringify(version) + ';');
101
- " | deno run --allow-read --allow-write --allow-net --allow-run --allow-env --lock="$build_dir/build.lock" -
100
+ await Deno.writeTextFile('lib-dev/version.js', 'export const version = ' + JSON.stringify(version) + ';');
101
+ " | deno run --allow-read --allow-write --allow-net --allow-run --allow-env --lock="$build_dir/build.lock" -
102
+
103
+ # Stage a runnable tree in .dev/, with the freshly built artifacts overlaid on the hand-written
104
+ # files from lib/. Generated code never lands in the tracked lib/ directory, so a dev build cannot
105
+ # leave an unminified bundle where the release one belongs, and using the dev build is something you
106
+ # opt into by invoking .dev/single-file rather than something you get by forgetting to rebuild.
107
+ rm -rf .dev
108
+ mkdir -p .dev
109
+ cp ./single-file ./single-file-node.js ./single-file-launcher.js ./single-file-cli-api.js ./options.js ./deno.json ./package.json .dev/
110
+ cp -R ./resources .dev/resources
111
+ cp -R ./lib .dev/lib
112
+ cp lib-dev/* .dev/lib/
113
+
114
+ core_revision=$(git -C ../single-file-core rev-parse --short HEAD 2>/dev/null || echo "unknown")
115
+ if [ -n "$(git -C ../single-file-core status --porcelain 2>/dev/null | head -1)" ]; then
116
+ core_revision="$core_revision+uncommitted"
117
+ fi
118
+
119
+ echo
120
+ echo "Dev build ready: single-file-core $core_revision"
121
+ echo " run it with: node .dev/single-file <url> <output>"
122
+ echo " tracked lib/ is untouched; .dev/ and lib-dev/ are git-ignored"
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.113"
5
+ CORE_PACKAGE="npm:single-file-core@1.5.116"
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.6.0",
3
+ "version": "2.6.2",
4
4
  "description": "SingleFile CLI",
5
5
  "exports": {
6
6
  ".": "./single-file-cli-api.js"
package/eslint.config.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import js from "@eslint/js";
2
2
 
3
3
  export default [
4
- { ignores: ["lib/single-file-archive.js"] },
4
+ { ignores: ["lib/single-file-archive.js", "lib-dev/**", ".dev/**"] },
5
5
  js.configs.recommended,
6
6
  {
7
7
  languageOptions: {
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,
@@ -66,6 +66,7 @@ const CORS_SAFELISTED_HEADER_NAMES = ["accept", "accept-language", "content-lang
66
66
  const CORS_UNSAFE_HEADER_VALUE = /[^\t\x20-\x7e]|[():<>?@[\\\]{}]/;
67
67
  const CORS_SAFELISTED_HEADER_VALUE_MAX_LENGTH = 128;
68
68
  const SERVICE_WORKER_TARGET_TYPE = "service_worker";
69
+ const USER_AGENT_HEADER_NAME = "user-agent";
69
70
 
70
71
  let browserOptions, relaunchBrowserPromise;
71
72
 
@@ -77,10 +78,10 @@ export {
77
78
 
78
79
  async function initialize(singleFileOptions) {
79
80
  if (singleFileOptions.browserServer) {
80
- options.apiUrl = singleFileOptions.browserServer;
81
+ cdpOptions.apiUrl = singleFileOptions.browserServer;
81
82
  } else {
82
83
  browserOptions = getBrowserOptions(singleFileOptions);
83
- options.apiUrl = LOCALHOST + (await launchBrowser(browserOptions));
84
+ cdpOptions.apiUrl = LOCALHOST + (await launchBrowser(browserOptions));
84
85
  }
85
86
  }
86
87
 
@@ -90,7 +91,7 @@ function relaunchBrowser() {
90
91
  relaunchBrowserPromise = (async () => {
91
92
  await closeBrowser();
92
93
  browserOptions.singleProcess = false;
93
- options.apiUrl = LOCALHOST + (await launchBrowser(browserOptions));
94
+ cdpOptions.apiUrl = LOCALHOST + (await launchBrowser(browserOptions));
94
95
  })();
95
96
  }
96
97
  return relaunchBrowserPromise;
@@ -101,7 +102,7 @@ async function getPageData(options) {
101
102
  // compiled here so that an invalid pattern is reported before the page is
102
103
  // loaded, instead of throwing for every request that is intercepted
103
104
  const blockedURLPatterns = (options.blockedURLPatterns || []).map(pattern => new RegExp(pattern));
104
- const pageContext = { options, consoleMessages: [], debugMessages: [], httpInfo: {}, blockedURLPatterns, fetchAbortController: new AbortController() };
105
+ const pageContext = { options, consoleMessages: [], debugMessages: [], httpInfo: {}, browserInfo: {}, blockedURLPatterns, fetchAbortController: new AbortController() };
105
106
  let targetInfo, cdp;
106
107
  try {
107
108
  logData(["Loading page", EMPTY_PAGE_URL], pageContext);
@@ -111,7 +112,8 @@ async function getPageData(options) {
111
112
  await setupBrowserWindow(cdp, targetInfo.id, pageContext);
112
113
  await setupSecurity(cdp, pageContext);
113
114
  await setupDeviceEmulation(cdp, pageContext);
114
- await setupNetworkInterception(cdp, pageContext);
115
+ await setupNetwork(cdp, pageContext);
116
+ await setupDownloadBehavior(cdp);
115
117
  await setupScriptInjection(cdp, pageContext);
116
118
  const contextId = await getContextId(cdp, pageContext);
117
119
  const pageDataPromise = setupPageDataCapture(cdp, pageContext);
@@ -211,13 +213,18 @@ async function setupSecurity({ Security }, { options, debugMessages }, sessionId
211
213
  }
212
214
  }
213
215
 
214
- async function setupDeviceEmulation({ Browser, Emulation, Runtime }, { options, debugMessages }) {
216
+ async function setupDeviceEmulation({ Browser, Emulation, Runtime }, { options, debugMessages, browserInfo }) {
215
217
  const needsDeviceMetrics = options.browserMobileEmulation || options.browserDeviceWidth ||
216
218
  options.browserDeviceHeight || options.browserDeviceScaleFactor;
217
219
  if (needsDeviceMetrics) {
218
220
  await setupDeviceMetrics({ Emulation, Runtime }, { options, debugMessages });
219
221
  }
220
- await setupUserAgent({ Browser, Emulation }, { options, debugMessages });
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 });
221
228
  }
222
229
 
223
230
  function needsUserAgentOverride(options) {
@@ -261,14 +268,14 @@ async function setupUserAgent({ Browser, Emulation }, { options, debugMessages }
261
268
  logData(["Emulating user agent", JSON.stringify(agentOptions)], { options, debugMessages });
262
269
  await Emulation.setUserAgentOverride(agentOptions, sessionId);
263
270
  }
271
+ return agentOptions.userAgent;
264
272
  }
265
273
 
266
274
  function removeHeadlessToken(userAgent) {
267
275
  return userAgent.replace(HEADLESS_USER_AGENT_TOKEN, "");
268
276
  }
269
277
 
270
- async function setupNetworkInterception({ Browser, Emulation, Fetch, Network }, { options, debugMessages, httpInfo, blockedURLPatterns }) {
271
- const DENY_BEHAVIOR = "deny";
278
+ async function setupNetwork({ Fetch, Network }, { options, debugMessages, httpInfo, blockedURLPatterns }) {
272
279
  const { handleAuthRequests, patterns } = getInterceptionOptions(options);
273
280
  await Fetch.enable({ handleAuthRequests, patterns });
274
281
  if (handleAuthRequests) {
@@ -278,12 +285,13 @@ async function setupNetworkInterception({ Browser, Emulation, Fetch, Network },
278
285
  if (options.httpHeaders && !hasUnsafeHttpHeaders(options)) {
279
286
  await setupHttpHeaders({ Network }, { options, debugMessages });
280
287
  }
281
- if (options.emulateMediaFeatures) {
282
- await setupMediaFeatures({ Emulation }, { options, debugMessages });
283
- }
284
288
  if (options.browserCookies && options.browserCookies.length) {
285
289
  await setupCookies({ Network }, { options, debugMessages });
286
290
  }
291
+ }
292
+
293
+ async function setupDownloadBehavior({ Browser }) {
294
+ const DENY_BEHAVIOR = "deny";
287
295
  await Browser.setDownloadBehavior({ behavior: DENY_BEHAVIOR });
288
296
  }
289
297
 
@@ -693,7 +701,7 @@ function setupPageDataCapture({ Runtime }, { options, debugMessages }) {
693
701
  });
694
702
  }
695
703
 
696
- async function setupBindings({ Page, Runtime }, { options, debugMessages, blockedURLPatterns, fetchAbortController }) {
704
+ async function setupBindings({ Page, Runtime }, { options, debugMessages, browserInfo, blockedURLPatterns, fetchAbortController }) {
697
705
  await Runtime.addBinding({ name: SET_PAGE_DATA_FUNCTION_NAME, executionContextName: SINGLE_FILE_WORLD_NAME });
698
706
  if (options.embedScreenshot && options.compressContent) {
699
707
  await setupScreenshotCapture({ Page, Runtime }, { options, debugMessages });
@@ -704,7 +712,7 @@ async function setupBindings({ Page, Runtime }, { options, debugMessages, blocke
704
712
  await Runtime.addBinding({ name: FETCH_FUNCTION_NAME, executionContextName: SINGLE_FILE_WORLD_NAME });
705
713
  Runtime.addEventListener(BINDING_CALLED_EVENT_TYPE, ignoringErrors(async ({ params, sessionId }) => {
706
714
  if (params.name === FETCH_FUNCTION_NAME) {
707
- await handleFetchRequest({ Runtime }, params, { options, debugMessages, blockedURLPatterns, fetchAbortController }, sessionId);
715
+ await handleFetchRequest({ Runtime }, params, { options, debugMessages, browserInfo, blockedURLPatterns, fetchAbortController }, sessionId);
708
716
  }
709
717
  }, { options, debugMessages }));
710
718
  }
@@ -767,7 +775,7 @@ async function setupPdfCapture({ Page, Runtime }, { options, debugMessages }) {
767
775
  }
768
776
  }
769
777
 
770
- async function handleFetchRequest({ Runtime }, params, { options, debugMessages, blockedURLPatterns, fetchAbortController }, sessionId) {
778
+ async function handleFetchRequest({ Runtime }, params, { options, debugMessages, browserInfo, blockedURLPatterns, fetchAbortController }, sessionId) {
771
779
  const BLOCKED_URL_ERROR_MESSAGE = "Blocked URL";
772
780
  const { executionContextId: contextId, payload } = params;
773
781
  const { requestId, url, options: fetchOptions } = JSON.parse(payload);
@@ -779,9 +787,12 @@ async function handleFetchRequest({ Runtime }, params, { options, debugMessages,
779
787
  logData(["Blocking request", url], { options, debugMessages });
780
788
  throw new Error(BLOCKED_URL_ERROR_MESSAGE);
781
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
782
793
  const headers = Object.assign({}, fetchOptions.headers, options.httpHeaders);
783
- if (options.userAgent) {
784
- headers["user-agent"] = options.userAgent;
794
+ if (browserInfo.userAgent && !Object.keys(headers).some(name => name.toLowerCase() == USER_AGENT_HEADER_NAME)) {
795
+ headers[USER_AGENT_HEADER_NAME] = browserInfo.userAgent;
785
796
  }
786
797
  const response = await fetch(url, Object.assign({}, fetchOptions, { headers, signal: fetchAbortController.signal }));
787
798
  const arrayBuffer = await response.arrayBuffer();