single-file-cli 2.1.0 → 2.1.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/compile.sh CHANGED
@@ -9,7 +9,6 @@ trap 'rm -rf "$stage_dir"' EXIT
9
9
  cp ./single-file ./single-file-launcher.js ./single-file-cli-api.js ./options.js ./deno.json "$stage_dir"
10
10
  cp -R ./lib "$stage_dir/lib"
11
11
  cp -R ./resources "$stage_dir/resources"
12
- jq 'del(.dependencies, .devDependencies)' ./package.json > "$stage_dir/package.json"
13
12
  mkdir -p "$dist_dir"
14
13
  cd "$stage_dir"
15
14
 
package/deno.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@single-file/single-file-cli",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "SingleFile CLI",
5
5
  "exports": {
6
6
  ".": "./single-file-cli-api.js"
package/lib/browser.js CHANGED
@@ -21,7 +21,7 @@
21
21
  * Source.
22
22
  */
23
23
 
24
- /* global fetch */
24
+ /* global fetch, setTimeout */
25
25
 
26
26
  import { BROWSER_PATHS, BROWSER_ARGS } from "./constants.js";
27
27
  import { Deno } from "./deno-polyfill.js";
@@ -29,10 +29,12 @@ import { Deno } from "./deno-polyfill.js";
29
29
  const NULL_STD_CONFIG = "null";
30
30
  const DEBUG_PORT_MIN = 9222;
31
31
  const DEBUG_PORT_RANGE = 256;
32
+ const READY_TIMEOUT = 30000;
33
+ const READY_RETRY_DELAY = 250;
32
34
 
33
35
  const { build, makeTempDir, Command, errors, remove } = Deno;
34
- let child, profilePath;
35
- export { launchBrowser, closeBrowser };
36
+ let child, profilePath, childExited;
37
+ export { launchBrowser, closeBrowser, browserExited };
36
38
 
37
39
  async function launchBrowser(options = {}, indexPath = 0) {
38
40
  const executablePath = options.executablePath || BROWSER_PATHS[build.os][indexPath];
@@ -84,9 +86,47 @@ async function launchBrowser(options = {}, indexPath = 0) {
84
86
  throw error;
85
87
  }
86
88
  child.ref();
89
+ childExited = false;
90
+ child.status.then(() => childExited = true);
91
+ try {
92
+ await waitUntilReady(debugPort);
93
+ } catch (error) {
94
+ await closeBrowser();
95
+ if (options.singleProcess) {
96
+ console.warn("Warning: the browser exited when using --browser-single-process, retrying without it"); // eslint-disable-line no-console
97
+ return launchBrowser(Object.assign({}, options, { singleProcess: false }), indexPath);
98
+ }
99
+ throw error;
100
+ }
87
101
  return debugPort;
88
102
  }
89
103
 
104
+ async function waitUntilReady(debugPort) {
105
+ const timeoutTime = Date.now() + READY_TIMEOUT;
106
+ while (!childExited && Date.now() < timeoutTime) {
107
+ try {
108
+ await fetch("http://localhost:" + debugPort + "/json/version");
109
+ return;
110
+ } catch {
111
+ await new Promise(resolve => setTimeout(resolve, READY_RETRY_DELAY));
112
+ }
113
+ }
114
+ throw new Error(childExited ? "The browser exited unexpectedly" : "The browser is not responding");
115
+ }
116
+
117
+ async function browserExited(maxDelay = 0) {
118
+ if (child === undefined) {
119
+ return false;
120
+ }
121
+ if (childExited || !maxDelay) {
122
+ return childExited;
123
+ }
124
+ return Promise.race([
125
+ child.status.then(() => true),
126
+ new Promise(resolve => setTimeout(() => resolve(childExited), maxDelay))
127
+ ]);
128
+ }
129
+
90
130
  async function getDebugPort(port = getRandomDebugPort(), usedPorts = []) {
91
131
  try {
92
132
  await fetch("http://localhost:" + port + "/json/version");
@@ -111,7 +151,11 @@ function getRandomDebugPort() {
111
151
 
112
152
  async function closeBrowser() {
113
153
  if (child !== undefined) {
114
- child.kill();
154
+ try {
155
+ child.kill();
156
+ } catch {
157
+ // ignored
158
+ }
115
159
  await child.status;
116
160
  child = undefined;
117
161
  }
@@ -23,6 +23,7 @@
23
23
 
24
24
  /* global setTimeout, clearTimeout, fetch, URL, Headers */
25
25
 
26
+ import { Buffer } from "node:buffer";
26
27
  import { Deno, isDeno, path } from "./deno-polyfill.js";
27
28
 
28
29
  const ABORT_EVENT = "abort";
@@ -82,7 +83,7 @@ function waitForTimeout(abortSignal, maxDelay, errorMessage, errorCode) {
82
83
  }
83
84
 
84
85
  function arrayBufferToBase64(arrayBuffer) {
85
- return new Uint8Array(arrayBuffer).toBase64();
86
+ return Buffer.from(arrayBuffer).toString("base64");
86
87
  }
87
88
 
88
89
  function getAlternativeUrl(url) {
package/lib/cdp-client.js CHANGED
@@ -25,7 +25,8 @@
25
25
 
26
26
  import {
27
27
  launchBrowser,
28
- closeBrowser
28
+ closeBrowser,
29
+ browserExited
29
30
  } from "./browser.js";
30
31
  import {
31
32
  CDP,
@@ -57,6 +58,10 @@ const SET_SCREENSHOT_FUNCTION_NAME = "setScreenshot";
57
58
  const SET_PDF_FUNCTION_NAME = "setPDF";
58
59
  const SET_PAGE_DATA_FUNCTION_NAME = "setPageData";
59
60
  const BINDING_CALLED_EVENT_TYPE = "bindingCalled";
61
+ const LOCALHOST = "http://localhost:";
62
+ const BROWSER_EXITED_MAX_DELAY = 2000;
63
+
64
+ let browserOptions, relaunchBrowserPromise;
60
65
 
61
66
  export {
62
67
  initialize,
@@ -68,8 +73,7 @@ async function initialize(singleFileOptions) {
68
73
  if (singleFileOptions.browserServer) {
69
74
  options.apiUrl = singleFileOptions.browserServer;
70
75
  } else {
71
- const LOCALHOST = "http://localhost:";
72
- const browserOptions = {};
76
+ browserOptions = {};
73
77
  browserOptions.args = singleFileOptions.browserArgs;
74
78
  browserOptions.headless = singleFileOptions.browserHeadless;
75
79
  browserOptions.executablePath = singleFileOptions.browserExecutablePath;
@@ -84,6 +88,18 @@ async function initialize(singleFileOptions) {
84
88
  }
85
89
  }
86
90
 
91
+ function relaunchBrowser() {
92
+ if (!relaunchBrowserPromise) {
93
+ console.warn("Warning: the browser exited when using --browser-single-process, retrying without it"); // eslint-disable-line no-console
94
+ relaunchBrowserPromise = (async () => {
95
+ await closeBrowser();
96
+ browserOptions.singleProcess = false;
97
+ options.apiUrl = LOCALHOST + (await launchBrowser(browserOptions));
98
+ })();
99
+ }
100
+ return relaunchBrowserPromise;
101
+ }
102
+
87
103
  async function getPageData(options) {
88
104
  const EMPTY_PAGE_URL = "about:blank";
89
105
  const pageContext = { options, consoleMessages: [], debugMessages: [], httpInfo: {} };
@@ -108,6 +124,9 @@ async function getPageData(options) {
108
124
  if (shouldRetryWithFallback(error)) {
109
125
  return await retryWithFallback();
110
126
  }
127
+ if (await shouldRelaunchBrowser()) {
128
+ return await relaunchBrowserAndRetry();
129
+ }
111
130
  attachDebugInfo(error, pageContext);
112
131
  throw error;
113
132
  } finally {
@@ -131,9 +150,28 @@ async function getPageData(options) {
131
150
  return await getPageData(options);
132
151
  }
133
152
 
153
+ async function shouldRelaunchBrowser() {
154
+ return Boolean(browserOptions && browserOptions.singleProcess) && await browserExited(BROWSER_EXITED_MAX_DELAY);
155
+ }
156
+
157
+ async function relaunchBrowserAndRetry() {
158
+ logData(["Relaunching the browser"], pageContext);
159
+ targetInfo = null;
160
+ if (cdp) {
161
+ cdp.reset();
162
+ cdp = null;
163
+ }
164
+ await relaunchBrowser();
165
+ return await getPageData(options);
166
+ }
167
+
134
168
  async function closeTarget() {
135
169
  if (targetInfo && !options.browserDebug) {
136
- await CDP.closeTarget(targetInfo.id);
170
+ try {
171
+ await CDP.closeTarget(targetInfo.id);
172
+ } catch {
173
+ // ignored
174
+ }
137
175
  targetInfo = null;
138
176
  }
139
177
  // the connection is closed even when the target is left open for
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const version = "2.1.0";
1
+ export const version = "2.1.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "single-file-cli",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "SingleFile CLI",
5
5
  "author": "Gildas Lormeau",
6
6
  "engines": {
@@ -23,6 +23,7 @@
23
23
 
24
24
  /* global URL */
25
25
 
26
+ import { Buffer } from "node:buffer";
26
27
  import * as backend from "./lib/cdp-client.js";
27
28
  import { getZipScriptSource } from "./lib/single-file-script.js";
28
29
  import { Deno, path } from "./lib/deno-polyfill.js";
@@ -292,7 +293,7 @@ async function capturePage(options) {
292
293
  if (options.outputJson) {
293
294
  if (content instanceof Uint8Array) {
294
295
  pageData.content = undefined;
295
- pageData.binaryContent = content.toBase64();
296
+ pageData.binaryContent = Buffer.from(content).toString("base64");
296
297
  }
297
298
  pageData.doctype = undefined;
298
299
  pageData.viewport = undefined;
@@ -18,8 +18,8 @@ const TEST_TIMEOUT = 120000;
18
18
  let crawlPromise;
19
19
 
20
20
  test("a page linked with and without fragment is captured once", { timeout: TEST_TIMEOUT }, async () => {
21
- const { filenames } = await getCrawlResult();
22
- assert.ok(filenames.includes("Top Page.html"));
21
+ const { filenames, stderr } = await getCrawlResult();
22
+ assert.ok(filenames.includes("Top Page.html"), "missing top page, captured: " + filenames.join(", ") + ", stderr: " + stderr);
23
23
  assert.equal(filenames.filter(filename => filename.startsWith("Linked Page")).length, 1);
24
24
  });
25
25
 
@@ -66,7 +66,9 @@ test("mail and script links are not crawled as external links", { timeout: TEST_
66
66
  "--errors-file", join(directory, "errors.txt"),
67
67
  "--max-parallel-workers", "1"
68
68
  ], { cwd: cliDirectory });
69
- assert.deepEqual(await readdir(directory), ["Mail Top.html"]);
69
+ const filenames = await readdir(directory);
70
+ const errors = filenames.includes("errors.txt") ? await readFile(join(directory, "errors.txt"), "utf8") : "";
71
+ assert.deepEqual(filenames, ["Mail Top.html"], "errors: " + errors);
70
72
  } finally {
71
73
  await rm(directory, { recursive: true });
72
74
  server.close();
@@ -115,7 +117,7 @@ async function runCrawl() {
115
117
  const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
116
118
  try {
117
119
  const url = "http://localhost:" + server.address().port + "/";
118
- await execFileAsync(process.execPath, [
120
+ const { stderr } = await execFileAsync(process.execPath, [
119
121
  "single-file-node.js", url,
120
122
  "--crawl-links",
121
123
  "--crawl-replace-URLs",
@@ -128,7 +130,7 @@ async function runCrawl() {
128
130
  for (const filename of filenames) {
129
131
  pages[filename] = await readFile(join(directory, filename), "utf8");
130
132
  }
131
- return { filenames, pages, otherServerRequests };
133
+ return { filenames, pages, otherServerRequests, stderr };
132
134
  } finally {
133
135
  await rm(directory, { recursive: true });
134
136
  server.close();
@@ -9,13 +9,14 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join, dirname } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
+ import { Buffer } from "node:buffer";
12
13
  import process from "node:process";
13
14
 
14
15
  const execFileAsync = promisify(execFile);
15
16
  const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
16
17
  const LOAD_MARKER = "MARKER_ADDED_AFTER_LOAD_EVENT";
17
18
  const SLOW_RESOURCE_DELAY = 3500;
18
- const PIXEL_PNG = Uint8Array.fromBase64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==");
19
+ const PIXEL_PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", "base64");
19
20
 
20
21
  const TOP_PAGE = `<html><head><title>top</title></head><body>
21
22
  <img src="/slow.png">
@@ -29,7 +30,7 @@ addEventListener("load", () => {
29
30
  </script>
30
31
  </body></html>`;
31
32
 
32
- test("capture waits for the top frame, not a fast iframe", { timeout: 60000 }, async () => {
33
+ test("capture waits for the top frame, not a fast iframe", { timeout: 120000 }, async () => {
33
34
  const server = createServer((request, response) => {
34
35
  const { pathname } = new URL(request.url, "http://localhost");
35
36
  if (pathname === "/top.html") {
@@ -1,5 +1,3 @@
1
- /* global TextDecoder */
2
-
3
1
  import { test } from "node:test";
4
2
  import assert from "node:assert/strict";
5
3
  import { createServer } from "node:http";
@@ -9,13 +7,14 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
9
7
  import { tmpdir } from "node:os";
10
8
  import { join, dirname } from "node:path";
11
9
  import { fileURLToPath } from "node:url";
10
+ import { Buffer } from "node:buffer";
12
11
  import process from "node:process";
13
12
 
14
13
  const execFileAsync = promisify(execFile);
15
14
  const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
16
15
  const ZIP_SIGNATURE = "PK\u0003\u0004";
17
16
 
18
- test("output-json embeds compressed content as base64", { timeout: 60000 }, async () => {
17
+ test("output-json embeds compressed content as base64", { timeout: 120000 }, async () => {
19
18
  const server = createServer((_, response) => response
20
19
  .writeHead(200, { "content-type": "text/html" })
21
20
  .end("<html><head><title>JSON Output</title></head><body>content</body></html>"));
@@ -24,14 +23,20 @@ test("output-json embeds compressed content as base64", { timeout: 60000 }, asyn
24
23
  try {
25
24
  const outputPath = join(directory, "page.json");
26
25
  const url = "http://localhost:" + server.address().port + "/";
27
- await execFileAsync(process.execPath, [
26
+ const { stderr } = await execFileAsync(process.execPath, [
28
27
  "single-file-node.js", url, outputPath,
29
28
  "--compress-content",
30
29
  "--output-json"
31
30
  ], { cwd: cliDirectory });
32
- const pageData = JSON.parse(await readFile(outputPath, "utf8"));
31
+ let rawPageData;
32
+ try {
33
+ rawPageData = await readFile(outputPath, "utf8");
34
+ } catch (error) {
35
+ throw new Error("missing output file, stderr: " + stderr, { cause: error });
36
+ }
37
+ const pageData = JSON.parse(rawPageData);
33
38
  assert.ok(pageData.binaryContent);
34
- const content = new TextDecoder("latin1").decode(Uint8Array.fromBase64(pageData.binaryContent));
39
+ const content = Buffer.from(pageData.binaryContent, "base64").toString("latin1");
35
40
  assert.ok(content.includes(ZIP_SIGNATURE));
36
41
  } finally {
37
42
  await rm(directory, { recursive: true });