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.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +514 -0
  3. package/bin/patchrome.js +10 -0
  4. package/dist/build-id.d.ts +2 -0
  5. package/dist/build-id.js +21 -0
  6. package/dist/challenges.d.ts +22 -0
  7. package/dist/challenges.js +97 -0
  8. package/dist/chrome-profiles.d.ts +17 -0
  9. package/dist/chrome-profiles.js +141 -0
  10. package/dist/cli-options.d.ts +131 -0
  11. package/dist/cli-options.js +43 -0
  12. package/dist/cli.d.ts +48 -0
  13. package/dist/cli.js +572 -0
  14. package/dist/client.d.ts +16 -0
  15. package/dist/client.js +210 -0
  16. package/dist/commands.d.ts +58 -0
  17. package/dist/commands.js +1076 -0
  18. package/dist/completions.d.ts +1 -0
  19. package/dist/completions.js +114 -0
  20. package/dist/copy-guard.d.ts +75 -0
  21. package/dist/copy-guard.js +167 -0
  22. package/dist/daemon.d.ts +7 -0
  23. package/dist/daemon.js +313 -0
  24. package/dist/diagnostics.d.ts +44 -0
  25. package/dist/diagnostics.js +117 -0
  26. package/dist/engine.d.ts +51 -0
  27. package/dist/engine.js +257 -0
  28. package/dist/events.d.ts +41 -0
  29. package/dist/events.js +106 -0
  30. package/dist/extract.d.ts +27 -0
  31. package/dist/extract.js +62 -0
  32. package/dist/focus.d.ts +1 -0
  33. package/dist/focus.js +44 -0
  34. package/dist/glob.d.ts +4 -0
  35. package/dist/glob.js +63 -0
  36. package/dist/har.d.ts +105 -0
  37. package/dist/har.js +88 -0
  38. package/dist/history.d.ts +35 -0
  39. package/dist/history.js +277 -0
  40. package/dist/host-platform.d.ts +5 -0
  41. package/dist/host-platform.js +19 -0
  42. package/dist/host-prompts-macos.d.ts +2 -0
  43. package/dist/host-prompts-macos.js +102 -0
  44. package/dist/host-prompts-wsl.d.ts +6 -0
  45. package/dist/host-prompts-wsl.js +64 -0
  46. package/dist/host-prompts.d.ts +3 -0
  47. package/dist/host-prompts.js +25 -0
  48. package/dist/index.d.ts +17 -0
  49. package/dist/index.js +47 -0
  50. package/dist/network.d.ts +54 -0
  51. package/dist/network.js +204 -0
  52. package/dist/origin-storage.d.ts +31 -0
  53. package/dist/origin-storage.js +82 -0
  54. package/dist/paths.d.ts +17 -0
  55. package/dist/paths.js +52 -0
  56. package/dist/pipe.d.ts +9 -0
  57. package/dist/pipe.js +73 -0
  58. package/dist/profile-mode.d.ts +10 -0
  59. package/dist/profile-mode.js +42 -0
  60. package/dist/protocol-help.d.ts +34 -0
  61. package/dist/protocol-help.js +66 -0
  62. package/dist/protocol.d.ts +49 -0
  63. package/dist/protocol.js +89 -0
  64. package/dist/refs.d.ts +9 -0
  65. package/dist/refs.js +46 -0
  66. package/dist/routes.d.ts +20 -0
  67. package/dist/routes.js +106 -0
  68. package/dist/runner.d.ts +20 -0
  69. package/dist/runner.js +81 -0
  70. package/dist/session-name.d.ts +9 -0
  71. package/dist/session-name.js +50 -0
  72. package/dist/session-store.d.ts +5 -0
  73. package/dist/session-store.js +58 -0
  74. package/dist/sessions.d.ts +47 -0
  75. package/dist/sessions.js +171 -0
  76. package/dist/tab-groups.d.ts +9 -0
  77. package/dist/tab-groups.js +13 -0
  78. package/dist/targets.d.ts +43 -0
  79. package/dist/targets.js +229 -0
  80. package/dist/validate.d.ts +3 -0
  81. package/dist/validate.js +31 -0
  82. package/dist/wait.d.ts +24 -0
  83. package/dist/wait.js +88 -0
  84. package/examples/go/go.mod +3 -0
  85. package/examples/go/main.go +104 -0
  86. package/examples/hn-front-page.sh +18 -0
  87. package/examples/hn-front-page.ts +24 -0
  88. package/examples/hn_front_page.py +56 -0
  89. package/extension/tab-groups/manifest.json +8 -0
  90. package/extension/tab-groups/service-worker.js +41 -0
  91. package/package.json +60 -0
  92. package/skills/patchrome/SKILL.md +74 -0
  93. package/skills/patchrome/references/commands.md +130 -0
  94. package/skills/patchrome/references/debugging.md +20 -0
  95. package/skills/patchrome/references/hard-pages.md +49 -0
  96. package/skills/patchrome/references/logins.md +46 -0
  97. package/skills/patchrome/references/scraping.md +51 -0
  98. package/skills/patchrome/references/scripting.md +79 -0
