pi-browser-harness 0.10.2 → 0.11.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/CHANGELOG.md +28 -0
- package/README.md +5 -9
- package/package.json +5 -2
- package/skills/pi-browser-harness/SKILL.md +1 -1
- package/src/cdp/commands.ts +256 -0
- package/src/cdp/console-buffer.ts +10 -85
- package/src/cdp/discovery.ts +28 -71
- package/src/cdp/errors.ts +4 -0
- package/src/cdp/events.ts +170 -0
- package/src/cdp/network-buffer.ts +42 -109
- package/src/cdp/ownership.ts +0 -17
- package/src/cdp/record-store.ts +61 -0
- package/src/cdp/session.ts +205 -193
- package/src/cdp/target-factory.ts +10 -80
- package/src/cdp/types.ts +21 -12
- package/src/client.ts +39 -95
- package/src/daemon/bridge.ts +150 -203
- package/src/daemon/index.ts +0 -28
- package/src/daemon/protocol.ts +6 -68
- package/src/daemon/queue.ts +85 -0
- package/src/daemon/server.ts +24 -99
- package/src/daemon/spawn.ts +18 -68
- package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +18 -51
- package/src/domains/ax-tree.ts +179 -0
- package/src/domains/box.ts +51 -0
- package/src/domains/cdp-call.ts +55 -0
- package/src/domains/click.ts +19 -32
- package/src/domains/console.ts +48 -2
- package/src/domains/dialog.ts +8 -5
- package/src/domains/drag.ts +8 -7
- package/src/domains/element-call.ts +117 -0
- package/src/domains/files.ts +22 -26
- package/src/domains/fill-engine.ts +80 -0
- package/src/domains/form-batch.ts +65 -0
- package/src/domains/form.ts +116 -105
- package/src/domains/history.ts +13 -9
- package/src/domains/isolated-tab.ts +26 -55
- package/src/domains/js.ts +29 -42
- package/src/domains/keyboard.ts +36 -27
- package/src/domains/navigate.ts +16 -23
- package/src/domains/network.ts +60 -10
- package/src/domains/page.ts +24 -3
- package/src/domains/readpage/read-page.ts +74 -19
- package/src/domains/readpage/readability.ts +0 -24
- package/src/domains/ref-resolve.ts +24 -58
- package/src/domains/render.ts +6 -27
- package/src/domains/screenshot-capture.ts +80 -0
- package/src/domains/screenshot.ts +9 -86
- package/src/domains/scroll.ts +10 -11
- package/src/domains/search/google-serp.ts +0 -29
- package/src/domains/search/web-search.ts +48 -20
- package/src/domains/setup.ts +1 -11
- package/src/domains/snapshot.ts +35 -276
- package/src/domains/tabs.ts +14 -7
- package/src/domains/target.ts +35 -0
- package/src/domains/viewport.ts +5 -3
- package/src/index.ts +8 -58
- package/src/profile/bind.ts +8 -57
- package/src/profile/browser-process.ts +75 -126
- package/src/profile/command.ts +50 -29
- package/src/profile/launch.ts +22 -40
- package/src/profile/list.ts +14 -54
- package/src/profile/paths.ts +2 -65
- package/src/profile/store.ts +35 -56
- package/src/registry.ts +30 -33
- package/src/schemas/parse.ts +23 -0
- package/src/setup.ts +0 -25
- package/src/state.ts +16 -16
- package/src/util/debug.ts +8 -0
- package/src/util/guards.ts +30 -0
- package/src/util/js-template.ts +1 -16
- package/src/util/mutex.ts +0 -5
- package/src/util/result.ts +0 -6
- package/src/util/sharp-shim.ts +12 -19
- package/src/util/tool.ts +17 -42
- package/src/util/truncate.ts +6 -0
- package/tsconfig.json +1 -16
- package/skills/deep-research/SKILL.md +0 -66
- package/src/cdp/event-queue.ts +0 -128
- package/src/cdp/transport.ts +0 -156
- package/src/cdp/window-binding.ts +0 -15
- package/src/deep-research.ts +0 -41
- package/src/domains/forms.ts +0 -355
- package/src/domains/readpage/capture.ts +0 -46
- package/src/domains/search/extract.ts +0 -44
- package/src/profile/picker.ts +0 -73
- package/src/util/keycodes.ts +0 -10
- package/src/util/time.ts +0 -29
package/src/daemon/protocol.ts
CHANGED
|
@@ -1,32 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
* Unix socket daemon protocol — wire message types, type guards, and constants.
|
|
3
|
-
*
|
|
4
|
-
* The daemon speaks JSON-line protocol over a Unix domain socket.
|
|
5
|
-
* One JSON object per newline. Four message types: request, response, event, control.
|
|
6
|
-
*
|
|
7
|
-
* References:
|
|
8
|
-
* - CDP message format: https://github.com/aslushnikov/getting-started-with-cdp#protocol-fundamentals
|
|
9
|
-
* - net.createServer IPC: https://nodejs.org/docs/latest-v22.x/api/net.html#serverlistenpath-backlog-callback
|
|
10
|
-
* - readline line-delimited: https://nodejs.org/docs/latest-v22.x/api/readline.html#event-line
|
|
11
|
-
*/
|
|
1
|
+
import { isRecord } from "../util/guards";
|
|
12
2
|
|
|
13
|
-
// ── Message types ──────────────────────────────────────────────────────────────
|
|
14
|
-
|
|
15
|
-
/** A CDP command sent from a pi client to the daemon. */
|
|
16
3
|
export type WireRequest = {
|
|
17
4
|
readonly type: "request";
|
|
18
|
-
/** Client-local CDP id. The daemon remaps this to a globally-unique id for Chrome. */
|
|
19
5
|
readonly id: number;
|
|
20
6
|
readonly method: string;
|
|
21
7
|
readonly params?: Record<string, unknown>;
|
|
22
|
-
/** Optional CDP session id. Omitted for browser-level commands. */
|
|
23
8
|
readonly sessionId?: string;
|
|
24
9
|
};
|
|
25
10
|
|
|
26
|
-
/** A CDP response (result or error) sent from the daemon back to the pi client. */
|
|
27
11
|
export type WireResponse = {
|
|
28
12
|
readonly type: "response";
|
|
29
|
-
/** Matches the id from the corresponding WireRequest. */
|
|
30
13
|
readonly id: number;
|
|
31
14
|
readonly result?: unknown;
|
|
32
15
|
readonly error?: {
|
|
@@ -36,39 +19,24 @@ export type WireResponse = {
|
|
|
36
19
|
};
|
|
37
20
|
};
|
|
38
21
|
|
|
39
|
-
/** A CDP event forwarded from Chrome through the daemon to pi client(s). */
|
|
40
22
|
export type WireEvent = {
|
|
41
23
|
readonly type: "event";
|
|
42
24
|
readonly method: string;
|
|
43
25
|
readonly params?: Record<string, unknown>;
|
|
44
|
-
/** Session-scoped events carry this; browser-level events omit it. */
|
|
45
26
|
readonly sessionId?: string;
|
|
46
27
|
};
|
|
47
28
|
|
|
48
|
-
/** Daemon lifecycle control messages. Bidirectional. */
|
|
49
29
|
export type WireControl = {
|
|
50
30
|
readonly type: "control";
|
|
51
31
|
readonly action: "register" | "registered" | "deregister" | "shutdown";
|
|
52
|
-
/** pi client namespace (required for register/registered/deregister). */
|
|
53
32
|
readonly clientId?: string;
|
|
54
|
-
/** Human-readable reason for shutdown. */
|
|
55
33
|
readonly reason?: string;
|
|
56
34
|
};
|
|
57
35
|
|
|
58
|
-
/** All messages that may appear on the Unix socket. */
|
|
59
36
|
export type WireMessage = WireRequest | WireResponse | WireEvent | WireControl;
|
|
60
37
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const isObject = (v: unknown): v is Readonly<Record<string, unknown>> =>
|
|
64
|
-
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Validates that an unknown parsed-JSON value is a well-formed WireMessage.
|
|
68
|
-
* Does NOT validate the full CDP semantics — only structural shape.
|
|
69
|
-
*/
|
|
70
|
-
export const isWireMessage = (v: unknown): v is WireMessage => {
|
|
71
|
-
if (!isObject(v)) return false;
|
|
38
|
+
const isWireMessage = (v: unknown): v is WireMessage => {
|
|
39
|
+
if (!isRecord(v)) return false;
|
|
72
40
|
const t = v["type"];
|
|
73
41
|
if (typeof t !== "string") return false;
|
|
74
42
|
|
|
@@ -84,7 +52,7 @@ export const isWireMessage = (v: unknown): v is WireMessage => {
|
|
|
84
52
|
if (typeof v["id"] !== "number") return false;
|
|
85
53
|
const errVal = v["error"];
|
|
86
54
|
if (errVal !== undefined) {
|
|
87
|
-
if (!
|
|
55
|
+
if (!isRecord(errVal)) return false;
|
|
88
56
|
if (typeof errVal["message"] !== "string") return false;
|
|
89
57
|
if (typeof errVal["code"] !== "number") return false;
|
|
90
58
|
}
|
|
@@ -114,12 +82,8 @@ export const isWireMessage = (v: unknown): v is WireMessage => {
|
|
|
114
82
|
}
|
|
115
83
|
};
|
|
116
84
|
|
|
117
|
-
// ── Wire message constructors ──────────────────────────────────────────────────
|
|
118
|
-
|
|
119
|
-
/** Serialize a WireMessage to a JSON line (no trailing newline — caller adds \\n). */
|
|
120
85
|
export const serialize = (msg: WireMessage): string => JSON.stringify(msg);
|
|
121
86
|
|
|
122
|
-
/** Deserialize a single JSON-line string to a WireMessage. Returns null on parse failure. */
|
|
123
87
|
export const deserialize = (line: string): WireMessage | null => {
|
|
124
88
|
let parsed: unknown;
|
|
125
89
|
try {
|
|
@@ -131,42 +95,16 @@ export const deserialize = (line: string): WireMessage | null => {
|
|
|
131
95
|
return parsed;
|
|
132
96
|
};
|
|
133
97
|
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* IPC endpoint for the daemon.
|
|
138
|
-
*
|
|
139
|
-
* On POSIX platforms this is a Unix domain socket path. On Windows, Node's IPC
|
|
140
|
-
* requires a named pipe of the form `\\.\pipe\<name>` — a Unix path like
|
|
141
|
-
* `/tmp/…` is not a valid `net` listen/connect target there and causes setup
|
|
142
|
-
* to fail. See https://nodejs.org/api/net.html#ipc-support.
|
|
143
|
-
*/
|
|
98
|
+
// Windows requires a named pipe (`\\.\pipe\<name>`); a Unix path is not a valid `net` listen/connect target there.
|
|
144
99
|
export const DAEMON_SOCKET_PATH =
|
|
145
100
|
process.platform === "win32"
|
|
146
101
|
? "\\\\.\\pipe\\pi-browser-daemon"
|
|
147
102
|
: "/tmp/pi-browser-daemon.sock";
|
|
148
103
|
|
|
149
|
-
|
|
150
|
-
* How long the daemon stays alive with zero connected clients before exiting
|
|
151
|
-
* (milliseconds). After this period, the daemon cleans up and shuts down.
|
|
152
|
-
*/
|
|
153
|
-
export const DAEMON_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
104
|
+
export const DAEMON_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
154
105
|
|
|
155
|
-
/**
|
|
156
|
-
* Hard cap on concurrent pi client connections. Extra connections are rejected
|
|
157
|
-
* immediately to prevent resource exhaustion.
|
|
158
|
-
*/
|
|
159
106
|
export const DAEMON_MAX_CLIENTS = 16;
|
|
160
107
|
|
|
161
|
-
/**
|
|
162
|
-
* If true, the daemon unlinks (deletes) any stale socket file from a previous
|
|
163
|
-
* daemon crash before calling server.listen(). Without this, a crashed daemon
|
|
164
|
-
* leaves a socket inode that blocks the next listen() call with EADDRINUSE.
|
|
165
|
-
*/
|
|
166
|
-
export const DAEMON_STALE_SOCKET_CLEANUP = true;
|
|
167
|
-
|
|
168
|
-
/** CDP WebSocket connection timeout (ms). */
|
|
169
108
|
export const CDP_CONNECT_TIMEOUT_MS = 10_000;
|
|
170
109
|
|
|
171
|
-
/** CDP command timeout when proxying through daemon (ms). */
|
|
172
110
|
export const CDP_COMMAND_TIMEOUT_MS = 10_000;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type Result, err } from "../util/result";
|
|
2
|
+
import { type CdpError, cdpError } from "../cdp/errors";
|
|
3
|
+
import type { CdpEvent } from "../cdp/types";
|
|
4
|
+
|
|
5
|
+
export type EventQueue = {
|
|
6
|
+
readonly push: (e: CdpEvent) => void;
|
|
7
|
+
readonly end: () => void;
|
|
8
|
+
readonly iter: AsyncIterable<CdpEvent>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const makeEventQueue = (): EventQueue => {
|
|
12
|
+
const buf: CdpEvent[] = [];
|
|
13
|
+
const waiters: Array<(v: IteratorResult<CdpEvent, undefined>) => void> = [];
|
|
14
|
+
let ended = false;
|
|
15
|
+
return {
|
|
16
|
+
push(e) {
|
|
17
|
+
if (ended) return;
|
|
18
|
+
const w = waiters.shift();
|
|
19
|
+
if (w) w({ value: e, done: false });
|
|
20
|
+
else buf.push(e);
|
|
21
|
+
},
|
|
22
|
+
end() {
|
|
23
|
+
ended = true;
|
|
24
|
+
for (const w of waiters.splice(0)) w({ value: undefined, done: true });
|
|
25
|
+
},
|
|
26
|
+
iter: {
|
|
27
|
+
[Symbol.asyncIterator](): AsyncIterator<CdpEvent, undefined, undefined> {
|
|
28
|
+
return {
|
|
29
|
+
next: (): Promise<IteratorResult<CdpEvent, undefined>> =>
|
|
30
|
+
new Promise((resolve) => {
|
|
31
|
+
const next = buf.shift();
|
|
32
|
+
if (next) resolve({ value: next, done: false });
|
|
33
|
+
else if (ended) resolve({ value: undefined, done: true });
|
|
34
|
+
else waiters.push(resolve);
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type CdpResult = Result<unknown, CdpError>;
|
|
43
|
+
|
|
44
|
+
export type Pending = {
|
|
45
|
+
readonly resolve: (v: Result<unknown, CdpError>) => void;
|
|
46
|
+
readonly timer: ReturnType<typeof setTimeout>;
|
|
47
|
+
readonly method: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const rejectAllPending = (pending: Map<number, Pending>, reason: string): void => {
|
|
51
|
+
for (const [, p] of pending) {
|
|
52
|
+
clearTimeout(p.timer);
|
|
53
|
+
p.resolve(err(cdpError("transport_closed", reason, p.method)));
|
|
54
|
+
}
|
|
55
|
+
pending.clear();
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const sendWithTimeout = (
|
|
59
|
+
pending: Map<number, Pending>,
|
|
60
|
+
id: number,
|
|
61
|
+
method: string,
|
|
62
|
+
timeoutMs: number,
|
|
63
|
+
timeoutLabel: string,
|
|
64
|
+
send: () => void,
|
|
65
|
+
): Promise<CdpResult> =>
|
|
66
|
+
new Promise((resolve) => {
|
|
67
|
+
const timer = setTimeout(() => {
|
|
68
|
+
pending.delete(id);
|
|
69
|
+
resolve(err(cdpError("timeout", `${timeoutLabel} timeout after ${timeoutMs}ms: ${method}`, method)));
|
|
70
|
+
}, timeoutMs);
|
|
71
|
+
pending.set(id, { resolve, timer, method });
|
|
72
|
+
try {
|
|
73
|
+
send();
|
|
74
|
+
} catch (e) {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
pending.delete(id);
|
|
77
|
+
resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e), method)));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export const makeOnClose = (closeListeners: Set<() => void>) =>
|
|
82
|
+
(cb: () => void): (() => void) => {
|
|
83
|
+
closeListeners.add(cb);
|
|
84
|
+
return () => closeListeners.delete(cb);
|
|
85
|
+
};
|
package/src/daemon/server.ts
CHANGED
|
@@ -1,76 +1,40 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* IpcServer — Unix socket server for daemon ↔ pi client communication.
|
|
3
|
-
*
|
|
4
|
-
* Accepts multiple concurrent pi client connections over a Unix domain
|
|
5
|
-
* stream socket. Uses newline-delimited JSON framing (one WireMessage per
|
|
6
|
-
* line). Clients are anonymous until they send a control.register message.
|
|
7
|
-
*
|
|
8
|
-
* References:
|
|
9
|
-
* - net.createServer: https://nodejs.org/docs/latest-v22.x/api/net.html#netcreateserveroptions-connectionlistener
|
|
10
|
-
* - server.listen(path): https://nodejs.org/docs/latest-v22.x/api/net.html#serverlistenpath-backlog-callback
|
|
11
|
-
* - readline.createInterface: https://nodejs.org/docs/latest-v22.x/api/readline.html#readlinecreateinterfaceoptions
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
1
|
import { createServer, type Server, type Socket } from "node:net";
|
|
15
2
|
import { createInterface, type Interface } from "node:readline";
|
|
16
3
|
import { unlinkSync } from "node:fs";
|
|
17
4
|
import {
|
|
18
5
|
type WireMessage,
|
|
19
6
|
type WireControl,
|
|
20
|
-
|
|
7
|
+
deserialize,
|
|
8
|
+
serialize,
|
|
21
9
|
DAEMON_SOCKET_PATH,
|
|
22
10
|
DAEMON_MAX_CLIENTS,
|
|
23
|
-
DAEMON_STALE_SOCKET_CLEANUP,
|
|
24
11
|
} from "./protocol";
|
|
25
12
|
|
|
26
|
-
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
/** A connected pi client with its socket and readline interface. */
|
|
29
13
|
export type ClientSocket = {
|
|
30
|
-
/** Client namespace (set after registration). */
|
|
31
14
|
id: string;
|
|
32
15
|
readonly socket: Socket;
|
|
33
16
|
readonly rl: Interface;
|
|
34
|
-
/** True once a valid control.register message has been received. */
|
|
35
17
|
registered: boolean;
|
|
36
18
|
};
|
|
37
19
|
|
|
38
|
-
/** Handler called for every valid WireMessage from any client. */
|
|
39
20
|
export type MessageHandler = (msg: WireMessage, client: ClientSocket) => void;
|
|
40
21
|
|
|
41
|
-
/** Handler called when a client connects or disconnects. */
|
|
42
22
|
export type ConnectionHandler = (client: ClientSocket) => void;
|
|
43
23
|
|
|
44
24
|
export type IpcServer = {
|
|
45
|
-
/** Start listening on the Unix socket. Resolves when the server is ready. */
|
|
46
25
|
start(): Promise<void>;
|
|
47
|
-
/** Close the server and all client connections. Resolves when cleaned up. */
|
|
48
26
|
stop(): Promise<void>;
|
|
49
|
-
/** Register a handler for incoming WireMessages from any client. */
|
|
50
27
|
onMessage(handler: MessageHandler): void;
|
|
51
|
-
/** Register a handler for client connect events. */
|
|
52
28
|
onConnect(handler: ConnectionHandler): void;
|
|
53
|
-
/** Register a handler for client disconnect events. */
|
|
54
29
|
onDisconnect(handler: ConnectionHandler): void;
|
|
55
|
-
/** Send a WireMessage to a specific client by clientId. No-op if client not found. */
|
|
56
30
|
send(clientId: string, msg: WireMessage): void;
|
|
57
|
-
/** Send a WireMessage to all connected (registered) clients. */
|
|
58
31
|
broadcast(msg: WireMessage): void;
|
|
59
|
-
/** Force-disconnect a client by clientId. */
|
|
60
|
-
disconnectClient(clientId: string): void;
|
|
61
|
-
/** All currently connected clients (by clientId). */
|
|
62
|
-
clients(): ReadonlyMap<string, ClientSocket>;
|
|
63
|
-
/** Number of connected clients. */
|
|
64
32
|
clientCount(): number;
|
|
65
33
|
};
|
|
66
34
|
|
|
67
|
-
// ── Implementation ─────────────────────────────────────────────────────────────
|
|
68
|
-
|
|
69
35
|
export const createIpcServer = (): IpcServer => {
|
|
70
36
|
let server: Server | null = null;
|
|
71
|
-
// Anonymous temp-id → ClientSocket. Temp ids are assigned before registration.
|
|
72
37
|
const sockets = new Map<string, ClientSocket>();
|
|
73
|
-
// clientId → ClientSocket (only registered clients).
|
|
74
38
|
const clients = new Map<string, ClientSocket>();
|
|
75
39
|
let messageHandler: MessageHandler | null = null;
|
|
76
40
|
let connectHandler: ConnectionHandler | null = null;
|
|
@@ -79,17 +43,11 @@ export const createIpcServer = (): IpcServer => {
|
|
|
79
43
|
|
|
80
44
|
const start = (): Promise<void> =>
|
|
81
45
|
new Promise((resolve, reject) => {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
unlinkSync(DAEMON_SOCKET_PATH);
|
|
86
|
-
} catch {
|
|
87
|
-
// ENOENT is expected — no stale socket to clean.
|
|
88
|
-
}
|
|
89
|
-
}
|
|
46
|
+
try {
|
|
47
|
+
unlinkSync(DAEMON_SOCKET_PATH);
|
|
48
|
+
} catch {}
|
|
90
49
|
|
|
91
50
|
server = createServer({ pauseOnConnect: false }, (socket: Socket) => {
|
|
92
|
-
// Reject if at capacity (check registered clients only).
|
|
93
51
|
if (clients.size >= DAEMON_MAX_CLIENTS) {
|
|
94
52
|
socket.destroy();
|
|
95
53
|
return;
|
|
@@ -101,77 +59,54 @@ export const createIpcServer = (): IpcServer => {
|
|
|
101
59
|
|
|
102
60
|
sockets.set(tempId, currentClient);
|
|
103
61
|
|
|
104
|
-
// Buffer for partial lines. readline handles line splitting, but
|
|
105
|
-
// we store a reference in case we need it for diagnostics.
|
|
106
|
-
let lineCount = 0;
|
|
107
|
-
|
|
108
62
|
rl.on("line", (line: string) => {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
try {
|
|
112
|
-
const parsed: unknown = JSON.parse(line);
|
|
113
|
-
if (!isWireMessage(parsed)) return;
|
|
114
|
-
msg = parsed;
|
|
115
|
-
} catch {
|
|
116
|
-
// Malformed JSON — ignore silently, don't crash the daemon.
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
63
|
+
const msg = deserialize(line);
|
|
64
|
+
if (!msg) return;
|
|
119
65
|
|
|
120
|
-
// Handle registration before forwarding to the general handler.
|
|
121
66
|
if (msg.type === "control" && msg.action === "register" && !currentClient.registered) {
|
|
122
67
|
const clientId = msg.clientId;
|
|
123
68
|
if (!clientId) return;
|
|
124
69
|
|
|
125
|
-
// If a client with this id is already connected, reject.
|
|
126
70
|
if (clients.has(clientId)) {
|
|
127
71
|
const reply: WireControl = {
|
|
128
72
|
type: "control",
|
|
129
73
|
action: "shutdown",
|
|
130
74
|
reason: `clientId ${clientId} is already connected`,
|
|
131
75
|
};
|
|
132
|
-
socket.write(
|
|
76
|
+
socket.write(serialize(reply) + "\n");
|
|
133
77
|
socket.destroy();
|
|
134
78
|
return;
|
|
135
79
|
}
|
|
136
80
|
|
|
137
|
-
//
|
|
138
|
-
// closure captures the updated client reference.
|
|
81
|
+
// Mutate in place so the already-captured closure sees the promoted client.
|
|
139
82
|
sockets.delete(tempId);
|
|
140
83
|
currentClient.id = clientId;
|
|
141
84
|
currentClient.registered = true;
|
|
142
85
|
clients.set(clientId, currentClient);
|
|
143
86
|
|
|
144
|
-
// Acknowledge registration.
|
|
145
87
|
const ack: WireControl = { type: "control", action: "registered", clientId };
|
|
146
|
-
socket.write(
|
|
88
|
+
socket.write(serialize(ack) + "\n");
|
|
147
89
|
|
|
148
90
|
connectHandler?.(currentClient);
|
|
149
91
|
return;
|
|
150
92
|
}
|
|
151
93
|
|
|
152
|
-
// Require registration for non-control messages.
|
|
153
94
|
if (!currentClient.registered) return;
|
|
154
95
|
|
|
155
|
-
// Forward to the general handler.
|
|
156
96
|
messageHandler?.(msg, currentClient);
|
|
157
97
|
});
|
|
158
98
|
|
|
159
|
-
socket.on("error", () => {
|
|
160
|
-
// Socket errors (ECONNRESET, etc.) are handled by the 'close' event.
|
|
161
|
-
});
|
|
99
|
+
socket.on("error", () => {});
|
|
162
100
|
|
|
163
101
|
socket.on("close", () => {
|
|
164
|
-
// Clean up whichever map the client is in.
|
|
165
102
|
const found = clients.get(currentClient.id) ?? sockets.get(tempId);
|
|
166
103
|
if (found) {
|
|
167
104
|
clients.delete(found.id);
|
|
168
105
|
sockets.delete(tempId);
|
|
169
|
-
// Notify if this was a registered client.
|
|
170
106
|
if (found.registered) {
|
|
171
107
|
disconnectHandler?.(found);
|
|
172
108
|
}
|
|
173
109
|
}
|
|
174
|
-
// Clean up readline to prevent memory leaks.
|
|
175
110
|
rl.close();
|
|
176
111
|
});
|
|
177
112
|
});
|
|
@@ -187,20 +122,18 @@ export const createIpcServer = (): IpcServer => {
|
|
|
187
122
|
|
|
188
123
|
const stop = (): Promise<void> =>
|
|
189
124
|
new Promise((resolve) => {
|
|
190
|
-
// Close all client sockets.
|
|
191
125
|
for (const [, client] of clients) {
|
|
192
|
-
try { client.socket.destroy(); } catch {
|
|
126
|
+
try { client.socket.destroy(); } catch {}
|
|
193
127
|
}
|
|
194
128
|
for (const [, client] of sockets) {
|
|
195
|
-
try { client.socket.destroy(); } catch {
|
|
129
|
+
try { client.socket.destroy(); } catch {}
|
|
196
130
|
}
|
|
197
131
|
clients.clear();
|
|
198
132
|
sockets.clear();
|
|
199
133
|
|
|
200
134
|
if (server) {
|
|
201
135
|
server.close(() => {
|
|
202
|
-
|
|
203
|
-
try { unlinkSync(DAEMON_SOCKET_PATH); } catch { /* ENOENT ok */ }
|
|
136
|
+
try { unlinkSync(DAEMON_SOCKET_PATH); } catch {}
|
|
204
137
|
resolve();
|
|
205
138
|
});
|
|
206
139
|
} else {
|
|
@@ -208,6 +141,14 @@ export const createIpcServer = (): IpcServer => {
|
|
|
208
141
|
}
|
|
209
142
|
});
|
|
210
143
|
|
|
144
|
+
const send = (clientId: string, msg: WireMessage): void => {
|
|
145
|
+
const client = clients.get(clientId);
|
|
146
|
+
if (!client) return;
|
|
147
|
+
try {
|
|
148
|
+
client.socket.write(serialize(msg) + "\n");
|
|
149
|
+
} catch {}
|
|
150
|
+
};
|
|
151
|
+
|
|
211
152
|
return {
|
|
212
153
|
start,
|
|
213
154
|
stop,
|
|
@@ -220,26 +161,10 @@ export const createIpcServer = (): IpcServer => {
|
|
|
220
161
|
onDisconnect(handler) {
|
|
221
162
|
disconnectHandler = handler;
|
|
222
163
|
},
|
|
223
|
-
send
|
|
224
|
-
const client = clients.get(clientId);
|
|
225
|
-
if (!client) return;
|
|
226
|
-
try {
|
|
227
|
-
client.socket.write(JSON.stringify(msg) + "\n");
|
|
228
|
-
} catch {
|
|
229
|
-
// Socket write error — the close handler will clean up.
|
|
230
|
-
}
|
|
231
|
-
},
|
|
164
|
+
send,
|
|
232
165
|
broadcast(msg) {
|
|
233
|
-
for (const [clientId] of clients)
|
|
234
|
-
this.send(clientId, msg);
|
|
235
|
-
}
|
|
236
|
-
},
|
|
237
|
-
disconnectClient(clientId) {
|
|
238
|
-
const client = clients.get(clientId);
|
|
239
|
-
if (!client) return;
|
|
240
|
-
client.socket.destroy();
|
|
166
|
+
for (const [clientId] of clients) send(clientId, msg);
|
|
241
167
|
},
|
|
242
|
-
clients: (): ReadonlyMap<string, ClientSocket> => clients,
|
|
243
168
|
clientCount: (): number => clients.size,
|
|
244
169
|
};
|
|
245
170
|
};
|
package/src/daemon/spawn.ts
CHANGED
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Daemon lifecycle helpers — spawn detection and lazy launch.
|
|
3
|
-
*
|
|
4
|
-
* The daemon is a standalone long-lived process that owns the single WebSocket
|
|
5
|
-
* to Chrome. pi instances connect to it via a Unix domain socket. This module
|
|
6
|
-
* provides the plumbing to detect a running daemon and spawn one if needed.
|
|
7
|
-
*
|
|
8
|
-
* Reference:
|
|
9
|
-
* - child_process.spawn detached: https://nodejs.org/docs/latest-v22.x/api/child_process.html#optionsdetached
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
13
2
|
import { access, unlink } from "node:fs/promises";
|
|
14
3
|
import { existsSync } from "node:fs";
|
|
@@ -21,34 +10,27 @@ const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
|
21
10
|
|
|
22
11
|
const isWindows = process.platform === "win32";
|
|
23
12
|
|
|
24
|
-
|
|
25
|
-
* Remove a stale POSIX socket file. On Windows the daemon endpoint is a named
|
|
26
|
-
* pipe, which is not a filesystem entry and is torn down automatically when the
|
|
27
|
-
* owning process exits — so there is nothing to unlink, and calling unlink on a
|
|
28
|
-
* pipe path throws. This is a no-op on Windows.
|
|
29
|
-
*/
|
|
13
|
+
// No-op on Windows: a named pipe is not a filesystem entry and unlinking a pipe path throws.
|
|
30
14
|
const cleanupStaleSocket = (): void => {
|
|
31
15
|
if (isWindows) return;
|
|
32
16
|
unlink(DAEMON_SOCKET_PATH).catch(() => {});
|
|
33
17
|
};
|
|
34
18
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (!isWindows) {
|
|
46
|
-
try {
|
|
47
|
-
await access(DAEMON_SOCKET_PATH);
|
|
48
|
-
} catch {
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
19
|
+
export const daemonSocketMayExist = async (
|
|
20
|
+
platform: NodeJS.Platform = process.platform,
|
|
21
|
+
socketPath: string = DAEMON_SOCKET_PATH,
|
|
22
|
+
): Promise<boolean> => {
|
|
23
|
+
if (platform === "win32") return true;
|
|
24
|
+
try {
|
|
25
|
+
await access(socketPath);
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
51
29
|
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
|
|
33
|
+
if (!(await daemonSocketMayExist())) return false;
|
|
52
34
|
|
|
53
35
|
return new Promise((resolve) => {
|
|
54
36
|
let settled = false;
|
|
@@ -61,7 +43,6 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
|
|
|
61
43
|
const sock = createConnection(DAEMON_SOCKET_PATH);
|
|
62
44
|
const timer = setTimeout(() => {
|
|
63
45
|
sock.destroy();
|
|
64
|
-
// ponytail: stale socket cleanup; ENOENT = already gone, fine
|
|
65
46
|
cleanupStaleSocket();
|
|
66
47
|
done(false);
|
|
67
48
|
}, timeoutMs);
|
|
@@ -75,7 +56,6 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
|
|
|
75
56
|
const text = chunk.toString();
|
|
76
57
|
if (text.includes('"registered"')) {
|
|
77
58
|
clearTimeout(timer);
|
|
78
|
-
// Send deregister so the probe doesn't hog a slot
|
|
79
59
|
const deregMsg: WireControl = { type: "control", action: "deregister", clientId: "_liveness_probe" };
|
|
80
60
|
try { sock.write(serialize(deregMsg) + "\n"); } catch {}
|
|
81
61
|
sock.destroy();
|
|
@@ -86,13 +66,11 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
|
|
|
86
66
|
sock.on("error", () => {
|
|
87
67
|
clearTimeout(timer);
|
|
88
68
|
sock.destroy();
|
|
89
|
-
// ponytail: connection refused = daemon dead, clean up stale socket
|
|
90
69
|
cleanupStaleSocket();
|
|
91
70
|
done(false);
|
|
92
71
|
});
|
|
93
72
|
|
|
94
|
-
//
|
|
95
|
-
// don't unlink — the daemon is still using that socket.
|
|
73
|
+
// Closed without a 'registered' ack: the daemon is alive but rejected us, so do NOT unlink its socket.
|
|
96
74
|
sock.on("close", () => {
|
|
97
75
|
clearTimeout(timer);
|
|
98
76
|
done(false);
|
|
@@ -100,45 +78,24 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => {
|
|
|
100
78
|
});
|
|
101
79
|
};
|
|
102
80
|
|
|
103
|
-
/**
|
|
104
|
-
* Spawn the daemon as a detached child process. The daemon will continue
|
|
105
|
-
* running after the parent pi process exits.
|
|
106
|
-
*
|
|
107
|
-
* The daemon entry point is resolved relative to this module. When running
|
|
108
|
-
* from source (tsx), the entry is src/daemon/index.ts. When running compiled,
|
|
109
|
-
* it's dist/daemon/index.js.
|
|
110
|
-
*
|
|
111
|
-
* Returns the child process handle (caller should call child.unref()).
|
|
112
|
-
*/
|
|
113
81
|
export const spawnDaemon = (): ChildProcess | null => {
|
|
114
|
-
// Resolve the daemon entry point and tsx binary.
|
|
115
82
|
const daemonScript = join(moduleDir, "index.ts");
|
|
116
83
|
|
|
117
|
-
//
|
|
118
|
-
// and the npx fallback is `npx.cmd`. A `.cmd` file can only be launched
|
|
119
|
-
// through a shell (spawning it directly yields EINVAL), so set shell: true
|
|
120
|
-
// whenever we invoke one.
|
|
84
|
+
// Windows npm binaries are `.cmd` shims, which only launch through a shell — spawning one directly yields EINVAL.
|
|
121
85
|
const tsxBinBase = join(moduleDir, "..", "..", "node_modules", ".bin", "tsx");
|
|
122
86
|
const tsxBin = isWindows ? `${tsxBinBase}.cmd` : tsxBinBase;
|
|
123
87
|
const hasLocalTsx = existsSync(tsxBin);
|
|
124
88
|
|
|
125
|
-
// Use the local tsx binary to run the daemon TypeScript source directly.
|
|
126
|
-
// The spawned process inherits the parent's env so module resolution works.
|
|
127
|
-
// Fall back to `npx tsx` if the local binary isn't available.
|
|
128
89
|
const cmd = hasLocalTsx ? tsxBin : isWindows ? "npx.cmd" : "npx";
|
|
129
90
|
const args = hasLocalTsx ? [daemonScript] : ["tsx", daemonScript];
|
|
130
91
|
|
|
131
|
-
// With shell: true
|
|
132
|
-
// without quoting, so any path containing spaces (e.g. C:\Users\Some Name)
|
|
133
|
-
// would be split into multiple tokens. Wrap each token in double quotes on
|
|
134
|
-
// Windows to keep space-containing paths intact.
|
|
92
|
+
// With shell: true Node passes the command line verbatim, so a path containing spaces must be quoted or it splits into tokens.
|
|
135
93
|
const quote = (s: string): string => (isWindows ? `"${s}"` : s);
|
|
136
94
|
|
|
137
95
|
const child = spawn(quote(cmd), args.map(quote), {
|
|
138
96
|
detached: true,
|
|
139
97
|
stdio: "ignore",
|
|
140
98
|
env: { ...process.env },
|
|
141
|
-
// `.cmd` shims (local tsx or npx.cmd on Windows) must run via a shell.
|
|
142
99
|
shell: isWindows,
|
|
143
100
|
windowsVerbatimArguments: isWindows,
|
|
144
101
|
});
|
|
@@ -147,19 +104,12 @@ export const spawnDaemon = (): ChildProcess | null => {
|
|
|
147
104
|
return child;
|
|
148
105
|
};
|
|
149
106
|
|
|
150
|
-
/**
|
|
151
|
-
* Ensure the daemon is running. If not, spawn it and wait (up to timeoutMs)
|
|
152
|
-
* for the socket file to appear.
|
|
153
|
-
*
|
|
154
|
-
* Returns true if the daemon is running (or was successfully spawned).
|
|
155
|
-
*/
|
|
156
107
|
export const ensureDaemon = async (timeoutMs = 10_000): Promise<boolean> => {
|
|
157
108
|
if (await isDaemonRunning()) return true;
|
|
158
109
|
|
|
159
110
|
const child = spawnDaemon();
|
|
160
111
|
if (!child) return false;
|
|
161
112
|
|
|
162
|
-
// Poll for the socket file to appear
|
|
163
113
|
const deadline = Date.now() + timeoutMs;
|
|
164
114
|
while (Date.now() < deadline) {
|
|
165
115
|
if (await isDaemonRunning()) return true;
|