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 +0 -1
- package/deno.json +1 -1
- package/lib/browser.js +48 -4
- package/lib/cdp-client-util.js +2 -1
- package/lib/cdp-client.js +42 -4
- package/lib/version.js +1 -1
- package/package.json +1 -1
- package/single-file-cli-api.js +2 -1
- package/test/e2e/crawl.test.js +7 -5
- package/test/e2e/frame-gate.test.js +3 -2
- package/test/e2e/output-json.test.js +11 -6
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
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
|
-
|
|
154
|
+
try {
|
|
155
|
+
child.kill();
|
|
156
|
+
} catch {
|
|
157
|
+
// ignored
|
|
158
|
+
}
|
|
115
159
|
await child.status;
|
|
116
160
|
child = undefined;
|
|
117
161
|
}
|
package/lib/cdp-client-util.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1
|
+
export const version = "2.1.1";
|
package/package.json
CHANGED
package/single-file-cli-api.js
CHANGED
|
@@ -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.
|
|
296
|
+
pageData.binaryContent = Buffer.from(content).toString("base64");
|
|
296
297
|
}
|
|
297
298
|
pageData.doctype = undefined;
|
|
298
299
|
pageData.viewport = undefined;
|
package/test/e2e/crawl.test.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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 });
|