package/dist/client.js ADDED
@@ -0,0 +1,210 @@
1
+ import { currentBuildId, moduleExtension } from "./build-id.js";
2
+ import { spawn } from "node:child_process";
3
+ import { mkdir, open, readFile, rm, stat, writeFile } from "node:fs/promises";
4
+ import { connect } from "node:net";
5
+ import { join } from "node:path";
6
+ import { setTimeout as sleep } from "node:timers/promises";
7
+ import { fileURLToPath } from "node:url";
8
+ import { idleMsFrom, profilePaths } from "./paths.js";
9
+ import { CommandError, } from "./protocol.js";
10
+ const daemonStartTimeoutMs = 30_000;
11
+ const lockPollMs = 100;
12
+ // A lock whose owner pid is dead is stale at once. Without a pid file yet, the owner may be between
13
+ // mkdir and writing it, so only a lock older than this is stale.
14
+ const ownerlessLockAgeMs = 5_000;
15
+ // One socket to one profile's daemon, carrying any number of requests told apart by id. A CLI call sends one
16
+ // request; `pipe` and the library send many over the same socket, so they pay for Node and the daemon
17
+ // handshake once. When the daemon goes away, pending requests fail and the next request reconnects,
18
+ // starting a daemon if it may.
19
+ export class DaemonConnection {
20
+ #profile;
21
+ #env;
22
+ #socket;
23
+ #pending = new Map();
24
+ #nextId = 1;
25
+ constructor(profile, env = process.env) {
26
+ idleMsFrom(env);
27
+ this.#profile = profile;
28
+ this.#env = env;
29
+ }
30
+ async request(request) {
31
+ const socket = await this.#connect(request.shouldStartDaemon);
32
+ const id = this.#nextId++;
33
+ const deadlineMs = request.timeoutMs + daemonStartTimeoutMs;
34
+ const answered = new Promise((resolve, reject) => {
35
+ const timer = setTimeout(() => {
36
+ this.#pending.delete(id);
37
+ this.#unrefWhenIdle(socket);
38
+ reject(new CommandError("daemon_unreachable", `daemon did not answer within ${deadlineMs} ms`));
39
+ }, deadlineMs);
40
+ this.#pending.set(id, { resolve, reject, onStream: request.onStream ?? (() => { }), timer });
41
+ });
42
+ socket.ref();
43
+ socket.write(`${JSON.stringify({
44
+ id,
45
+ session: request.session,
46
+ command: request.command,
47
+ args: request.args,
48
+ timeoutMs: request.timeoutMs,
49
+ argv: request.argv,
50
+ buildId: currentBuildId(this.#env),
51
+ })}\n`);
52
+ return answered;
53
+ }
54
+ close() {
55
+ void this.#socket?.then((socket) => socket.end(), () => { });
56
+ this.#socket = undefined;
57
+ }
58
+ #connect(shouldStartDaemon) {
59
+ if (this.#socket !== undefined)
60
+ return this.#socket;
61
+ const connecting = (async () => {
62
+ const paths = profilePaths(this.#profile, this.#env);
63
+ const existing = await tryConnect(paths.socketPath);
64
+ if (existing)
65
+ return this.#attach(existing);
66
+ if (!shouldStartDaemon)
67
+ throw new CommandError("daemon_unreachable", `no daemon running for profile ${this.#profile}`);
68
+ return this.#attach(await startDaemonAndConnect(this.#profile, paths, this.#env));
69
+ })();
70
+ this.#socket = connecting;
71
+ // A failed connect is not cached: the next request tries again.
72
+ connecting.catch(() => {
73
+ if (this.#socket === connecting)
74
+ this.#socket = undefined;
75
+ });
76
+ return connecting;
77
+ }
78
+ #attach(socket) {
79
+ let buffer = "";
80
+ socket.setEncoding("utf8");
81
+ socket.on("data", (chunk) => {
82
+ buffer += chunk;
83
+ let newlineIndex = buffer.indexOf("\n");
84
+ while (newlineIndex !== -1) {
85
+ const message = JSON.parse(buffer.slice(0, newlineIndex));
86
+ buffer = buffer.slice(newlineIndex + 1);
87
+ const pending = this.#pending.get(message.id);
88
+ if (pending !== undefined) {
89
+ if ("stream" in message) {
90
+ pending.onStream(message.stream);
91
+ }
92
+ else {
93
+ clearTimeout(pending.timer);
94
+ this.#pending.delete(message.id);
95
+ pending.resolve(message);
96
+ }
97
+ }
98
+ newlineIndex = buffer.indexOf("\n");
99
+ }
100
+ this.#unrefWhenIdle(socket);
101
+ });
102
+ const fail = (reason) => {
103
+ const connecting = this.#socket;
104
+ void connecting?.then((current) => {
105
+ if (current === socket && this.#socket === connecting)
106
+ this.#socket = undefined;
107
+ }, () => { });
108
+ for (const [id, pending] of this.#pending) {
109
+ clearTimeout(pending.timer);
110
+ this.#pending.delete(id);
111
+ pending.reject(new CommandError("daemon_unreachable", reason));
112
+ }
113
+ };
114
+ socket.on("error", (err) => fail(err.message));
115
+ socket.on("close", () => fail("daemon closed the connection without answering"));
116
+ return socket;
117
+ }
118
+ // An idle socket must not keep a script's process alive after its last request.
119
+ #unrefWhenIdle(socket) {
120
+ if (this.#pending.size === 0)
121
+ socket.unref();
122
+ }
123
+ }
124
+ // Three agents starting at once must end with one daemon. `mkdir` is atomic, so exactly one starter
125
+ // wins the lock and spawns; the rest poll the socket until the winner's daemon answers.
126
+ async function startDaemonAndConnect(profile, paths, env) {
127
+ await mkdir(paths.profileDir, { recursive: true });
128
+ const deadlineMs = Date.now() + daemonStartTimeoutMs;
129
+ while (Date.now() < deadlineMs) {
130
+ const socket = await tryConnect(paths.socketPath);
131
+ if (socket)
132
+ return socket;
133
+ if (await tryAcquireLock(paths.lockDir)) {
134
+ const existing = await tryConnect(paths.socketPath);
135
+ if (existing) {
136
+ await rm(paths.lockDir, { recursive: true, force: true });
137
+ return existing;
138
+ }
139
+ await spawnDaemon(profile, paths, env);
140
+ }
141
+ else {
142
+ await clearStaleLock(paths.lockDir);
143
+ }
144
+ await sleep(lockPollMs);
145
+ }
146
+ const logTail = (await readFile(paths.logPath, "utf8").catch(() => "")).trim().split("\n").slice(-3).join(" | ");
147
+ throw new CommandError("daemon_unreachable", `daemon for profile ${profile} did not start within ${daemonStartTimeoutMs} ms`, logTail === "" ? `see ${paths.logPath}` : `last log lines: ${logTail}`);
148
+ }
149
+ async function tryAcquireLock(lockDir) {
150
+ try {
151
+ await mkdir(lockDir);
152
+ }
153
+ catch (err) {
154
+ if (err.code === "EEXIST")
155
+ return false;
156
+ throw err;
157
+ }
158
+ await writeFile(join(lockDir, "owner.pid"), String(process.pid));
159
+ return true;
160
+ }
161
+ async function clearStaleLock(lockDir) {
162
+ try {
163
+ const ownerPid = Number(await readFile(join(lockDir, "owner.pid"), "utf8").catch(() => "0"));
164
+ if (ownerPid > 0) {
165
+ if (isAlive(ownerPid))
166
+ return;
167
+ }
168
+ else {
169
+ const { mtimeMs } = await stat(lockDir);
170
+ if (Date.now() - mtimeMs < ownerlessLockAgeMs)
171
+ return;
172
+ }
173
+ await rm(lockDir, { recursive: true, force: true });
174
+ }
175
+ catch {
176
+ // The lock vanished between checks, which means its owner finished.
177
+ }
178
+ }
179
+ function isAlive(pid) {
180
+ try {
181
+ process.kill(pid, 0);
182
+ return true;
183
+ }
184
+ catch {
185
+ return false;
186
+ }
187
+ }
188
+ async function spawnDaemon(profile, paths, env) {
189
+ const cliPath = fileURLToPath(new URL(`./cli${moduleExtension}`, import.meta.url));
190
+ const logFile = await open(paths.logPath, "a");
191
+ const child = spawn(process.execPath, [cliPath, "__daemon", "--profile", profile], {
192
+ detached: true,
193
+ stdio: ["ignore", logFile.fd, logFile.fd],
194
+ env,
195
+ });
196
+ // The lock passes to the daemon, which removes it once listening. Its pid replaces ours as owner.
197
+ await writeFile(join(paths.lockDir, "owner.pid"), String(child.pid ?? 0));
198
+ child.unref();
199
+ await logFile.close();
200
+ }
201
+ function tryConnect(socketPath) {
202
+ return new Promise((resolve) => {
203
+ const socket = connect(socketPath);
204
+ socket.once("connect", () => {
205
+ socket.removeAllListeners("error");
206
+ resolve(socket);
207
+ });
208
+ socket.once("error", () => resolve(undefined));
209
+ });
210
+ }
@@ -0,0 +1,58 @@
1
+ import type { Cookie } from "patchright";
2
+ import { type SessionEvents } from "./events.ts";
3
+ import type { CopyGuard } from "./copy-guard.ts";
4
+ import { type PageDiagnostics } from "./diagnostics.ts";
5
+ import type { BrowserEngine } from "./engine.ts";
6
+ import { type NetworkLog } from "./network.ts";
7
+ import type { ProfileMode } from "./profile-mode.ts";
8
+ import { type CommandArgs, type CommandData, type CommandName, type DaemonStreamLine } from "./protocol.ts";
9
+ import type { RouteTable } from "./routes.ts";
10
+ import type { SavedSession, SessionRegistry, Tab } from "./sessions.ts";
11
+ export interface CommandContext {
12
+ engine: BrowserEngine;
13
+ registry: SessionRegistry;
14
+ network: NetworkLog;
15
+ routes: RouteTable;
16
+ diagnostics: PageDiagnostics;
17
+ events: SessionEvents;
18
+ mode: ProfileMode;
19
+ trace: {
20
+ owner: string | undefined;
21
+ };
22
+ consoleCaptureReady: (tab: Tab) => Promise<void>;
23
+ version: string;
24
+ buildId: string;
25
+ sessionsDir: string;
26
+ profile: string;
27
+ startedAtMs: number;
28
+ requestShutdown: () => void;
29
+ regroupTabs: (session: string) => Promise<void>;
30
+ savedSessionNames: () => string[];
31
+ forgetSavedSession: (session: string) => void;
32
+ copyGuard: CopyGuard;
33
+ }
34
+ export interface CommandCall {
35
+ session: string;
36
+ command: CommandName;
37
+ args: CommandArgs;
38
+ timeoutMs: number;
39
+ emit: (stream: DaemonStreamLine["stream"]) => void;
40
+ disconnected: AbortSignal;
41
+ }
42
+ export declare function runCommand(ctx: CommandContext, call: CommandCall): Promise<CommandData>;
43
+ export declare function restoreSession(ctx: CommandContext, saved: SavedSession, timeoutMs: number): Promise<{
44
+ restored: string[];
45
+ dropped: string[];
46
+ }>;
47
+ interface StorageState {
48
+ cookies: Cookie[];
49
+ origins: Array<{
50
+ origin: string;
51
+ localStorage: Array<{
52
+ name: string;
53
+ value: string;
54
+ }>;
55
+ }>;
56
+ }
57
+ export declare function parseStorageState(raw: string): StorageState;
58
+ export {};