pi-browser-harness 0.10.2 → 0.12.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 +54 -0
- package/README.md +21 -10
- package/package.json +6 -3
- package/skills/pi-browser-harness/SKILL.md +30 -1
- package/src/cdp/commands.ts +260 -0
- package/src/cdp/console-buffer.ts +10 -85
- package/src/cdp/discovery.ts +31 -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 +375 -265
- 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 +90 -82
- package/src/{cdp/daemon-transport.ts → daemon/transport.ts} +24 -52
- 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 -37
- package/src/domains/console.ts +48 -4
- 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 +75 -30
- package/src/domains/fill-engine.ts +80 -0
- package/src/domains/form-batch.ts +64 -0
- package/src/domains/form.ts +115 -112
- package/src/domains/headers.ts +89 -0
- package/src/domains/history.ts +16 -12
- package/src/domains/isolated-tab.ts +26 -55
- package/src/domains/js.ts +32 -51
- package/src/domains/keyboard.ts +38 -34
- package/src/domains/navigate.ts +16 -26
- package/src/domains/network.ts +61 -13
- package/src/domains/page.ts +25 -10
- package/src/domains/readpage/read-page.ts +74 -20
- 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 -90
- package/src/domains/scroll.ts +10 -12
- package/src/domains/search/google-serp.ts +0 -29
- package/src/domains/search/web-search.ts +48 -21
- package/src/domains/setup.ts +1 -13
- package/src/domains/snapshot.ts +36 -280
- package/src/domains/tabs.ts +15 -16
- package/src/domains/target.ts +35 -0
- package/src/domains/viewport.ts +5 -4
- package/src/index.ts +23 -69
- 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/prompt.ts +22 -122
- package/src/registry.ts +33 -33
- package/src/schemas/parse.ts +23 -0
- package/src/setup.ts +0 -25
- package/src/state.ts +48 -18
- 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/paths.ts +11 -1
- 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/index.ts
CHANGED
|
@@ -1,13 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Browser daemon entry point — standalone long-lived process.
|
|
3
|
-
*
|
|
4
|
-
* Owns a single persistent WebSocket to Chrome. Exposes a Unix domain socket
|
|
5
|
-
* for pi instances to connect through. One Allow dialog ever (per Chrome
|
|
6
|
-
* lifetime), zero repeated prompts.
|
|
7
|
-
*
|
|
8
|
-
* Run: npx tsx src/daemon/index.ts
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
1
|
import { createIpcServer } from "./server";
|
|
12
2
|
import { createCdpBridge, type SendToClient } from "./bridge";
|
|
13
3
|
import { DAEMON_IDLE_TIMEOUT_MS } from "./protocol";
|
|
@@ -18,9 +8,6 @@ async function main() {
|
|
|
18
8
|
const ipcServer = createIpcServer();
|
|
19
9
|
const cdpBridge = createCdpBridge();
|
|
20
10
|
|
|
21
|
-
// ── Wire IpcServer → CdpBridge ─────────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
// When a pi client sends a CDP request, proxy it through the bridge to Chrome.
|
|
24
11
|
ipcServer.onMessage((msg, client) => {
|
|
25
12
|
if (msg.type !== "request") return;
|
|
26
13
|
|
|
@@ -31,19 +18,16 @@ async function main() {
|
|
|
31
18
|
cdpBridge.handleRequest(msg, client.id, send);
|
|
32
19
|
});
|
|
33
20
|
|
|
34
|
-
// When Chrome sends an event, route it to the correct pi client(s).
|
|
35
21
|
cdpBridge.onEvent((event, targetClientIds) => {
|
|
36
22
|
for (const cid of targetClientIds) {
|
|
37
23
|
ipcServer.send(cid, event);
|
|
38
24
|
}
|
|
39
25
|
});
|
|
40
26
|
|
|
41
|
-
// When a client disconnects, clean up its CDP state.
|
|
42
27
|
ipcServer.onDisconnect((client) => {
|
|
43
28
|
cdpBridge.removeClient(client.id);
|
|
44
29
|
});
|
|
45
30
|
|
|
46
|
-
// When Chrome disconnects, notify all clients.
|
|
47
31
|
cdpBridge.onClose(() => {
|
|
48
32
|
console.log("[pi-browser-daemon] Chrome disconnected");
|
|
49
33
|
ipcServer.broadcast({
|
|
@@ -53,11 +37,6 @@ async function main() {
|
|
|
53
37
|
});
|
|
54
38
|
});
|
|
55
39
|
|
|
56
|
-
// ── Idle timeout ───────────────────────────────────────────────────────
|
|
57
|
-
|
|
58
|
-
// The daemon exits after DAEMON_IDLE_TIMEOUT_MS with zero connected clients.
|
|
59
|
-
// This prevents zombie processes when pi sessions end and never reconnect.
|
|
60
|
-
// The timeout resets whenever a client connects.
|
|
61
40
|
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
62
41
|
|
|
63
42
|
const resetIdleTimer = (): void => {
|
|
@@ -78,8 +57,6 @@ async function main() {
|
|
|
78
57
|
ipcServer.onConnect(() => { cancelIdleTimer(); });
|
|
79
58
|
ipcServer.onDisconnect(() => { resetIdleTimer(); });
|
|
80
59
|
|
|
81
|
-
// ── Start ──────────────────────────────────────────────────────────────
|
|
82
|
-
|
|
83
60
|
try {
|
|
84
61
|
await ipcServer.start();
|
|
85
62
|
console.log("[pi-browser-daemon] IPC server listening");
|
|
@@ -88,15 +65,10 @@ async function main() {
|
|
|
88
65
|
process.exit(1);
|
|
89
66
|
}
|
|
90
67
|
|
|
91
|
-
// Connect to Chrome (auto-retries with backoff in the background).
|
|
92
|
-
// The daemon is ready immediately — Chrome may connect later.
|
|
93
68
|
await cdpBridge.start();
|
|
94
69
|
|
|
95
|
-
// Start the idle timer (no clients yet at startup).
|
|
96
70
|
resetIdleTimer();
|
|
97
71
|
|
|
98
|
-
// ── Graceful shutdown ──────────────────────────────────────────────────
|
|
99
|
-
|
|
100
72
|
const shutdown = async () => {
|
|
101
73
|
console.log("[pi-browser-daemon] Shutting down...");
|
|
102
74
|
await cdpBridge.stop();
|
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
|
};
|