pi-feats 0.1.2 → 0.1.4
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/README.md +24 -1
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/assets/pi-feats-preview.png +0 -0
- package/extensions/api-server/application-log-store.ts +31 -0
- package/extensions/api-server/application-runtime.ts +80 -18
- package/extensions/api-server/application-store.ts +6 -2
- package/extensions/api-server/profile-store.ts +47 -20
- package/extensions/api-server/server.ts +182 -47
- package/extensions/browser-harness/cdp/commands.ts +260 -0
- package/extensions/browser-harness/cdp/console-buffer.ts +188 -0
- package/extensions/browser-harness/cdp/discovery.ts +198 -0
- package/extensions/browser-harness/cdp/errors.ts +23 -0
- package/extensions/browser-harness/cdp/events.ts +170 -0
- package/extensions/browser-harness/cdp/network-buffer.ts +146 -0
- package/extensions/browser-harness/cdp/ownership.ts +73 -0
- package/extensions/browser-harness/cdp/record-store.ts +61 -0
- package/extensions/browser-harness/cdp/session.ts +403 -0
- package/extensions/browser-harness/cdp/steel-transport.ts +151 -0
- package/extensions/browser-harness/cdp/target-factory.ts +153 -0
- package/extensions/browser-harness/cdp/types.ts +82 -0
- package/extensions/browser-harness/client.ts +366 -0
- package/extensions/browser-harness/daemon/bridge.ts +531 -0
- package/extensions/browser-harness/daemon/index.ts +86 -0
- package/extensions/browser-harness/daemon/protocol.ts +110 -0
- package/extensions/browser-harness/daemon/queue.ts +85 -0
- package/extensions/browser-harness/daemon/server.ts +170 -0
- package/extensions/browser-harness/daemon/spawn.ts +124 -0
- package/extensions/browser-harness/daemon/transport.ts +178 -0
- package/extensions/browser-harness/domains/ax-tree.ts +179 -0
- package/extensions/browser-harness/domains/box.ts +51 -0
- package/extensions/browser-harness/domains/cdp-call.ts +55 -0
- package/extensions/browser-harness/domains/click.ts +97 -0
- package/extensions/browser-harness/domains/console.ts +219 -0
- package/extensions/browser-harness/domains/dialog.ts +34 -0
- package/extensions/browser-harness/domains/drag.ts +67 -0
- package/extensions/browser-harness/domains/element-call.ts +117 -0
- package/extensions/browser-harness/domains/files.ts +219 -0
- package/extensions/browser-harness/domains/fill-engine.ts +80 -0
- package/extensions/browser-harness/domains/form-batch.ts +65 -0
- package/extensions/browser-harness/domains/form.ts +283 -0
- package/extensions/browser-harness/domains/headers.ts +89 -0
- package/extensions/browser-harness/domains/history.ts +80 -0
- package/extensions/browser-harness/domains/isolated-tab.ts +91 -0
- package/extensions/browser-harness/domains/js.ts +241 -0
- package/extensions/browser-harness/domains/keyboard.ts +197 -0
- package/extensions/browser-harness/domains/navigate.ts +161 -0
- package/extensions/browser-harness/domains/network.ts +301 -0
- package/extensions/browser-harness/domains/page.ts +154 -0
- package/extensions/browser-harness/domains/readpage/read-page.ts +174 -0
- package/extensions/browser-harness/domains/readpage/readability.ts +65 -0
- package/extensions/browser-harness/domains/ref-resolve.ts +118 -0
- package/extensions/browser-harness/domains/render.ts +43 -0
- package/extensions/browser-harness/domains/screenshot-capture.ts +80 -0
- package/extensions/browser-harness/domains/screenshot.ts +76 -0
- package/extensions/browser-harness/domains/scroll.ts +59 -0
- package/extensions/browser-harness/domains/search/google-serp.ts +85 -0
- package/extensions/browser-harness/domains/search/web-search.ts +144 -0
- package/extensions/browser-harness/domains/setup.ts +35 -0
- package/extensions/browser-harness/domains/snapshot.ts +256 -0
- package/extensions/browser-harness/domains/tabs.ts +202 -0
- package/extensions/browser-harness/domains/target.ts +35 -0
- package/extensions/browser-harness/domains/viewport.ts +42 -0
- package/extensions/browser-harness/index.ts +88 -0
- package/extensions/browser-harness/profile/bind.ts +90 -0
- package/extensions/browser-harness/profile/browser-process.ts +320 -0
- package/extensions/browser-harness/profile/command.ts +189 -0
- package/extensions/browser-harness/profile/launch.ts +80 -0
- package/extensions/browser-harness/profile/list.ts +131 -0
- package/extensions/browser-harness/profile/paths.ts +103 -0
- package/extensions/browser-harness/profile/store.ts +84 -0
- package/extensions/browser-harness/prompt.ts +16 -0
- package/extensions/browser-harness/registry.ts +80 -0
- package/extensions/browser-harness/schemas/common.ts +11 -0
- package/extensions/browser-harness/schemas/parse.ts +23 -0
- package/extensions/browser-harness/setup.ts +47 -0
- package/extensions/browser-harness/state.ts +51 -0
- package/extensions/browser-harness/steel.ts +215 -0
- package/extensions/browser-harness/util/debug.ts +8 -0
- package/extensions/browser-harness/util/guards.ts +30 -0
- package/extensions/browser-harness/util/js-template.ts +12 -0
- package/extensions/browser-harness/util/mutex.ts +20 -0
- package/extensions/browser-harness/util/paths.ts +31 -0
- package/extensions/browser-harness/util/result.ts +9 -0
- package/extensions/browser-harness/util/sharp-shim.ts +42 -0
- package/extensions/browser-harness/util/tool.ts +185 -0
- package/extensions/browser-harness/util/truncate.ts +41 -0
- package/extensions/cli-resources.ts +35 -16
- package/extensions/composio/index.ts +163 -0
- package/extensions/context-memory/index.ts +58 -0
- package/extensions/lib/application-context.ts +22 -0
- package/extensions/lib/application-handler-templates.ts +17 -1
- package/extensions/lib/context-memory.ts +165 -0
- package/extensions/lib/profile-sandbox.ts +3 -0
- package/extensions/lib/workflow-harness.ts +238 -0
- package/extensions/pi-console-webui/app/[section]/page.tsx +6 -1
- package/extensions/pi-console-webui/app/applications/[slug]/page.tsx +15 -0
- package/extensions/pi-console-webui/app/layout.tsx +6 -1
- package/extensions/pi-console-webui/app/login/page.tsx +5 -9
- package/extensions/pi-console-webui/app/not-found.tsx +5 -0
- package/extensions/pi-console-webui/app/page.tsx +6 -0
- package/extensions/pi-console-webui/app/terminal/page.tsx +3 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +4 -4
- package/extensions/pi-console-webui/components/application-logs.tsx +12 -8
- package/extensions/pi-console-webui/components/application-settings.tsx +5 -1
- package/extensions/pi-console-webui/components/chat-workspace.tsx +7 -4
- package/extensions/pi-console-webui/components/console-page.tsx +3 -1
- package/extensions/pi-console-webui/components/console.tsx +53 -7
- package/extensions/pi-console-webui/components/login-form.tsx +14 -0
- package/extensions/pi-console-webui/components/pulse-resources.tsx +10 -6
- package/extensions/pi-console-webui/components/skill-resources.tsx +3 -3
- package/extensions/pi-console-webui/lib/page-metadata.ts +33 -0
- package/extensions/profiles.ts +18 -7
- package/extensions/pulse/store.ts +5 -2
- package/extensions/sequential-workflow.ts +340 -256
- package/extensions/skill-sources/store.ts +24 -3
- package/licenses/pi-browser-harness-MIT.txt +21 -0
- package/package.json +25 -4
- package/schemas/sequential-workflow-template.schema.json +62 -0
- package/skills/browser-harness/SKILL.md +33 -0
- package/skills/composio-connections/SKILL.md +75 -0
- package/skills/sequential-workflow/SKILL.md +70 -0
- package/skills/sequential-workflow-template/SKILL.md +44 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { map, type Result, err, ok } from "../util/result";
|
|
2
|
+
import { type CdpError, cdpError } from "./errors";
|
|
3
|
+
import type { DialogInfo } from "./types";
|
|
4
|
+
import type { OwnershipRegistry } from "./ownership";
|
|
5
|
+
import type { CdpTransport } from "./types";
|
|
6
|
+
import { type CdpMethod, type ParamsOf, type ResultOf, decodeResult } from "./commands";
|
|
7
|
+
import { decodeEvent } from "./events";
|
|
8
|
+
import { createNetworkBuffer, type DrainResult, type NetworkFilter } from "./network-buffer";
|
|
9
|
+
import { createConsoleBuffer, type ConsoleDrainResult, type ConsoleFilter } from "./console-buffer";
|
|
10
|
+
|
|
11
|
+
type TabSession = {
|
|
12
|
+
sessionId: string;
|
|
13
|
+
targetId: string;
|
|
14
|
+
dialog: DialogInfo | null;
|
|
15
|
+
pageInfoDirty: boolean;
|
|
16
|
+
networkBuffer: ReturnType<typeof createNetworkBuffer>;
|
|
17
|
+
consoleBuffer: ReturnType<typeof createConsoleBuffer>;
|
|
18
|
+
refMap: Map<string, number>;
|
|
19
|
+
refSig: Map<string, string>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const dialogType = (raw: string): DialogInfo["type"] =>
|
|
23
|
+
raw === "confirm" || raw === "prompt" || raw === "beforeunload" ? raw : "alert";
|
|
24
|
+
|
|
25
|
+
export type CdpSession = {
|
|
26
|
+
attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>;
|
|
27
|
+
attach(targetId: string): Promise<Result<string, CdpError>>;
|
|
28
|
+
windowId(targetId: string): Promise<Result<number, CdpError>>;
|
|
29
|
+
switchTo(targetId: string): Promise<Result<void, CdpError>>;
|
|
30
|
+
current(): { readonly sessionId: string; readonly targetId: string } | null;
|
|
31
|
+
setRefMap(refMap: Map<string, number>, refSig: Map<string, string>): void;
|
|
32
|
+
resolveRef(ref: string): number | undefined;
|
|
33
|
+
refSignatures(): ReadonlyMap<string, string>;
|
|
34
|
+
refMappings(): ReadonlyMap<string, number>;
|
|
35
|
+
call<M extends CdpMethod>(method: M, params?: ParamsOf<M>, opts?: { timeoutMs?: number }): Promise<Result<ResultOf<M>, CdpError>>;
|
|
36
|
+
callOnTarget<M extends CdpMethod>(method: M, params: ParamsOf<M>, sessionId: string, opts?: { timeoutMs?: number }): Promise<Result<ResultOf<M>, CdpError>>;
|
|
37
|
+
callBrowser<M extends CdpMethod>(method: M, params?: ParamsOf<M>, opts?: { timeoutMs?: number }): Promise<Result<ResultOf<M>, CdpError>>;
|
|
38
|
+
takeDialog(): DialogInfo | null;
|
|
39
|
+
drainPageInfoInvalidations(): boolean;
|
|
40
|
+
drainNetworkBuffer(filter: NetworkFilter): DrainResult;
|
|
41
|
+
drainConsoleBuffer(filter: ConsoleFilter): ConsoleDrainResult;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Injected so seeding goes through the target factory; attachFirstPage's own `Target.createTarget` would land in whichever profile has focus.
|
|
45
|
+
export type SeedTargetProvider = () => Promise<Result<string, CdpError>>;
|
|
46
|
+
|
|
47
|
+
export const createCdpSession = (
|
|
48
|
+
transport: CdpTransport,
|
|
49
|
+
ownership?: OwnershipRegistry,
|
|
50
|
+
createSeedTarget?: SeedTargetProvider,
|
|
51
|
+
): CdpSession => {
|
|
52
|
+
let sessionId: string | null = null;
|
|
53
|
+
let targetId: string | null = null;
|
|
54
|
+
|
|
55
|
+
const tabs = new Map<string, TabSession>();
|
|
56
|
+
const sessionIdToTargetId = new Map<string, string>();
|
|
57
|
+
|
|
58
|
+
let activeConsumer: Promise<void> = Promise.resolve();
|
|
59
|
+
|
|
60
|
+
const resolveTab = (evSessionId?: string): TabSession | undefined => {
|
|
61
|
+
const tid = evSessionId ? sessionIdToTargetId.get(evSessionId) : targetId;
|
|
62
|
+
return tid ? tabs.get(tid) : undefined;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const consumeEvents = async (): Promise<void> => {
|
|
66
|
+
for await (const ev of transport.events()) {
|
|
67
|
+
if (ev.method !== "Target.targetDestroyed") {
|
|
68
|
+
if (ev.sessionId && !sessionIdToTargetId.has(ev.sessionId)) continue;
|
|
69
|
+
}
|
|
70
|
+
switch (ev.method) {
|
|
71
|
+
case "Page.javascriptDialogOpening": {
|
|
72
|
+
const tab = resolveTab(ev.sessionId);
|
|
73
|
+
if (!tab) break;
|
|
74
|
+
const decoded = decodeEvent(ev.method, ev.params);
|
|
75
|
+
tab.dialog = decoded.success
|
|
76
|
+
? {
|
|
77
|
+
type: dialogType(decoded.data.type),
|
|
78
|
+
message: decoded.data.message,
|
|
79
|
+
...(decoded.data.defaultPrompt !== undefined
|
|
80
|
+
? { defaultPrompt: decoded.data.defaultPrompt }
|
|
81
|
+
: {}),
|
|
82
|
+
}
|
|
83
|
+
: { type: "alert", message: "" };
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
// Intentionally NOT cleared on javascriptDialogClosed: a fast dismiss would otherwise drop a dialog the agent was about to read.
|
|
87
|
+
case "Page.frameNavigated":
|
|
88
|
+
case "Page.loadEventFired": {
|
|
89
|
+
const tab = resolveTab(ev.sessionId);
|
|
90
|
+
if (tab) tab.pageInfoDirty = true;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case "Target.targetCreated": {
|
|
94
|
+
if (!ownership) break;
|
|
95
|
+
const decoded = decodeEvent(ev.method, ev.params);
|
|
96
|
+
if (!decoded.success) break;
|
|
97
|
+
const info = decoded.data.targetInfo;
|
|
98
|
+
if (info.type === "page" && info.targetId && info.openerId && ownership.has(info.openerId)) {
|
|
99
|
+
ownership.add(info.targetId);
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
case "Target.targetDestroyed": {
|
|
104
|
+
if (!ownership) break;
|
|
105
|
+
const decoded = decodeEvent(ev.method, ev.params);
|
|
106
|
+
if (!decoded.success || !decoded.data.targetId) break;
|
|
107
|
+
const destroyed = decoded.data.targetId;
|
|
108
|
+
ownership.remove(destroyed);
|
|
109
|
+
const tab = tabs.get(destroyed);
|
|
110
|
+
if (tab) {
|
|
111
|
+
sessionIdToTargetId.delete(tab.sessionId);
|
|
112
|
+
tabs.delete(destroyed);
|
|
113
|
+
}
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
case "Network.requestWillBeSent": {
|
|
117
|
+
const tab = resolveTab(ev.sessionId);
|
|
118
|
+
if (tab) tab.networkBuffer.ingestRequestWillBeSent(ev.params);
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case "Network.responseReceived": {
|
|
122
|
+
const tab = resolveTab(ev.sessionId);
|
|
123
|
+
if (tab) tab.networkBuffer.ingestResponseReceived(ev.params);
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
case "Network.loadingFinished": {
|
|
127
|
+
const tab = resolveTab(ev.sessionId);
|
|
128
|
+
if (tab) tab.networkBuffer.ingestLoadingFinished(ev.params);
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
case "Network.loadingFailed": {
|
|
132
|
+
const tab = resolveTab(ev.sessionId);
|
|
133
|
+
if (tab) tab.networkBuffer.ingestLoadingFailed(ev.params);
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case "Runtime.consoleAPICalled": {
|
|
137
|
+
const tab = resolveTab(ev.sessionId);
|
|
138
|
+
if (tab) tab.consoleBuffer.ingestConsoleApi(ev.params);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case "Log.entryAdded": {
|
|
142
|
+
const tab = resolveTab(ev.sessionId);
|
|
143
|
+
if (tab) tab.consoleBuffer.ingestLogEntry(ev.params);
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
default:
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const restartConsumer = (): void => {
|
|
153
|
+
activeConsumer = activeConsumer.then(() => consumeEvents()).catch((e: unknown) => {
|
|
154
|
+
console.warn("[pi-browser-harness] CDP event consumer crashed:", e);
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
restartConsumer();
|
|
159
|
+
transport.onClose(() => {
|
|
160
|
+
sessionId = null;
|
|
161
|
+
targetId = null;
|
|
162
|
+
tabs.clear();
|
|
163
|
+
sessionIdToTargetId.clear();
|
|
164
|
+
// Do NOT clear `dialog` here: a pending takeDialog() must still see it.
|
|
165
|
+
restartConsumer();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const enableDomains = async (sid: string): Promise<void> => {
|
|
169
|
+
for (const d of ["Page", "DOM", "Runtime", "Network", "Accessibility", "Log"]) {
|
|
170
|
+
await transport.request(`${d}.enable`, {}, { sessionId: sid });
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const req = async <M extends CdpMethod>(
|
|
175
|
+
method: M,
|
|
176
|
+
params: ParamsOf<M> | undefined,
|
|
177
|
+
sid: string | null,
|
|
178
|
+
opts: { timeoutMs?: number } = {},
|
|
179
|
+
): Promise<Result<ResultOf<M>, CdpError>> => {
|
|
180
|
+
const raw = await transport.request(method, { ...(params ?? {}) }, {
|
|
181
|
+
sessionId: sid,
|
|
182
|
+
...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
|
|
183
|
+
});
|
|
184
|
+
if (!raw.success) return raw;
|
|
185
|
+
return decodeResult(method, raw.data);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const attach = async (tid: string): Promise<Result<string, CdpError>> =>
|
|
189
|
+
map(await req("Target.attachToTarget", { targetId: tid, flatten: true }, null), (d) => d.sessionId);
|
|
190
|
+
|
|
191
|
+
const windowId = async (tid: string): Promise<Result<number, CdpError>> =>
|
|
192
|
+
map(await req("Browser.getWindowForTarget", { targetId: tid }, null), (d) => d.windowId);
|
|
193
|
+
|
|
194
|
+
const currentTab = (): TabSession | undefined => (targetId ? tabs.get(targetId) : undefined);
|
|
195
|
+
|
|
196
|
+
const session: CdpSession = {
|
|
197
|
+
async attachFirstPage() {
|
|
198
|
+
await req("Target.setDiscoverTargets", { discover: true }, null);
|
|
199
|
+
|
|
200
|
+
const targets = await req("Target.getTargets", {}, null);
|
|
201
|
+
if (!targets.success) return targets;
|
|
202
|
+
const allPages = targets.data.targetInfos.filter((t) => t.type === "page");
|
|
203
|
+
if (ownership) {
|
|
204
|
+
const live = new Set(allPages.map((p) => p.targetId));
|
|
205
|
+
const survivors = ownership.list().filter((id) => live.has(id));
|
|
206
|
+
if (survivors.length !== ownership.list().length) ownership.replaceAll(survivors);
|
|
207
|
+
const hw = ownership.harnessWindow();
|
|
208
|
+
if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined);
|
|
209
|
+
|
|
210
|
+
const anchorId = survivors[0];
|
|
211
|
+
if (anchorId === undefined) {
|
|
212
|
+
ownership.setHarnessWindowId(undefined);
|
|
213
|
+
} else {
|
|
214
|
+
const anchorWin = await windowId(anchorId);
|
|
215
|
+
if (anchorWin.success) {
|
|
216
|
+
const anchorWindowId = anchorWin.data;
|
|
217
|
+
ownership.setHarnessWindowId(anchorWindowId);
|
|
218
|
+
const windows = await Promise.all(
|
|
219
|
+
allPages.map((p) =>
|
|
220
|
+
p.targetId === anchorId
|
|
221
|
+
? Promise.resolve(anchorWin)
|
|
222
|
+
: windowId(p.targetId),
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
allPages.forEach((p, i) => {
|
|
226
|
+
const r = windows[i];
|
|
227
|
+
if (r?.success && r.data === anchorWindowId) {
|
|
228
|
+
ownership.add(p.targetId);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (!ownership.harnessWindow()) ownership.setHarnessWindow(anchorId);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const liveTargetIds = new Set(allPages.map((p) => p.targetId));
|
|
236
|
+
for (const tid of tabs.keys()) {
|
|
237
|
+
if (!liveTargetIds.has(tid)) {
|
|
238
|
+
const tab = tabs.get(tid);
|
|
239
|
+
if (tab) sessionIdToTargetId.delete(tab.sessionId);
|
|
240
|
+
tabs.delete(tid);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let pickTargetId: string | undefined;
|
|
245
|
+
if (ownership) {
|
|
246
|
+
const ownedLive = ownership.list().filter((id) => allPages.some((p) => p.targetId === id));
|
|
247
|
+
pickTargetId = ownedLive[0];
|
|
248
|
+
}
|
|
249
|
+
if (!pickTargetId && createSeedTarget) {
|
|
250
|
+
const seeded = await createSeedTarget();
|
|
251
|
+
if (!seeded.success) return seeded;
|
|
252
|
+
pickTargetId = seeded.data;
|
|
253
|
+
}
|
|
254
|
+
if (!pickTargetId) {
|
|
255
|
+
const created = await req(
|
|
256
|
+
"Target.createTarget",
|
|
257
|
+
{ url: "about:blank", ...(ownership ? { newWindow: true } : {}) },
|
|
258
|
+
null,
|
|
259
|
+
);
|
|
260
|
+
if (!created.success) return created;
|
|
261
|
+
pickTargetId = created.data.targetId;
|
|
262
|
+
if (ownership) {
|
|
263
|
+
ownership.setHarnessWindow(created.data.targetId);
|
|
264
|
+
ownership.add(created.data.targetId);
|
|
265
|
+
const win = await windowId(created.data.targetId);
|
|
266
|
+
if (win.success) ownership.setHarnessWindowId(win.data);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const attached = await attach(pickTargetId);
|
|
271
|
+
if (!attached.success) return attached;
|
|
272
|
+
const attachedSessionId = attached.data;
|
|
273
|
+
sessionId = attachedSessionId;
|
|
274
|
+
targetId = pickTargetId;
|
|
275
|
+
tabs.set(pickTargetId, {
|
|
276
|
+
sessionId: attachedSessionId,
|
|
277
|
+
targetId: pickTargetId,
|
|
278
|
+
dialog: null,
|
|
279
|
+
pageInfoDirty: false,
|
|
280
|
+
networkBuffer: createNetworkBuffer(),
|
|
281
|
+
consoleBuffer: createConsoleBuffer(),
|
|
282
|
+
refMap: new Map(),
|
|
283
|
+
refSig: new Map(),
|
|
284
|
+
});
|
|
285
|
+
sessionIdToTargetId.set(attachedSessionId, pickTargetId);
|
|
286
|
+
await enableDomains(attachedSessionId);
|
|
287
|
+
return ok({ targetId: pickTargetId, sessionId: attachedSessionId });
|
|
288
|
+
},
|
|
289
|
+
async switchTo(tid) {
|
|
290
|
+
const activated = await req("Target.activateTarget", { targetId: tid }, null);
|
|
291
|
+
if (!activated.success) return activated;
|
|
292
|
+
const attached = await attach(tid);
|
|
293
|
+
if (!attached.success) return attached;
|
|
294
|
+
const attachedSessionId = attached.data;
|
|
295
|
+
const existing = tabs.get(tid);
|
|
296
|
+
const tab: TabSession = existing ?? {
|
|
297
|
+
sessionId: attachedSessionId,
|
|
298
|
+
targetId: tid,
|
|
299
|
+
dialog: null,
|
|
300
|
+
pageInfoDirty: true,
|
|
301
|
+
networkBuffer: createNetworkBuffer(),
|
|
302
|
+
consoleBuffer: createConsoleBuffer(),
|
|
303
|
+
refMap: new Map(),
|
|
304
|
+
refSig: new Map(),
|
|
305
|
+
};
|
|
306
|
+
if (existing) {
|
|
307
|
+
sessionIdToTargetId.delete(existing.sessionId);
|
|
308
|
+
existing.sessionId = attachedSessionId;
|
|
309
|
+
} else {
|
|
310
|
+
tabs.set(tid, tab);
|
|
311
|
+
}
|
|
312
|
+
sessionIdToTargetId.set(attachedSessionId, tid);
|
|
313
|
+
await enableDomains(attachedSessionId);
|
|
314
|
+
sessionId = tab.sessionId;
|
|
315
|
+
targetId = tid;
|
|
316
|
+
return ok(undefined);
|
|
317
|
+
},
|
|
318
|
+
attach,
|
|
319
|
+
windowId,
|
|
320
|
+
current() {
|
|
321
|
+
return sessionId && targetId ? { sessionId, targetId } : null;
|
|
322
|
+
},
|
|
323
|
+
setRefMap(refMap, refSig) {
|
|
324
|
+
const tab = currentTab();
|
|
325
|
+
if (!tab) return;
|
|
326
|
+
tab.refMap = refMap;
|
|
327
|
+
tab.refSig = refSig;
|
|
328
|
+
},
|
|
329
|
+
resolveRef(ref) {
|
|
330
|
+
const tab = currentTab();
|
|
331
|
+
return tab?.refMap.get(ref);
|
|
332
|
+
},
|
|
333
|
+
refSignatures() {
|
|
334
|
+
const tab = currentTab();
|
|
335
|
+
return tab?.refSig ?? new Map();
|
|
336
|
+
},
|
|
337
|
+
refMappings() {
|
|
338
|
+
const tab = currentTab();
|
|
339
|
+
return tab?.refMap ?? new Map();
|
|
340
|
+
},
|
|
341
|
+
call(method, params, opts) {
|
|
342
|
+
return req(method, params, sessionId, opts);
|
|
343
|
+
},
|
|
344
|
+
callOnTarget(method, params, sid, opts) {
|
|
345
|
+
return req(method, params, sid, opts);
|
|
346
|
+
},
|
|
347
|
+
callBrowser(method, params, opts) {
|
|
348
|
+
return req(method, params, null, opts);
|
|
349
|
+
},
|
|
350
|
+
takeDialog() {
|
|
351
|
+
const tab = currentTab();
|
|
352
|
+
if (!tab) return null;
|
|
353
|
+
const d = tab.dialog;
|
|
354
|
+
tab.dialog = null;
|
|
355
|
+
return d;
|
|
356
|
+
},
|
|
357
|
+
drainPageInfoInvalidations() {
|
|
358
|
+
const tab = currentTab();
|
|
359
|
+
if (!tab) return false;
|
|
360
|
+
const dirty = tab.pageInfoDirty;
|
|
361
|
+
tab.pageInfoDirty = false;
|
|
362
|
+
return dirty;
|
|
363
|
+
},
|
|
364
|
+
drainNetworkBuffer(filter) {
|
|
365
|
+
const tab = currentTab();
|
|
366
|
+
if (!tab) return { records: [], total: 0, bufferOverflowed: false };
|
|
367
|
+
return tab.networkBuffer.drain(filter);
|
|
368
|
+
},
|
|
369
|
+
drainConsoleBuffer(filter) {
|
|
370
|
+
const tab = currentTab();
|
|
371
|
+
if (!tab) return { records: [], total: 0, bufferOverflowed: false };
|
|
372
|
+
return tab.consoleBuffer.drain(filter);
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
return session;
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
export const evaluateJson = async <T>(
|
|
379
|
+
session: CdpSession,
|
|
380
|
+
expression: string,
|
|
381
|
+
check: (v: unknown) => v is T,
|
|
382
|
+
opts?: { sessionId?: string | undefined; timeoutMs?: number | undefined },
|
|
383
|
+
): Promise<Result<T, CdpError>> => {
|
|
384
|
+
const params = {
|
|
385
|
+
expression,
|
|
386
|
+
returnByValue: true,
|
|
387
|
+
awaitPromise: true,
|
|
388
|
+
};
|
|
389
|
+
const callOpts = opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {};
|
|
390
|
+
const r = opts?.sessionId !== undefined
|
|
391
|
+
? await session.callOnTarget("Runtime.evaluate", params, opts.sessionId, callOpts)
|
|
392
|
+
: await session.call("Runtime.evaluate", params, callOpts);
|
|
393
|
+
if (!r.success) return r;
|
|
394
|
+
const { result, exceptionDetails } = r.data;
|
|
395
|
+
if (exceptionDetails !== undefined) {
|
|
396
|
+
return err(cdpError("remote_error", exceptionDetails.text, "Runtime.evaluate"));
|
|
397
|
+
}
|
|
398
|
+
const value = result.value;
|
|
399
|
+
if (!check(value)) {
|
|
400
|
+
return err(cdpError("invalid_response", `unexpected evaluate shape (type: ${result.type}) for: ${expression.slice(0, 60)}`, "Runtime.evaluate"));
|
|
401
|
+
}
|
|
402
|
+
return ok(value);
|
|
403
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import type { CdpError } from "./errors";
|
|
3
|
+
import { cdpError, classifyRemoteError } from "./errors";
|
|
4
|
+
import type { CdpEvent, CdpRawMessage, CdpTransport } from "./types";
|
|
5
|
+
import { DEFAULT_TIMEOUT_MS, isCdpRawMessage } from "./types";
|
|
6
|
+
import { err, ok, type Result } from "../util/result";
|
|
7
|
+
|
|
8
|
+
export type EndpointProvider = () => Promise<Result<string, CdpError>>;
|
|
9
|
+
|
|
10
|
+
type Pending = {
|
|
11
|
+
readonly method: string;
|
|
12
|
+
readonly resolve: (value: Result<unknown, CdpError>) => void;
|
|
13
|
+
readonly timer: ReturnType<typeof setTimeout>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const createSteelTransport = (endpoint: EndpointProvider): CdpTransport => {
|
|
17
|
+
let socket: WebSocket | null = null;
|
|
18
|
+
let status: "open" | "closed" | "connecting" = "closed";
|
|
19
|
+
let nextId = 1;
|
|
20
|
+
const pending = new Map<number, Pending>();
|
|
21
|
+
const events: CdpEvent[] = [];
|
|
22
|
+
let waiter: ((value: IteratorResult<CdpEvent>) => void) | undefined;
|
|
23
|
+
const closeListeners = new Set<() => void>();
|
|
24
|
+
|
|
25
|
+
const rejectPending = (message: string): void => {
|
|
26
|
+
for (const [id, request] of pending) {
|
|
27
|
+
clearTimeout(request.timer);
|
|
28
|
+
request.resolve(err(cdpError("transport_closed", message, request.method)));
|
|
29
|
+
pending.delete(id);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const emit = (event: CdpEvent): void => {
|
|
34
|
+
if (waiter) {
|
|
35
|
+
const resolve = waiter;
|
|
36
|
+
waiter = undefined;
|
|
37
|
+
resolve({ value: event, done: false });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
events.push(event);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const close = async (): Promise<void> => {
|
|
44
|
+
const current = socket;
|
|
45
|
+
socket = null;
|
|
46
|
+
status = "closed";
|
|
47
|
+
rejectPending("Steel CDP connection closed");
|
|
48
|
+
if (current && current.readyState !== WebSocket.CLOSED) current.close();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const connect = async (_ignoredUrl: string, options?: { timeoutMs?: number }): Promise<Result<void, CdpError>> => {
|
|
52
|
+
if (status === "open" && socket?.readyState === WebSocket.OPEN) return ok(undefined);
|
|
53
|
+
await close();
|
|
54
|
+
status = "connecting";
|
|
55
|
+
const resolved = await endpoint();
|
|
56
|
+
if (!resolved.success) {
|
|
57
|
+
status = "closed";
|
|
58
|
+
return resolved;
|
|
59
|
+
}
|
|
60
|
+
const timeoutMs = options?.timeoutMs ?? 10_000;
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
let settled = false;
|
|
63
|
+
const finish = (result: Result<void, CdpError>): void => {
|
|
64
|
+
if (settled) return;
|
|
65
|
+
settled = true;
|
|
66
|
+
resolve(result);
|
|
67
|
+
};
|
|
68
|
+
let ws: WebSocket;
|
|
69
|
+
try { ws = new WebSocket(resolved.data, { perMessageDeflate: false }); }
|
|
70
|
+
catch (error) {
|
|
71
|
+
status = "closed";
|
|
72
|
+
finish(err(cdpError("transport_closed", error instanceof Error ? error.message : String(error))));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
socket = ws;
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
ws.close();
|
|
78
|
+
status = "closed";
|
|
79
|
+
finish(err(cdpError("timeout", `Steel CDP connection timed out after ${timeoutMs}ms`)));
|
|
80
|
+
}, timeoutMs);
|
|
81
|
+
ws.on("open", () => {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
status = "open";
|
|
84
|
+
finish(ok(undefined));
|
|
85
|
+
});
|
|
86
|
+
ws.on("message", (data) => {
|
|
87
|
+
let message: unknown;
|
|
88
|
+
try { message = JSON.parse(data.toString()); } catch { return; }
|
|
89
|
+
if (!isCdpRawMessage(message)) return;
|
|
90
|
+
const raw = message as CdpRawMessage;
|
|
91
|
+
if (raw.id !== undefined) {
|
|
92
|
+
const request = pending.get(raw.id);
|
|
93
|
+
if (!request) return;
|
|
94
|
+
pending.delete(raw.id);
|
|
95
|
+
clearTimeout(request.timer);
|
|
96
|
+
if (raw.error) request.resolve(err(cdpError(classifyRemoteError(raw.error.message), raw.error.message, request.method)));
|
|
97
|
+
else request.resolve(ok(raw.result));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (raw.method) emit({ method: raw.method, params: raw.params ?? {}, ...(raw.sessionId ? { sessionId: raw.sessionId } : {}) });
|
|
101
|
+
});
|
|
102
|
+
const disconnected = (): void => {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
if (socket !== ws) return;
|
|
105
|
+
socket = null;
|
|
106
|
+
status = "closed";
|
|
107
|
+
rejectPending("Steel CDP disconnected");
|
|
108
|
+
for (const listener of closeListeners) listener();
|
|
109
|
+
finish(err(cdpError("transport_closed", "Steel CDP disconnected before connection completed")));
|
|
110
|
+
};
|
|
111
|
+
ws.on("error", () => {});
|
|
112
|
+
ws.on("close", disconnected);
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
connect,
|
|
118
|
+
close,
|
|
119
|
+
request(method, params, options) {
|
|
120
|
+
const ws = socket;
|
|
121
|
+
if (!ws || status !== "open" || ws.readyState !== WebSocket.OPEN) {
|
|
122
|
+
return Promise.resolve(err(cdpError("transport_closed", "Steel CDP is not connected", method)));
|
|
123
|
+
}
|
|
124
|
+
const id = nextId++;
|
|
125
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
126
|
+
return new Promise((resolve) => {
|
|
127
|
+
const timer = setTimeout(() => {
|
|
128
|
+
pending.delete(id);
|
|
129
|
+
resolve(err(cdpError("timeout", `Steel CDP command timed out after ${timeoutMs}ms`, method)));
|
|
130
|
+
}, timeoutMs);
|
|
131
|
+
pending.set(id, { method, resolve, timer });
|
|
132
|
+
try {
|
|
133
|
+
ws.send(JSON.stringify({ id, method, params, ...(options?.sessionId ? { sessionId: options.sessionId } : {}) }));
|
|
134
|
+
} catch (error) {
|
|
135
|
+
pending.delete(id);
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
resolve(err(cdpError("transport_closed", error instanceof Error ? error.message : String(error), method)));
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
async *events() {
|
|
142
|
+
while (true) {
|
|
143
|
+
const event = events.shift();
|
|
144
|
+
if (event) yield event;
|
|
145
|
+
else yield await new Promise<CdpEvent>((resolve) => { waiter = (result) => resolve(result.value); });
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
state: () => status,
|
|
149
|
+
onClose(callback) { closeListeners.add(callback); return () => closeListeners.delete(callback); },
|
|
150
|
+
};
|
|
151
|
+
};
|