patchrome 0.1.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/LICENSE +21 -0
- package/README.md +514 -0
- package/bin/patchrome.js +10 -0
- package/dist/build-id.d.ts +2 -0
- package/dist/build-id.js +21 -0
- package/dist/challenges.d.ts +22 -0
- package/dist/challenges.js +97 -0
- package/dist/chrome-profiles.d.ts +17 -0
- package/dist/chrome-profiles.js +141 -0
- package/dist/cli-options.d.ts +131 -0
- package/dist/cli-options.js +43 -0
- package/dist/cli.d.ts +48 -0
- package/dist/cli.js +572 -0
- package/dist/client.d.ts +16 -0
- package/dist/client.js +210 -0
- package/dist/commands.d.ts +58 -0
- package/dist/commands.js +1076 -0
- package/dist/completions.d.ts +1 -0
- package/dist/completions.js +114 -0
- package/dist/copy-guard.d.ts +75 -0
- package/dist/copy-guard.js +167 -0
- package/dist/daemon.d.ts +7 -0
- package/dist/daemon.js +313 -0
- package/dist/diagnostics.d.ts +44 -0
- package/dist/diagnostics.js +117 -0
- package/dist/engine.d.ts +51 -0
- package/dist/engine.js +257 -0
- package/dist/events.d.ts +41 -0
- package/dist/events.js +106 -0
- package/dist/extract.d.ts +27 -0
- package/dist/extract.js +62 -0
- package/dist/focus.d.ts +1 -0
- package/dist/focus.js +44 -0
- package/dist/glob.d.ts +4 -0
- package/dist/glob.js +63 -0
- package/dist/har.d.ts +105 -0
- package/dist/har.js +88 -0
- package/dist/history.d.ts +35 -0
- package/dist/history.js +277 -0
- package/dist/host-platform.d.ts +5 -0
- package/dist/host-platform.js +19 -0
- package/dist/host-prompts-macos.d.ts +2 -0
- package/dist/host-prompts-macos.js +102 -0
- package/dist/host-prompts-wsl.d.ts +6 -0
- package/dist/host-prompts-wsl.js +64 -0
- package/dist/host-prompts.d.ts +3 -0
- package/dist/host-prompts.js +25 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +47 -0
- package/dist/network.d.ts +54 -0
- package/dist/network.js +204 -0
- package/dist/origin-storage.d.ts +31 -0
- package/dist/origin-storage.js +82 -0
- package/dist/paths.d.ts +17 -0
- package/dist/paths.js +52 -0
- package/dist/pipe.d.ts +9 -0
- package/dist/pipe.js +73 -0
- package/dist/profile-mode.d.ts +10 -0
- package/dist/profile-mode.js +42 -0
- package/dist/protocol-help.d.ts +34 -0
- package/dist/protocol-help.js +66 -0
- package/dist/protocol.d.ts +49 -0
- package/dist/protocol.js +89 -0
- package/dist/refs.d.ts +9 -0
- package/dist/refs.js +46 -0
- package/dist/routes.d.ts +20 -0
- package/dist/routes.js +106 -0
- package/dist/runner.d.ts +20 -0
- package/dist/runner.js +81 -0
- package/dist/session-name.d.ts +9 -0
- package/dist/session-name.js +50 -0
- package/dist/session-store.d.ts +5 -0
- package/dist/session-store.js +58 -0
- package/dist/sessions.d.ts +47 -0
- package/dist/sessions.js +171 -0
- package/dist/tab-groups.d.ts +9 -0
- package/dist/tab-groups.js +13 -0
- package/dist/targets.d.ts +43 -0
- package/dist/targets.js +229 -0
- package/dist/validate.d.ts +3 -0
- package/dist/validate.js +31 -0
- package/dist/wait.d.ts +24 -0
- package/dist/wait.js +88 -0
- package/examples/go/go.mod +3 -0
- package/examples/go/main.go +104 -0
- package/examples/hn-front-page.sh +18 -0
- package/examples/hn-front-page.ts +24 -0
- package/examples/hn_front_page.py +56 -0
- package/extension/tab-groups/manifest.json +8 -0
- package/extension/tab-groups/service-worker.js +41 -0
- package/package.json +60 -0
- package/skills/patchrome/SKILL.md +74 -0
- package/skills/patchrome/references/commands.md +130 -0
- package/skills/patchrome/references/debugging.md +20 -0
- package/skills/patchrome/references/hard-pages.md +49 -0
- package/skills/patchrome/references/logins.md +46 -0
- package/skills/patchrome/references/scraping.md +51 -0
- package/skills/patchrome/references/scripting.md +79 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { helperFailure, parseApprovalAnswer } from "./copy-guard.js";
|
|
4
|
+
const run = promisify(execFile);
|
|
5
|
+
// WSL interop runs Windows' powershell.exe from Linux. Scripts go in -EncodedCommand, and every string from
|
|
6
|
+
// patchrome goes in as base64, so a session name or file path can never become PowerShell code.
|
|
7
|
+
export function encodedPowerShell(script) {
|
|
8
|
+
return Buffer.from(script, "utf16le").toString("base64");
|
|
9
|
+
}
|
|
10
|
+
export function powerShellString(text) {
|
|
11
|
+
return `([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(text, "utf8").toString("base64")}')))`;
|
|
12
|
+
}
|
|
13
|
+
// Windows Hello is the prompt a script cannot answer. Without Hello set up there is no such prompt, and the
|
|
14
|
+
// copy is refused rather than falling back to a clickable message box.
|
|
15
|
+
export function approvalScript(reason, timeoutMs) {
|
|
16
|
+
return `
|
|
17
|
+
$ErrorActionPreference = 'Stop'
|
|
18
|
+
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
|
19
|
+
$asTask = [System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation\`1' } | Select-Object -First 1
|
|
20
|
+
function Wait-WinRt($operation, [Type]$resultType, [int]$timeoutMs) {
|
|
21
|
+
$task = $asTask.MakeGenericMethod($resultType).Invoke($null, @($operation))
|
|
22
|
+
if (-not $task.Wait($timeoutMs)) { return $null }
|
|
23
|
+
return $task.Result
|
|
24
|
+
}
|
|
25
|
+
$verifier = [Windows.Security.Credentials.UI.UserConsentVerifier, Windows.Security.Credentials.UI, ContentType = WindowsRuntime]
|
|
26
|
+
$availability = Wait-WinRt ($verifier::CheckAvailabilityAsync()) ([Windows.Security.Credentials.UI.UserConsentVerifierAvailability]) 10000
|
|
27
|
+
if ("$availability" -ne 'Available') { "unavailable Windows Hello is $availability"; return }
|
|
28
|
+
$result = Wait-WinRt ($verifier::RequestVerificationAsync(${powerShellString(reason)})) ([Windows.Security.Credentials.UI.UserConsentVerificationResult]) ${timeoutMs}
|
|
29
|
+
if ($null -eq $result) { 'timed_out' }
|
|
30
|
+
elseif ("$result" -eq 'Verified') { 'approved' }
|
|
31
|
+
elseif ("$result" -eq 'Canceled' -or "$result" -eq 'RetriesExhausted') { 'denied' }
|
|
32
|
+
else { "unavailable Windows Hello answered $result" }
|
|
33
|
+
`;
|
|
34
|
+
}
|
|
35
|
+
export function notifyScript(title, body) {
|
|
36
|
+
return `
|
|
37
|
+
$ErrorActionPreference = 'Stop'
|
|
38
|
+
$manager = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
|
|
39
|
+
$null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
|
|
40
|
+
$toast = $manager::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
|
|
41
|
+
$texts = $toast.GetElementsByTagName('text')
|
|
42
|
+
$null = $texts.Item(0).AppendChild($toast.CreateTextNode(${powerShellString(title)}))
|
|
43
|
+
$null = $texts.Item(1).AppendChild($toast.CreateTextNode(${powerShellString(body)}))
|
|
44
|
+
# Toasts need a registered app id; Windows PowerShell's own is present on every Windows install.
|
|
45
|
+
$manager::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show([Windows.UI.Notifications.ToastNotification]::new($toast))
|
|
46
|
+
`;
|
|
47
|
+
}
|
|
48
|
+
function powerShell(script, timeoutMs) {
|
|
49
|
+
return run("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encodedPowerShell(script)], { timeout: timeoutMs });
|
|
50
|
+
}
|
|
51
|
+
export const wslPrompts = {
|
|
52
|
+
async askApproval(reason, timeoutMs) {
|
|
53
|
+
try {
|
|
54
|
+
const { stdout } = await powerShell(approvalScript(reason, timeoutMs), timeoutMs + 20_000);
|
|
55
|
+
return parseApprovalAnswer(stdout);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
return { answer: "unavailable", detail: helperFailure("powershell.exe", err) };
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
async notify(title, body) {
|
|
62
|
+
await powerShell(notifyScript(title, body), 20_000);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { macosPrompts } from "./host-prompts-macos.js";
|
|
2
|
+
import { wslPrompts } from "./host-prompts-wsl.js";
|
|
3
|
+
// A desktop Linux has no prompt a script cannot click, so copies there are refused and only logged.
|
|
4
|
+
const noPrompts = (platform, log) => ({
|
|
5
|
+
async askApproval() {
|
|
6
|
+
return {
|
|
7
|
+
answer: "unavailable",
|
|
8
|
+
detail: `no approval prompt on ${platform}; patchrome asks through Touch ID on macOS and Windows Hello on WSL`,
|
|
9
|
+
};
|
|
10
|
+
},
|
|
11
|
+
async notify(title, body) {
|
|
12
|
+
log(`${title}: ${body}`);
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
export function hostPromptsFor(platform, log, macosBundlesDir) {
|
|
16
|
+
switch (platform) {
|
|
17
|
+
case "macos":
|
|
18
|
+
return macosPrompts(macosBundlesDir, log);
|
|
19
|
+
case "wsl":
|
|
20
|
+
return wslPrompts;
|
|
21
|
+
case "linux":
|
|
22
|
+
case "unsupported":
|
|
23
|
+
return noPrompts(platform, log);
|
|
24
|
+
}
|
|
25
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { CommandError, errorCodes, type ErrorCode } from "./protocol.ts";
|
|
2
|
+
export { CommandError, errorCodes, type ErrorCode };
|
|
3
|
+
export interface ConnectOptions {
|
|
4
|
+
session?: string;
|
|
5
|
+
profile?: string;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
env?: NodeJS.ProcessEnv;
|
|
8
|
+
}
|
|
9
|
+
export type CommandFields = Record<string, unknown>;
|
|
10
|
+
export declare class Patchrome {
|
|
11
|
+
#private;
|
|
12
|
+
constructor(options?: ConnectOptions);
|
|
13
|
+
run(...words: string[]): Promise<CommandFields>;
|
|
14
|
+
stream(words: string[], onEvent: (fields: CommandFields) => void): Promise<CommandFields>;
|
|
15
|
+
close(): void;
|
|
16
|
+
}
|
|
17
|
+
export declare function connect(options?: ConnectOptions): Patchrome;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { CommandError, errorCodes } from "./protocol.js";
|
|
2
|
+
import { CommandRunner } from "./runner.js";
|
|
3
|
+
import { lookupProcess, resolveSessionName } from "./session-name.js";
|
|
4
|
+
export { CommandError, errorCodes };
|
|
5
|
+
// The patchrome CLI as a Node library. Each call takes the words you would type after `patchrome` and
|
|
6
|
+
// returns what `--json` puts under `data`; a failed command throws a CommandError with its code.
|
|
7
|
+
//
|
|
8
|
+
// const browser = connect({ session: "prices" });
|
|
9
|
+
// await browser.run("open", "https://shop.example");
|
|
10
|
+
// const { rows } = await browser.run("extract", schema, "--inline");
|
|
11
|
+
export class Patchrome {
|
|
12
|
+
#runner;
|
|
13
|
+
#timeoutMs;
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
const env = {
|
|
16
|
+
...(options.env ?? process.env),
|
|
17
|
+
...(options.profile === undefined ? {} : { PATCHROME_PROFILE: options.profile }),
|
|
18
|
+
};
|
|
19
|
+
let session = options.session;
|
|
20
|
+
this.#runner = new CommandRunner(env, () => {
|
|
21
|
+
session ??= resolveSessionName(env, process.ppid, lookupProcess);
|
|
22
|
+
return session;
|
|
23
|
+
});
|
|
24
|
+
this.#timeoutMs = options.timeoutMs;
|
|
25
|
+
}
|
|
26
|
+
async run(...words) {
|
|
27
|
+
return this.stream(words, () => { });
|
|
28
|
+
}
|
|
29
|
+
// For `watch` and `console --follow`: onEvent gets each event's fields, and the promise resolves when the
|
|
30
|
+
// stream stops.
|
|
31
|
+
async stream(words, onEvent) {
|
|
32
|
+
const argv = this.#timeoutMs === undefined || words.includes("--timeout-ms")
|
|
33
|
+
? words
|
|
34
|
+
: ["--timeout-ms", String(this.#timeoutMs), ...words];
|
|
35
|
+
const result = await this.#runner.run(argv, { onStream: (stream) => onEvent(stream.fields) });
|
|
36
|
+
if (!result.ok)
|
|
37
|
+
throw new CommandError(result.error.code, result.error.message, result.error.hint);
|
|
38
|
+
return result.data;
|
|
39
|
+
}
|
|
40
|
+
// Idle connections never keep the process alive, so close is only needed to drop them early.
|
|
41
|
+
close() {
|
|
42
|
+
this.#runner.close();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function connect(options = {}) {
|
|
46
|
+
return new Patchrome(options);
|
|
47
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Tab } from "./sessions.ts";
|
|
2
|
+
export declare const resourceTypes: readonly ["document", "stylesheet", "image", "media", "font", "script", "texttrack", "xhr", "fetch", "eventsource", "websocket", "manifest", "other"];
|
|
3
|
+
export type ResourceType = (typeof resourceTypes)[number];
|
|
4
|
+
export interface RequestTiming {
|
|
5
|
+
startTime: number;
|
|
6
|
+
domainLookupStart: number;
|
|
7
|
+
domainLookupEnd: number;
|
|
8
|
+
connectStart: number;
|
|
9
|
+
secureConnectionStart: number;
|
|
10
|
+
connectEnd: number;
|
|
11
|
+
requestStart: number;
|
|
12
|
+
responseStart: number;
|
|
13
|
+
responseEnd: number;
|
|
14
|
+
}
|
|
15
|
+
export interface NetworkEntry {
|
|
16
|
+
id: string;
|
|
17
|
+
tabId: string;
|
|
18
|
+
session: string;
|
|
19
|
+
method: string;
|
|
20
|
+
url: string;
|
|
21
|
+
resourceType: string;
|
|
22
|
+
startedAtMs: number;
|
|
23
|
+
requestHeaders: Record<string, string>;
|
|
24
|
+
postData: string | undefined;
|
|
25
|
+
state: "pending" | "finished" | "failed";
|
|
26
|
+
status: number | undefined;
|
|
27
|
+
statusText: string;
|
|
28
|
+
responseHeaders: Record<string, string>;
|
|
29
|
+
durationMs: number | undefined;
|
|
30
|
+
timing: RequestTiming | undefined;
|
|
31
|
+
failure: string | undefined;
|
|
32
|
+
}
|
|
33
|
+
export interface NetworkFilter {
|
|
34
|
+
urlGlob: string | undefined;
|
|
35
|
+
types: string | undefined;
|
|
36
|
+
status: string | undefined;
|
|
37
|
+
}
|
|
38
|
+
export declare class NetworkLog {
|
|
39
|
+
#private;
|
|
40
|
+
constructor(onSettled?: (entry: NetworkEntry) => void);
|
|
41
|
+
record(tab: Tab): void;
|
|
42
|
+
list(session: string, filter: NetworkFilter): NetworkEntry[];
|
|
43
|
+
droppedCount(session: string): number;
|
|
44
|
+
entry(session: string, id: string): NetworkEntry;
|
|
45
|
+
body(entry: NetworkEntry): Promise<Buffer>;
|
|
46
|
+
startHar(session: string): void;
|
|
47
|
+
stopHar(session: string): {
|
|
48
|
+
entries: NetworkEntry[];
|
|
49
|
+
droppedCount: number;
|
|
50
|
+
};
|
|
51
|
+
forget(session: string): void;
|
|
52
|
+
}
|
|
53
|
+
export declare function isTextual(headers: Record<string, string>): boolean;
|
|
54
|
+
export declare function bodyExtension(headers: Record<string, string>): string;
|
package/dist/network.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { parseStatusFilter, urlGlobMatches } from "./glob.js";
|
|
2
|
+
import { CommandError } from "./protocol.js";
|
|
3
|
+
export const resourceTypes = [
|
|
4
|
+
"document",
|
|
5
|
+
"stylesheet",
|
|
6
|
+
"image",
|
|
7
|
+
"media",
|
|
8
|
+
"font",
|
|
9
|
+
"script",
|
|
10
|
+
"texttrack",
|
|
11
|
+
"xhr",
|
|
12
|
+
"fetch",
|
|
13
|
+
"eventsource",
|
|
14
|
+
"websocket",
|
|
15
|
+
"manifest",
|
|
16
|
+
"other",
|
|
17
|
+
];
|
|
18
|
+
// Enough for an agent to find the API call behind a page without holding a busy session's whole history.
|
|
19
|
+
const bufferLimitPerSession = 1000;
|
|
20
|
+
const harLimitPerSession = 10_000;
|
|
21
|
+
const postDataLimitBytes = 64 * 1024;
|
|
22
|
+
export class NetworkLog {
|
|
23
|
+
#sessions = new Map();
|
|
24
|
+
#responses = new Map();
|
|
25
|
+
#nextId = 1;
|
|
26
|
+
#onSettled;
|
|
27
|
+
// onSettled fires once per request, when it finishes or fails.
|
|
28
|
+
constructor(onSettled = () => { }) {
|
|
29
|
+
this.#onSettled = onSettled;
|
|
30
|
+
}
|
|
31
|
+
record(tab) {
|
|
32
|
+
const pending = new WeakMap();
|
|
33
|
+
const page = tab.page;
|
|
34
|
+
page.on("request", (request) => {
|
|
35
|
+
const postData = request.postData() ?? undefined;
|
|
36
|
+
const entry = {
|
|
37
|
+
id: `n${this.#nextId++}`,
|
|
38
|
+
tabId: tab.id,
|
|
39
|
+
session: tab.session,
|
|
40
|
+
method: request.method(),
|
|
41
|
+
url: request.url(),
|
|
42
|
+
resourceType: request.resourceType(),
|
|
43
|
+
startedAtMs: Date.now(),
|
|
44
|
+
requestHeaders: request.headers(),
|
|
45
|
+
postData: postData !== undefined && Buffer.byteLength(postData) > postDataLimitBytes ? undefined : postData,
|
|
46
|
+
state: "pending",
|
|
47
|
+
status: undefined,
|
|
48
|
+
statusText: "",
|
|
49
|
+
responseHeaders: {},
|
|
50
|
+
durationMs: undefined,
|
|
51
|
+
timing: undefined,
|
|
52
|
+
failure: undefined,
|
|
53
|
+
};
|
|
54
|
+
pending.set(request, entry);
|
|
55
|
+
this.#append(tab.session, entry);
|
|
56
|
+
});
|
|
57
|
+
page.on("response", (response) => {
|
|
58
|
+
const entry = pending.get(response.request());
|
|
59
|
+
if (!entry)
|
|
60
|
+
return;
|
|
61
|
+
entry.status = response.status();
|
|
62
|
+
entry.statusText = response.statusText();
|
|
63
|
+
entry.responseHeaders = response.headers();
|
|
64
|
+
this.#responses.set(entry.id, response);
|
|
65
|
+
});
|
|
66
|
+
page.on("requestfinished", (request) => {
|
|
67
|
+
const entry = pending.get(request);
|
|
68
|
+
if (!entry)
|
|
69
|
+
return;
|
|
70
|
+
entry.state = "finished";
|
|
71
|
+
entry.durationMs = Date.now() - entry.startedAtMs;
|
|
72
|
+
entry.timing = request.timing();
|
|
73
|
+
this.#onSettled(entry);
|
|
74
|
+
});
|
|
75
|
+
page.on("requestfailed", (request) => {
|
|
76
|
+
const entry = pending.get(request);
|
|
77
|
+
if (!entry)
|
|
78
|
+
return;
|
|
79
|
+
entry.state = "failed";
|
|
80
|
+
entry.durationMs = Date.now() - entry.startedAtMs;
|
|
81
|
+
entry.failure = request.failure()?.errorText ?? "failed";
|
|
82
|
+
this.#onSettled(entry);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
list(session, filter) {
|
|
86
|
+
const matchesStatus = filter.status === undefined ? () => true : parseStatusFilter(filter.status);
|
|
87
|
+
const types = filter.types === undefined ? undefined : parseTypes(filter.types);
|
|
88
|
+
return (this.#sessions.get(session)?.entries ?? []).filter((entry) => (filter.urlGlob === undefined || urlGlobMatches(filter.urlGlob, entry.url)) &&
|
|
89
|
+
(types === undefined || types.has(entry.resourceType)) &&
|
|
90
|
+
matchesStatus(entry.status));
|
|
91
|
+
}
|
|
92
|
+
droppedCount(session) {
|
|
93
|
+
return this.#sessions.get(session)?.droppedCount ?? 0;
|
|
94
|
+
}
|
|
95
|
+
entry(session, id) {
|
|
96
|
+
const found = this.#sessions.get(session)?.entries.find((entry) => entry.id === id) ??
|
|
97
|
+
this.#sessions.get(session)?.har?.find((entry) => entry.id === id);
|
|
98
|
+
if (!found) {
|
|
99
|
+
throw new CommandError("bad_args", `no request ${id} in this session`, "run `patchrome network list`; old requests leave the buffer after 1000");
|
|
100
|
+
}
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
103
|
+
// Chrome keeps a body only while the page that loaded it is alive, so bodies are read on demand.
|
|
104
|
+
async body(entry) {
|
|
105
|
+
const response = this.#responses.get(entry.id);
|
|
106
|
+
if (!response)
|
|
107
|
+
throw new CommandError("bad_args", `request ${entry.id} has no response`, entry.failure === undefined ? "the request is still pending" : `it failed: ${entry.failure}`);
|
|
108
|
+
try {
|
|
109
|
+
return await response.body();
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
const message = err instanceof Error ? (err.message.split("\n")[0] ?? err.message) : String(err);
|
|
113
|
+
throw new CommandError("tab_gone", `body of ${entry.id} is no longer available: ${message}`, "Chrome drops bodies when the tab closes or navigates away; reload and fetch it sooner");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
startHar(session) {
|
|
117
|
+
const network = this.#sessionFor(session);
|
|
118
|
+
if (network.har)
|
|
119
|
+
throw new CommandError("bad_args", "a HAR recording is already running for this session", "run `patchrome network har stop` first");
|
|
120
|
+
network.har = [];
|
|
121
|
+
network.harDroppedCount = 0;
|
|
122
|
+
}
|
|
123
|
+
stopHar(session) {
|
|
124
|
+
const network = this.#sessions.get(session);
|
|
125
|
+
if (!network?.har)
|
|
126
|
+
throw new CommandError("bad_args", "no HAR recording is running for this session", "run `patchrome network har start`");
|
|
127
|
+
const result = { entries: network.har, droppedCount: network.harDroppedCount };
|
|
128
|
+
network.har = undefined;
|
|
129
|
+
this.#releaseResponses(result.entries.filter((entry) => !network.entries.includes(entry)));
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
forget(session) {
|
|
133
|
+
const network = this.#sessions.get(session);
|
|
134
|
+
if (!network)
|
|
135
|
+
return;
|
|
136
|
+
this.#releaseResponses([...network.entries, ...(network.har ?? [])]);
|
|
137
|
+
this.#sessions.delete(session);
|
|
138
|
+
}
|
|
139
|
+
#append(session, entry) {
|
|
140
|
+
const network = this.#sessionFor(session);
|
|
141
|
+
network.entries.push(entry);
|
|
142
|
+
if (network.entries.length > bufferLimitPerSession) {
|
|
143
|
+
const evicted = network.entries.shift();
|
|
144
|
+
network.droppedCount++;
|
|
145
|
+
if (evicted && !network.har?.includes(evicted))
|
|
146
|
+
this.#releaseResponses([evicted]);
|
|
147
|
+
}
|
|
148
|
+
if (network.har) {
|
|
149
|
+
if (network.har.length < harLimitPerSession)
|
|
150
|
+
network.har.push(entry);
|
|
151
|
+
else
|
|
152
|
+
network.harDroppedCount++;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
#releaseResponses(entries) {
|
|
156
|
+
for (const entry of entries)
|
|
157
|
+
this.#responses.delete(entry.id);
|
|
158
|
+
}
|
|
159
|
+
#sessionFor(session) {
|
|
160
|
+
let network = this.#sessions.get(session);
|
|
161
|
+
if (!network) {
|
|
162
|
+
network = { entries: [], droppedCount: 0, har: undefined, harDroppedCount: 0 };
|
|
163
|
+
this.#sessions.set(session, network);
|
|
164
|
+
}
|
|
165
|
+
return network;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function parseTypes(raw) {
|
|
169
|
+
const types = raw.split(",").map((part) => part.trim());
|
|
170
|
+
for (const type of types) {
|
|
171
|
+
if (!resourceTypes.includes(type)) {
|
|
172
|
+
throw new CommandError("bad_args", `--type ${type} is not a resource type`, `use any of ${resourceTypes.join(", ")}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return new Set(types);
|
|
176
|
+
}
|
|
177
|
+
export function isTextual(headers) {
|
|
178
|
+
const contentType = headers["content-type"] ?? "";
|
|
179
|
+
return /^text\/|json|javascript|xml|html|svg|x-www-form-urlencoded|graphql/i.test(contentType);
|
|
180
|
+
}
|
|
181
|
+
export function bodyExtension(headers) {
|
|
182
|
+
const contentType = headers["content-type"] ?? "";
|
|
183
|
+
if (/json/i.test(contentType))
|
|
184
|
+
return "json";
|
|
185
|
+
if (/html/i.test(contentType))
|
|
186
|
+
return "html";
|
|
187
|
+
if (/javascript/i.test(contentType))
|
|
188
|
+
return "js";
|
|
189
|
+
if (/css/i.test(contentType))
|
|
190
|
+
return "css";
|
|
191
|
+
if (/svg/i.test(contentType))
|
|
192
|
+
return "svg";
|
|
193
|
+
if (/xml/i.test(contentType))
|
|
194
|
+
return "xml";
|
|
195
|
+
if (/png/i.test(contentType))
|
|
196
|
+
return "png";
|
|
197
|
+
if (/jpe?g/i.test(contentType))
|
|
198
|
+
return "jpg";
|
|
199
|
+
if (/webp/i.test(contentType))
|
|
200
|
+
return "webp";
|
|
201
|
+
if (/^text\//i.test(contentType))
|
|
202
|
+
return "txt";
|
|
203
|
+
return "bin";
|
|
204
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface IndexedDbIndex {
|
|
2
|
+
name: string;
|
|
3
|
+
keyPath: string | string[];
|
|
4
|
+
unique: boolean;
|
|
5
|
+
multiEntry: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface IndexedDbStore {
|
|
8
|
+
name: string;
|
|
9
|
+
keyPath: string | string[] | null;
|
|
10
|
+
autoIncrement: boolean;
|
|
11
|
+
indexes: IndexedDbIndex[];
|
|
12
|
+
records: Array<{
|
|
13
|
+
key: unknown;
|
|
14
|
+
value: unknown;
|
|
15
|
+
}>;
|
|
16
|
+
}
|
|
17
|
+
export interface IndexedDbDatabase {
|
|
18
|
+
name: string;
|
|
19
|
+
version: number;
|
|
20
|
+
stores: IndexedDbStore[];
|
|
21
|
+
}
|
|
22
|
+
export interface OriginStorage {
|
|
23
|
+
origin: string;
|
|
24
|
+
localStorage: Array<{
|
|
25
|
+
name: string;
|
|
26
|
+
value: string;
|
|
27
|
+
}>;
|
|
28
|
+
indexedDB: IndexedDbDatabase[];
|
|
29
|
+
}
|
|
30
|
+
export declare function readOriginStorageInPage(): Promise<Omit<OriginStorage, "origin">>;
|
|
31
|
+
export declare function writeOriginStorageInPage(storage: Omit<OriginStorage, "origin">): Promise<void>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// localStorage and IndexedDB for one origin, in a shape Playwright's evaluate carries both ways. Dates,
|
|
2
|
+
// typed arrays, ArrayBuffers and BigInts survive the trip; Blobs, Maps and Sets do not.
|
|
3
|
+
// Runs in the page, so it may only use what the page has.
|
|
4
|
+
export async function readOriginStorageInPage() {
|
|
5
|
+
const settle = (request) => new Promise((resolve, reject) => {
|
|
6
|
+
request.onsuccess = () => resolve(request.result);
|
|
7
|
+
request.onerror = () => reject(request.error);
|
|
8
|
+
});
|
|
9
|
+
const databases = [];
|
|
10
|
+
for (const info of await indexedDB.databases()) {
|
|
11
|
+
if (info.name === undefined)
|
|
12
|
+
continue;
|
|
13
|
+
const db = await settle(indexedDB.open(info.name));
|
|
14
|
+
try {
|
|
15
|
+
const stores = [];
|
|
16
|
+
for (const storeName of db.objectStoreNames) {
|
|
17
|
+
const store = db.transaction(storeName, "readonly").objectStore(storeName);
|
|
18
|
+
const [keys, values] = await Promise.all([settle(store.getAllKeys()), settle(store.getAll())]);
|
|
19
|
+
stores.push({
|
|
20
|
+
name: storeName,
|
|
21
|
+
keyPath: store.keyPath,
|
|
22
|
+
autoIncrement: store.autoIncrement,
|
|
23
|
+
indexes: [...store.indexNames].map((indexName) => {
|
|
24
|
+
const index = store.index(indexName);
|
|
25
|
+
return { name: indexName, keyPath: index.keyPath, unique: index.unique, multiEntry: index.multiEntry };
|
|
26
|
+
}),
|
|
27
|
+
records: keys.map((key, position) => ({ key, value: values[position] })),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
databases.push({ name: info.name, version: db.version, stores });
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
db.close();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { localStorage: Object.entries(localStorage).map(([name, value]) => ({ name, value })), indexedDB: databases };
|
|
37
|
+
}
|
|
38
|
+
// localStorage items are added next to what the origin has. Each IndexedDB database is replaced whole:
|
|
39
|
+
// merging records into a schema of another version would leave the site's data half upgraded.
|
|
40
|
+
export async function writeOriginStorageInPage(storage) {
|
|
41
|
+
const settle = (request) => new Promise((resolve, reject) => {
|
|
42
|
+
request.onsuccess = () => resolve(request.result);
|
|
43
|
+
request.onerror = () => reject(request.error);
|
|
44
|
+
});
|
|
45
|
+
for (const { name, value } of storage.localStorage)
|
|
46
|
+
localStorage.setItem(name, value);
|
|
47
|
+
for (const database of storage.indexedDB) {
|
|
48
|
+
await settle(indexedDB.deleteDatabase(database.name));
|
|
49
|
+
const opening = indexedDB.open(database.name, database.version);
|
|
50
|
+
opening.onupgradeneeded = () => {
|
|
51
|
+
for (const store of database.stores) {
|
|
52
|
+
const created = opening.result.createObjectStore(store.name, {
|
|
53
|
+
keyPath: store.keyPath,
|
|
54
|
+
autoIncrement: store.autoIncrement,
|
|
55
|
+
});
|
|
56
|
+
for (const index of store.indexes)
|
|
57
|
+
created.createIndex(index.name, index.keyPath, { unique: index.unique, multiEntry: index.multiEntry });
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const db = await settle(opening);
|
|
61
|
+
try {
|
|
62
|
+
for (const store of database.stores) {
|
|
63
|
+
const transaction = db.transaction(store.name, "readwrite");
|
|
64
|
+
const objectStore = transaction.objectStore(store.name);
|
|
65
|
+
for (const { key, value } of store.records) {
|
|
66
|
+
if (store.keyPath === null)
|
|
67
|
+
objectStore.put(value, key);
|
|
68
|
+
else
|
|
69
|
+
objectStore.put(value);
|
|
70
|
+
}
|
|
71
|
+
await new Promise((resolve, reject) => {
|
|
72
|
+
transaction.oncomplete = () => resolve();
|
|
73
|
+
transaction.onerror = () => reject(transaction.error);
|
|
74
|
+
transaction.onabort = () => reject(transaction.error);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
db.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare function isValidName(name: string): boolean;
|
|
2
|
+
export declare function cacheRoot(env?: NodeJS.ProcessEnv): string;
|
|
3
|
+
export declare function auditLogPathFrom(env?: NodeJS.ProcessEnv): string;
|
|
4
|
+
export declare function approvalBundlesDirFrom(env?: NodeJS.ProcessEnv): string;
|
|
5
|
+
export interface ProfilePaths {
|
|
6
|
+
profileDir: string;
|
|
7
|
+
profileConfigPath: string;
|
|
8
|
+
chromeProfileDir: string;
|
|
9
|
+
socketPath: string;
|
|
10
|
+
lockDir: string;
|
|
11
|
+
logPath: string;
|
|
12
|
+
sessionsDir: string;
|
|
13
|
+
savedSessionsPath: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function profilePaths(profile: string, env?: NodeJS.ProcessEnv): ProfilePaths;
|
|
16
|
+
export declare function sessionFolderName(session: string): string;
|
|
17
|
+
export declare function idleMsFrom(env: NodeJS.ProcessEnv): number;
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CommandError } from "./protocol.js";
|
|
4
|
+
const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
5
|
+
// macOS caps a unix socket path at 104 bytes including the terminating NUL.
|
|
6
|
+
const maxSocketPathBytes = 103;
|
|
7
|
+
const defaultIdleMs = 30 * 60 * 1000;
|
|
8
|
+
export function isValidName(name) {
|
|
9
|
+
return namePattern.test(name);
|
|
10
|
+
}
|
|
11
|
+
export function cacheRoot(env = process.env) {
|
|
12
|
+
return env.PATCHROME_HOME ?? join(homedir(), ".cache", "patchrome");
|
|
13
|
+
}
|
|
14
|
+
// One log for every profile, so a copy from one profile into another shows up in one place.
|
|
15
|
+
export function auditLogPathFrom(env = process.env) {
|
|
16
|
+
return join(cacheRoot(env), "copy-audit.jsonl");
|
|
17
|
+
}
|
|
18
|
+
// macOS names the app asking for Touch ID, so the approval prompt runs from a patchrome.app built here.
|
|
19
|
+
export function approvalBundlesDirFrom(env = process.env) {
|
|
20
|
+
return join(cacheRoot(env), "approval-app");
|
|
21
|
+
}
|
|
22
|
+
export function profilePaths(profile, env = process.env) {
|
|
23
|
+
const profileDir = join(cacheRoot(env), profile);
|
|
24
|
+
const socketPath = join(profileDir, "daemon.sock");
|
|
25
|
+
if (Buffer.byteLength(socketPath) > maxSocketPathBytes) {
|
|
26
|
+
throw new CommandError("bad_args", `socket path ${socketPath} is over ${maxSocketPathBytes} bytes`, "use a shorter --profile name or PATCHROME_HOME");
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
profileDir,
|
|
30
|
+
profileConfigPath: join(profileDir, "profile.json"),
|
|
31
|
+
chromeProfileDir: join(profileDir, "chrome-profile"),
|
|
32
|
+
socketPath,
|
|
33
|
+
lockDir: join(profileDir, "daemon.lock"),
|
|
34
|
+
logPath: join(profileDir, "daemon.log"),
|
|
35
|
+
sessionsDir: join(profileDir, "sessions"),
|
|
36
|
+
savedSessionsPath: join(profileDir, "sessions.json"),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// Session names come from agent ids and ttys. Escaping is reversible, so `a/b` and `a_b` get different folders.
|
|
40
|
+
export function sessionFolderName(session) {
|
|
41
|
+
return session.replace(/[^A-Za-z0-9.-]/g, (char) => `_${char.codePointAt(0)?.toString(16)}_`);
|
|
42
|
+
}
|
|
43
|
+
export function idleMsFrom(env) {
|
|
44
|
+
const raw = env.PATCHROME_IDLE_MS;
|
|
45
|
+
if (raw === undefined)
|
|
46
|
+
return defaultIdleMs;
|
|
47
|
+
const idleMs = Number(raw);
|
|
48
|
+
if (!Number.isInteger(idleMs) || idleMs <= 0) {
|
|
49
|
+
throw new CommandError("bad_args", `PATCHROME_IDLE_MS must be a positive integer, got ${raw}`);
|
|
50
|
+
}
|
|
51
|
+
return idleMs;
|
|
52
|
+
}
|
package/dist/pipe.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Readable, Writable } from "node:stream";
|
|
2
|
+
import type { CommandRunner } from "./runner.ts";
|
|
3
|
+
export interface PipeOptions {
|
|
4
|
+
input: Readable;
|
|
5
|
+
output: Writable;
|
|
6
|
+
runner: CommandRunner;
|
|
7
|
+
isBail: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function runPipe({ input, output, runner, isBail }: PipeOptions): Promise<number>;
|