pi-feats 0.1.3 → 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 +22 -1
- package/THIRD_PARTY_NOTICES.md +12 -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 +21 -3
- 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,260 @@
|
|
|
1
|
+
import { Type, type Static, type TSchema } from "typebox";
|
|
2
|
+
import { Compile } from "typebox/compile";
|
|
3
|
+
import { type Result, err, ok } from "../util/result";
|
|
4
|
+
import { type CdpError, cdpError } from "./errors";
|
|
5
|
+
|
|
6
|
+
const cmd = <P extends TSchema, R extends TSchema>(params: P, result: R) => ({
|
|
7
|
+
params,
|
|
8
|
+
result,
|
|
9
|
+
validate: Compile(result),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const Empty = Type.Object({});
|
|
13
|
+
|
|
14
|
+
const TargetInfo = Type.Object(
|
|
15
|
+
{
|
|
16
|
+
targetId: Type.String(),
|
|
17
|
+
type: Type.String(),
|
|
18
|
+
title: Type.String(),
|
|
19
|
+
url: Type.String(),
|
|
20
|
+
attached: Type.Optional(Type.Boolean()),
|
|
21
|
+
openerId: Type.Optional(Type.String()),
|
|
22
|
+
browserContextId: Type.Optional(Type.String()),
|
|
23
|
+
},
|
|
24
|
+
{ additionalProperties: true },
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const RemoteObject = Type.Object(
|
|
28
|
+
{
|
|
29
|
+
type: Type.String(),
|
|
30
|
+
subtype: Type.Optional(Type.String()),
|
|
31
|
+
className: Type.Optional(Type.String()),
|
|
32
|
+
value: Type.Optional(Type.Unknown()),
|
|
33
|
+
description: Type.Optional(Type.String()),
|
|
34
|
+
objectId: Type.Optional(Type.String()),
|
|
35
|
+
},
|
|
36
|
+
{ additionalProperties: true },
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const CallArgument = Type.Object(
|
|
40
|
+
{
|
|
41
|
+
value: Type.Optional(Type.Unknown()),
|
|
42
|
+
objectId: Type.Optional(Type.String()),
|
|
43
|
+
unserializableValue: Type.Optional(Type.String()),
|
|
44
|
+
},
|
|
45
|
+
{ additionalProperties: true },
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const ExceptionDetails = Type.Object(
|
|
49
|
+
{ text: Type.String(), exception: Type.Optional(RemoteObject) },
|
|
50
|
+
{ additionalProperties: true },
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const AxValue = Type.Object({ value: Type.Optional(Type.Unknown()) }, { additionalProperties: true });
|
|
54
|
+
|
|
55
|
+
const AxNode = Type.Object(
|
|
56
|
+
{
|
|
57
|
+
nodeId: Type.String(),
|
|
58
|
+
parentId: Type.Optional(Type.String()),
|
|
59
|
+
childIds: Type.Optional(Type.Array(Type.String())),
|
|
60
|
+
ignored: Type.Optional(Type.Boolean()),
|
|
61
|
+
role: Type.Optional(AxValue),
|
|
62
|
+
name: Type.Optional(AxValue),
|
|
63
|
+
value: Type.Optional(AxValue),
|
|
64
|
+
description: Type.Optional(AxValue),
|
|
65
|
+
properties: Type.Optional(
|
|
66
|
+
Type.Array(
|
|
67
|
+
Type.Object(
|
|
68
|
+
{ name: Type.Optional(Type.String()), value: Type.Optional(AxValue) },
|
|
69
|
+
{ additionalProperties: true },
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
backendDOMNodeId: Type.Optional(Type.Number()),
|
|
74
|
+
},
|
|
75
|
+
{ additionalProperties: true },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
export type AxNodeResult = Static<typeof AxNode>;
|
|
79
|
+
|
|
80
|
+
export const COMMANDS = {
|
|
81
|
+
"Target.attachToTarget": cmd(
|
|
82
|
+
Type.Object({ targetId: Type.String(), flatten: Type.Optional(Type.Boolean()) }),
|
|
83
|
+
Type.Object({ sessionId: Type.String() }),
|
|
84
|
+
),
|
|
85
|
+
"Target.detachFromTarget": cmd(
|
|
86
|
+
Type.Object({ sessionId: Type.Optional(Type.String()) }),
|
|
87
|
+
Empty,
|
|
88
|
+
),
|
|
89
|
+
"Target.createTarget": cmd(
|
|
90
|
+
Type.Object({
|
|
91
|
+
url: Type.String(),
|
|
92
|
+
newWindow: Type.Optional(Type.Boolean()),
|
|
93
|
+
background: Type.Optional(Type.Boolean()),
|
|
94
|
+
windowId: Type.Optional(Type.Number()),
|
|
95
|
+
openerId: Type.Optional(Type.String()),
|
|
96
|
+
}),
|
|
97
|
+
Type.Object({ targetId: Type.String() }),
|
|
98
|
+
),
|
|
99
|
+
"Target.closeTarget": cmd(
|
|
100
|
+
Type.Object({ targetId: Type.String() }),
|
|
101
|
+
Type.Object({ success: Type.Optional(Type.Boolean()) }),
|
|
102
|
+
),
|
|
103
|
+
"Target.activateTarget": cmd(Type.Object({ targetId: Type.String() }), Empty),
|
|
104
|
+
"Target.getTargetInfo": cmd(
|
|
105
|
+
Type.Object({ targetId: Type.Optional(Type.String()) }),
|
|
106
|
+
Type.Object({ targetInfo: TargetInfo }),
|
|
107
|
+
),
|
|
108
|
+
"Target.getTargets": cmd(
|
|
109
|
+
Type.Object({}),
|
|
110
|
+
Type.Object({ targetInfos: Type.Array(TargetInfo) }),
|
|
111
|
+
),
|
|
112
|
+
"Target.setDiscoverTargets": cmd(Type.Object({ discover: Type.Boolean() }), Empty),
|
|
113
|
+
|
|
114
|
+
"Browser.getWindowForTarget": cmd(
|
|
115
|
+
Type.Object({ targetId: Type.Optional(Type.String()) }),
|
|
116
|
+
Type.Object({ windowId: Type.Number(), bounds: Type.Optional(Type.Unknown()) }),
|
|
117
|
+
),
|
|
118
|
+
"Browser.setDownloadBehavior": cmd(
|
|
119
|
+
Type.Object({
|
|
120
|
+
behavior: Type.String(),
|
|
121
|
+
downloadPath: Type.Optional(Type.String()),
|
|
122
|
+
eventsEnabled: Type.Optional(Type.Boolean()),
|
|
123
|
+
}),
|
|
124
|
+
Empty,
|
|
125
|
+
),
|
|
126
|
+
|
|
127
|
+
"Page.enable": cmd(Type.Object({}), Empty),
|
|
128
|
+
"Page.bringToFront": cmd(Type.Object({}), Empty),
|
|
129
|
+
"Page.navigate": cmd(
|
|
130
|
+
Type.Object({ url: Type.String(), referrer: Type.Optional(Type.String()) }),
|
|
131
|
+
Type.Object({
|
|
132
|
+
frameId: Type.String(),
|
|
133
|
+
loaderId: Type.Optional(Type.String()),
|
|
134
|
+
errorText: Type.Optional(Type.String()),
|
|
135
|
+
}),
|
|
136
|
+
),
|
|
137
|
+
"Page.reload": cmd(Type.Object({ ignoreCache: Type.Optional(Type.Boolean()) }), Empty),
|
|
138
|
+
"Page.getNavigationHistory": cmd(
|
|
139
|
+
Type.Object({}),
|
|
140
|
+
Type.Object({
|
|
141
|
+
currentIndex: Type.Number(),
|
|
142
|
+
entries: Type.Array(
|
|
143
|
+
Type.Object({ id: Type.Number(), url: Type.String(), title: Type.String() }),
|
|
144
|
+
),
|
|
145
|
+
}),
|
|
146
|
+
),
|
|
147
|
+
"Page.navigateToHistoryEntry": cmd(Type.Object({ entryId: Type.Number() }), Empty),
|
|
148
|
+
"Page.captureScreenshot": cmd(
|
|
149
|
+
Type.Object({
|
|
150
|
+
format: Type.Optional(Type.String()),
|
|
151
|
+
quality: Type.Optional(Type.Number()),
|
|
152
|
+
clip: Type.Optional(Type.Unknown()),
|
|
153
|
+
captureBeyondViewport: Type.Optional(Type.Boolean()),
|
|
154
|
+
}),
|
|
155
|
+
Type.Object({ data: Type.String() }),
|
|
156
|
+
),
|
|
157
|
+
"Page.printToPDF": cmd(Type.Object({}, { additionalProperties: true }), Type.Object({ data: Type.String() })),
|
|
158
|
+
"Page.handleJavaScriptDialog": cmd(
|
|
159
|
+
Type.Object({ accept: Type.Boolean(), promptText: Type.Optional(Type.String()) }),
|
|
160
|
+
Empty,
|
|
161
|
+
),
|
|
162
|
+
|
|
163
|
+
"Runtime.evaluate": cmd(
|
|
164
|
+
Type.Object(
|
|
165
|
+
{
|
|
166
|
+
expression: Type.String(),
|
|
167
|
+
returnByValue: Type.Optional(Type.Boolean()),
|
|
168
|
+
awaitPromise: Type.Optional(Type.Boolean()),
|
|
169
|
+
userGesture: Type.Optional(Type.Boolean()),
|
|
170
|
+
},
|
|
171
|
+
{ additionalProperties: true },
|
|
172
|
+
),
|
|
173
|
+
Type.Object({ result: RemoteObject, exceptionDetails: Type.Optional(ExceptionDetails) }),
|
|
174
|
+
),
|
|
175
|
+
"Runtime.callFunctionOn": cmd(
|
|
176
|
+
Type.Object(
|
|
177
|
+
{
|
|
178
|
+
functionDeclaration: Type.String(),
|
|
179
|
+
objectId: Type.Optional(Type.String()),
|
|
180
|
+
arguments: Type.Optional(Type.Array(CallArgument)),
|
|
181
|
+
returnByValue: Type.Optional(Type.Boolean()),
|
|
182
|
+
awaitPromise: Type.Optional(Type.Boolean()),
|
|
183
|
+
userGesture: Type.Optional(Type.Boolean()),
|
|
184
|
+
},
|
|
185
|
+
{ additionalProperties: true },
|
|
186
|
+
),
|
|
187
|
+
Type.Object({ result: RemoteObject, exceptionDetails: Type.Optional(ExceptionDetails) }),
|
|
188
|
+
),
|
|
189
|
+
"Runtime.releaseObject": cmd(Type.Object({ objectId: Type.String() }), Empty),
|
|
190
|
+
|
|
191
|
+
"DOM.getDocument": cmd(
|
|
192
|
+
Type.Object({ depth: Type.Optional(Type.Number()), pierce: Type.Optional(Type.Boolean()) }),
|
|
193
|
+
Type.Object({ root: Type.Object({ nodeId: Type.Number() }, { additionalProperties: true }) }),
|
|
194
|
+
),
|
|
195
|
+
"DOM.querySelector": cmd(
|
|
196
|
+
Type.Object({ nodeId: Type.Number(), selector: Type.String() }),
|
|
197
|
+
Type.Object({ nodeId: Type.Number() }),
|
|
198
|
+
),
|
|
199
|
+
"DOM.getBoxModel": cmd(
|
|
200
|
+
Type.Object({ nodeId: Type.Optional(Type.Number()), backendNodeId: Type.Optional(Type.Number()) }),
|
|
201
|
+
Type.Object({
|
|
202
|
+
model: Type.Object(
|
|
203
|
+
{ content: Type.Array(Type.Number()), width: Type.Number(), height: Type.Number() },
|
|
204
|
+
{ additionalProperties: true },
|
|
205
|
+
),
|
|
206
|
+
}),
|
|
207
|
+
),
|
|
208
|
+
"DOM.resolveNode": cmd(
|
|
209
|
+
Type.Object({ nodeId: Type.Optional(Type.Number()), backendNodeId: Type.Optional(Type.Number()) }),
|
|
210
|
+
Type.Object({ object: RemoteObject }),
|
|
211
|
+
),
|
|
212
|
+
"DOM.setFileInputFiles": cmd(
|
|
213
|
+
Type.Object({
|
|
214
|
+
files: Type.Array(Type.String()),
|
|
215
|
+
nodeId: Type.Optional(Type.Number()),
|
|
216
|
+
backendNodeId: Type.Optional(Type.Number()),
|
|
217
|
+
objectId: Type.Optional(Type.String()),
|
|
218
|
+
}),
|
|
219
|
+
Empty,
|
|
220
|
+
),
|
|
221
|
+
|
|
222
|
+
"Input.dispatchMouseEvent": cmd(Type.Object({}, { additionalProperties: true }), Empty),
|
|
223
|
+
"Input.dispatchKeyEvent": cmd(Type.Object({}, { additionalProperties: true }), Empty),
|
|
224
|
+
"Input.dispatchDragEvent": cmd(Type.Object({}, { additionalProperties: true }), Empty),
|
|
225
|
+
"Input.insertText": cmd(Type.Object({ text: Type.String() }), Empty),
|
|
226
|
+
|
|
227
|
+
"Network.getResponseBody": cmd(
|
|
228
|
+
Type.Object({ requestId: Type.String() }),
|
|
229
|
+
Type.Object({ body: Type.String(), base64Encoded: Type.Optional(Type.Boolean()) }),
|
|
230
|
+
),
|
|
231
|
+
"Network.setExtraHTTPHeaders": cmd(
|
|
232
|
+
Type.Object({ headers: Type.Record(Type.String(), Type.String()) }),
|
|
233
|
+
Empty,
|
|
234
|
+
),
|
|
235
|
+
"Emulation.setDeviceMetricsOverride": cmd(Type.Object({}, { additionalProperties: true }), Empty),
|
|
236
|
+
"Accessibility.getFullAXTree": cmd(
|
|
237
|
+
Type.Object({ depth: Type.Optional(Type.Number()) }, { additionalProperties: true }),
|
|
238
|
+
Type.Object({ nodes: Type.Array(AxNode) }),
|
|
239
|
+
),
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
export type CdpMethod = keyof typeof COMMANDS;
|
|
243
|
+
export type ParamsOf<M extends CdpMethod> = Static<(typeof COMMANDS)[M]["params"]>;
|
|
244
|
+
export type ResultOf<M extends CdpMethod> = Static<(typeof COMMANDS)[M]["result"]>;
|
|
245
|
+
|
|
246
|
+
export const decodeResult = <M extends CdpMethod>(
|
|
247
|
+
method: M,
|
|
248
|
+
raw: unknown,
|
|
249
|
+
): Result<ResultOf<M>, CdpError> => {
|
|
250
|
+
const spec = COMMANDS[method];
|
|
251
|
+
if (spec === undefined) {
|
|
252
|
+
return err(cdpError("invalid_response", `unknown CDP method`, method));
|
|
253
|
+
}
|
|
254
|
+
if (!spec.validate.Check(raw)) {
|
|
255
|
+
const why = spec.validate.Errors(raw).map((e) => e.message).join("; ");
|
|
256
|
+
return err(cdpError("invalid_response", why, method));
|
|
257
|
+
}
|
|
258
|
+
// Guarded assertion: the check above proves `raw` matches COMMANDS[method].result, which TS cannot correlate from a generic key.
|
|
259
|
+
return ok(raw as ResultOf<M>);
|
|
260
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { decodeEvent } from "./events";
|
|
2
|
+
import { createRecordStore } from "./record-store";
|
|
3
|
+
|
|
4
|
+
export type ConsoleLevel = "log" | "info" | "warn" | "error" | "debug";
|
|
5
|
+
|
|
6
|
+
export type ConsoleRecord = {
|
|
7
|
+
seq: number;
|
|
8
|
+
timestampMs: number;
|
|
9
|
+
level: ConsoleLevel;
|
|
10
|
+
text: string;
|
|
11
|
+
source: "console-api" | "log-entry";
|
|
12
|
+
url?: string;
|
|
13
|
+
lineNumber?: number;
|
|
14
|
+
stackTrace?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ConsoleFilter = {
|
|
18
|
+
levels?: ConsoleLevel[];
|
|
19
|
+
textPattern?: string;
|
|
20
|
+
sinceSeq?: number;
|
|
21
|
+
sinceMs?: number;
|
|
22
|
+
limit?: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ConsoleDrainResult = {
|
|
26
|
+
readonly records: ConsoleRecord[];
|
|
27
|
+
readonly total: number;
|
|
28
|
+
readonly bufferOverflowed: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type ConsoleBuffer = {
|
|
32
|
+
ingestConsoleApi(p: unknown): void;
|
|
33
|
+
ingestLogEntry(p: unknown): void;
|
|
34
|
+
drain(filter: ConsoleFilter): ConsoleDrainResult;
|
|
35
|
+
clear(): void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const PER_ARG_CAP = 2000;
|
|
39
|
+
const MAX_STACK_FRAMES = 3;
|
|
40
|
+
|
|
41
|
+
const compileTextMatcher = (pattern: string): ((text: string) => boolean) => {
|
|
42
|
+
if (pattern.length >= 2 && pattern.startsWith("/") && pattern.endsWith("/")) {
|
|
43
|
+
try {
|
|
44
|
+
const re = new RegExp(pattern.slice(1, -1));
|
|
45
|
+
return (text) => re.test(text);
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
return (text) => text.includes(pattern);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const truncateArg = (s: string): string =>
|
|
52
|
+
s.length > PER_ARG_CAP ? s.slice(0, PER_ARG_CAP) + "…" : s;
|
|
53
|
+
|
|
54
|
+
const stringifyRemoteObject = (
|
|
55
|
+
arg: { type?: string; subtype?: string; value?: unknown; description?: string; unserializableValue?: string },
|
|
56
|
+
): string => {
|
|
57
|
+
if (arg.unserializableValue !== undefined) return arg.unserializableValue;
|
|
58
|
+
if (arg.value !== undefined) {
|
|
59
|
+
if (typeof arg.value === "string") return arg.value;
|
|
60
|
+
try {
|
|
61
|
+
return JSON.stringify(arg.value);
|
|
62
|
+
} catch {
|
|
63
|
+
return String(arg.value);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (arg.description !== undefined) return arg.description;
|
|
67
|
+
return arg.type ?? "";
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const mapConsoleApiLevel = (type: string | undefined): ConsoleLevel => {
|
|
71
|
+
switch (type) {
|
|
72
|
+
case "error":
|
|
73
|
+
case "assert":
|
|
74
|
+
return "error";
|
|
75
|
+
case "warning":
|
|
76
|
+
return "warn";
|
|
77
|
+
case "debug":
|
|
78
|
+
case "trace":
|
|
79
|
+
return "debug";
|
|
80
|
+
case "info":
|
|
81
|
+
return "info";
|
|
82
|
+
default:
|
|
83
|
+
return "log";
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const mapLogEntryLevel = (level: string | undefined): ConsoleLevel => {
|
|
88
|
+
switch (level) {
|
|
89
|
+
case "error":
|
|
90
|
+
return "error";
|
|
91
|
+
case "warning":
|
|
92
|
+
return "warn";
|
|
93
|
+
case "verbose":
|
|
94
|
+
return "debug";
|
|
95
|
+
default:
|
|
96
|
+
return "info";
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const formatStackTrace = (
|
|
101
|
+
st: { callFrames?: ReadonlyArray<{ url?: string; lineNumber?: number; functionName?: string }> } | undefined,
|
|
102
|
+
): string | undefined => {
|
|
103
|
+
const frames = st?.callFrames;
|
|
104
|
+
if (!frames || frames.length === 0) return undefined;
|
|
105
|
+
const top = frames.slice(0, MAX_STACK_FRAMES);
|
|
106
|
+
const rendered = top.map((f) => {
|
|
107
|
+
const fn = f.functionName !== undefined && f.functionName !== "" ? f.functionName : "<anonymous>";
|
|
108
|
+
const loc = f.url !== undefined && f.url !== "" ? `${f.url}:${(f.lineNumber ?? 0) + 1}` : "<unknown>";
|
|
109
|
+
return ` at ${fn} (${loc})`;
|
|
110
|
+
});
|
|
111
|
+
return rendered.join("\n");
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const createConsoleBuffer = (capacity = 500): ConsoleBuffer => {
|
|
115
|
+
const records = createRecordStore<number, ConsoleRecord>(capacity);
|
|
116
|
+
let nextSeq = 1;
|
|
117
|
+
|
|
118
|
+
const insert = (rec: ConsoleRecord): void => {
|
|
119
|
+
records.set(rec.seq, rec);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
ingestConsoleApi(p) {
|
|
124
|
+
const decoded = decodeEvent("Runtime.consoleAPICalled", p);
|
|
125
|
+
if (!decoded.success) return;
|
|
126
|
+
const params = decoded.data;
|
|
127
|
+
const level = mapConsoleApiLevel(params.type);
|
|
128
|
+
const argTexts = (params.args ?? []).map((a) => truncateArg(stringifyRemoteObject(a)));
|
|
129
|
+
const text = argTexts.join(" ");
|
|
130
|
+
const stack = level === "error" || level === "warn" ? formatStackTrace(params.stackTrace) : undefined;
|
|
131
|
+
const top = params.stackTrace?.callFrames?.[0];
|
|
132
|
+
const rec: ConsoleRecord = {
|
|
133
|
+
seq: nextSeq++,
|
|
134
|
+
timestampMs: Date.now(),
|
|
135
|
+
level,
|
|
136
|
+
text,
|
|
137
|
+
source: "console-api",
|
|
138
|
+
...(top?.url !== undefined && top.url !== "" ? { url: top.url } : {}),
|
|
139
|
+
...(top?.lineNumber !== undefined ? { lineNumber: top.lineNumber + 1 } : {}),
|
|
140
|
+
...(stack !== undefined ? { stackTrace: stack } : {}),
|
|
141
|
+
};
|
|
142
|
+
insert(rec);
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
ingestLogEntry(p) {
|
|
146
|
+
const decoded = decodeEvent("Log.entryAdded", p);
|
|
147
|
+
const entry = decoded.success ? decoded.data.entry : undefined;
|
|
148
|
+
if (!entry) return;
|
|
149
|
+
const level = mapLogEntryLevel(entry.level);
|
|
150
|
+
const text = truncateArg(entry.text ?? "");
|
|
151
|
+
const stack = level === "error" || level === "warn" ? formatStackTrace(entry.stackTrace) : undefined;
|
|
152
|
+
const rec: ConsoleRecord = {
|
|
153
|
+
seq: nextSeq++,
|
|
154
|
+
timestampMs: Date.now(),
|
|
155
|
+
level,
|
|
156
|
+
text,
|
|
157
|
+
source: "log-entry",
|
|
158
|
+
...(entry.url !== undefined && entry.url !== "" ? { url: entry.url } : {}),
|
|
159
|
+
...(entry.lineNumber !== undefined ? { lineNumber: entry.lineNumber + 1 } : {}),
|
|
160
|
+
...(stack !== undefined ? { stackTrace: stack } : {}),
|
|
161
|
+
};
|
|
162
|
+
insert(rec);
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
drain(filter) {
|
|
166
|
+
const matchText = filter.textPattern !== undefined ? compileTextMatcher(filter.textPattern) : undefined;
|
|
167
|
+
const levels = filter.levels && filter.levels.length > 0 ? new Set(filter.levels) : undefined;
|
|
168
|
+
const cutoff = filter.sinceMs !== undefined ? Date.now() - filter.sinceMs : undefined;
|
|
169
|
+
const sinceSeq = filter.sinceSeq;
|
|
170
|
+
|
|
171
|
+
const matched: ConsoleRecord[] = [];
|
|
172
|
+
for (const r of records.values()) {
|
|
173
|
+
if (sinceSeq !== undefined && r.seq <= sinceSeq) continue;
|
|
174
|
+
if (levels && !levels.has(r.level)) continue;
|
|
175
|
+
if (cutoff !== undefined && r.timestampMs < cutoff) continue;
|
|
176
|
+
if (matchText && !matchText(r.text)) continue;
|
|
177
|
+
matched.push({ ...r });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return records.page(matched, filter.limit);
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
clear() {
|
|
184
|
+
records.clear();
|
|
185
|
+
nextSeq = 1;
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { connect as netConnect } from "node:net";
|
|
4
|
+
import { request as httpRequest } from "node:http";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { Compile } from "typebox/compile";
|
|
7
|
+
import { parseJson } from "../schemas/parse";
|
|
8
|
+
import { type Result, err, ok } from "../util/result";
|
|
9
|
+
import { userDataDirCandidates } from "../profile/paths";
|
|
10
|
+
import { type CdpError, cdpError } from "./errors";
|
|
11
|
+
import { errnoCode } from "../util/guards";
|
|
12
|
+
|
|
13
|
+
const PORT_PROBE_DEADLINE_MS = 30_000;
|
|
14
|
+
const PORT_PROBE_INTERVAL_MS = 1_000;
|
|
15
|
+
|
|
16
|
+
const VersionResponse = Type.Object(
|
|
17
|
+
{ webSocketDebuggerUrl: Type.Optional(Type.String()) },
|
|
18
|
+
{ additionalProperties: true },
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const versionValidator = Compile(VersionResponse);
|
|
22
|
+
|
|
23
|
+
export type CdpEndpoint = {
|
|
24
|
+
readonly wsUrl: string;
|
|
25
|
+
readonly userDataDir?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const probePort = (port: number): Promise<Result<void, CdpError>> =>
|
|
29
|
+
new Promise((resolve) => {
|
|
30
|
+
const sock = netConnect({ host: "127.0.0.1", port });
|
|
31
|
+
let settled = false;
|
|
32
|
+
const finish = (r: Result<void, CdpError>): void => {
|
|
33
|
+
if (settled) return;
|
|
34
|
+
settled = true;
|
|
35
|
+
sock.setTimeout(0);
|
|
36
|
+
sock.destroy();
|
|
37
|
+
resolve(r);
|
|
38
|
+
};
|
|
39
|
+
sock.setTimeout(1000, () => finish(err(cdpError("discovery_failed", "probe timeout"))));
|
|
40
|
+
sock.once("error", (e) => finish(err(cdpError("discovery_failed", e.message))));
|
|
41
|
+
sock.once("connect", () => finish(ok(undefined)));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const waitForPort = async (port: number): Promise<Result<void, CdpError>> => {
|
|
45
|
+
const end = Date.now() + PORT_PROBE_DEADLINE_MS;
|
|
46
|
+
let lastMessage = "unknown";
|
|
47
|
+
while (Date.now() < end) {
|
|
48
|
+
const probe = await probePort(port);
|
|
49
|
+
if (probe.success) return probe;
|
|
50
|
+
lastMessage = probe.error.message;
|
|
51
|
+
await new Promise((r) => setTimeout(r, PORT_PROBE_INTERVAL_MS));
|
|
52
|
+
}
|
|
53
|
+
return err(cdpError(
|
|
54
|
+
"discovery_failed",
|
|
55
|
+
`Chrome's remote-debugging page is open, but DevTools is not live yet on 127.0.0.1:${port} — if Chrome opened a profile picker, choose your normal profile first, then tick the checkbox and click Allow if shown (last error: ${lastMessage})`,
|
|
56
|
+
));
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const isPortLive = async (port: number): Promise<boolean> => (await probePort(port)).success;
|
|
60
|
+
|
|
61
|
+
// /json/version is authoritative: a stale DevToolsActivePort file may name a browser that has since exited while another now owns the port.
|
|
62
|
+
const queryLiveWsUrl = (port: number, timeoutMs = 1_500): Promise<string | null> =>
|
|
63
|
+
new Promise<string | null>((resolve) => {
|
|
64
|
+
const req = httpRequest(
|
|
65
|
+
{ host: "127.0.0.1", port, path: "/json/version", method: "GET", timeout: timeoutMs },
|
|
66
|
+
(res) => {
|
|
67
|
+
if (res.statusCode !== 200) {
|
|
68
|
+
res.resume();
|
|
69
|
+
resolve(null);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const chunks: Buffer[] = [];
|
|
73
|
+
res.on("data", (c: Buffer) => chunks.push(c));
|
|
74
|
+
res.on("end", () => {
|
|
75
|
+
const parsed = parseJson(Buffer.concat(chunks).toString("utf8"), versionValidator);
|
|
76
|
+
resolve(parsed.success ? parsed.data.webSocketDebuggerUrl ?? null : null);
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
req.on("timeout", () => { req.destroy(); resolve(null); });
|
|
81
|
+
req.on("error", () => resolve(null));
|
|
82
|
+
req.end();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const fallbackPorts = (): ReadonlyArray<number> => {
|
|
86
|
+
const ports = new Set<number>([9222]);
|
|
87
|
+
const fromEnv = process.env["BU_CDP_PORTS"];
|
|
88
|
+
if (fromEnv) {
|
|
89
|
+
for (const tok of fromEnv.split(",")) {
|
|
90
|
+
const n = Number(tok.trim());
|
|
91
|
+
if (Number.isInteger(n) && n > 0 && n < 65536) ports.add(n);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return Array.from(ports);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
type Candidate = {
|
|
98
|
+
readonly port: number;
|
|
99
|
+
readonly path: string;
|
|
100
|
+
readonly mtimeMs: number;
|
|
101
|
+
readonly userDataDir: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Read here rather than in the client: the daemon owns the only socket to Chrome, so an override the daemon cannot see has no effect.
|
|
105
|
+
export const endpointFromEnv = (env: NodeJS.ProcessEnv = process.env): CdpEndpoint | undefined => {
|
|
106
|
+
const wsUrl = env["BU_CDP_WS"]?.trim();
|
|
107
|
+
return wsUrl ? { wsUrl } : undefined;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>> => {
|
|
111
|
+
const override = endpointFromEnv();
|
|
112
|
+
if (override) return ok(override);
|
|
113
|
+
|
|
114
|
+
const dirs = userDataDirCandidates();
|
|
115
|
+
|
|
116
|
+
const candidates: Candidate[] = [];
|
|
117
|
+
const readErrors: string[] = [];
|
|
118
|
+
for (const base of dirs) {
|
|
119
|
+
const portFile = join(base, "DevToolsActivePort");
|
|
120
|
+
let raw: string;
|
|
121
|
+
let mtimeMs = 0;
|
|
122
|
+
try {
|
|
123
|
+
raw = await readFile(portFile, "utf8");
|
|
124
|
+
try {
|
|
125
|
+
mtimeMs = (await stat(portFile)).mtimeMs;
|
|
126
|
+
} catch {}
|
|
127
|
+
} catch (e) {
|
|
128
|
+
// EPERM/EACCES is common under sandboxes; remember it and fall back to network probing rather than failing the whole discovery.
|
|
129
|
+
const code = errnoCode(e);
|
|
130
|
+
if (code === "ENOENT" || code === undefined) continue;
|
|
131
|
+
if (code === "EPERM" || code === "EACCES") {
|
|
132
|
+
readErrors.push(`${portFile}: ${code}`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
return err(cdpError("discovery_failed", `failed to read ${portFile}: ${e instanceof Error ? e.message : String(e)}`));
|
|
136
|
+
}
|
|
137
|
+
const lines = raw.trim().split("\n");
|
|
138
|
+
if (lines.length < 2) continue;
|
|
139
|
+
const port = lines[0]?.trim();
|
|
140
|
+
const path = lines[1]?.trim();
|
|
141
|
+
if (!port || !path) continue;
|
|
142
|
+
// An out-of-range port makes net.connect throw ERR_SOCKET_BAD_PORT synchronously, escaping discoverWsUrl instead of surfacing as a Result error.
|
|
143
|
+
const portNum = Number(port);
|
|
144
|
+
if (!Number.isInteger(portNum) || portNum <= 0 || portNum >= 65536) continue;
|
|
145
|
+
candidates.push({ port: portNum, path, mtimeMs, userDataDir: base });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (candidates.length === 0) {
|
|
149
|
+
for (const port of fallbackPorts()) {
|
|
150
|
+
if (!(await isPortLive(port))) continue;
|
|
151
|
+
const live = await queryLiveWsUrl(port);
|
|
152
|
+
if (live) return ok({ wsUrl: live });
|
|
153
|
+
}
|
|
154
|
+
const permHint = readErrors.length > 0
|
|
155
|
+
? `\n\nDevToolsActivePort reads were denied (${readErrors.join(", ")}); if you're running in a sandbox, grant read access to those files or set BU_CDP_WS / BU_CDP_PORTS to bypass file discovery.`
|
|
156
|
+
: "";
|
|
157
|
+
return err(cdpError(
|
|
158
|
+
"discovery_failed",
|
|
159
|
+
`DevToolsActivePort not found in ${dirs.join(", ")} — open chrome://inspect/#remote-debugging (or brave://inspect, edge://inspect) in your browser, tick the checkbox, click Allow, then retry. Or set BU_CDP_WS to a remote browser endpoint.${permHint}`,
|
|
160
|
+
));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const byPort = new Map<number, Candidate>();
|
|
164
|
+
for (const c of candidates) {
|
|
165
|
+
const prev = byPort.get(c.port);
|
|
166
|
+
if (!prev || c.mtimeMs > prev.mtimeMs) byPort.set(c.port, c);
|
|
167
|
+
}
|
|
168
|
+
const unique = Array.from(byPort.values());
|
|
169
|
+
|
|
170
|
+
const liveChecks = await Promise.all(unique.map((c) => isPortLive(c.port)));
|
|
171
|
+
const liveCandidates = unique.filter((_, i) => liveChecks[i]);
|
|
172
|
+
const ordered = liveCandidates.length > 0 ? liveCandidates : unique;
|
|
173
|
+
|
|
174
|
+
// Some browsers disable the /json/version endpoint when remote debugging is toggled via chrome://inspect rather than a launch flag, hence the DevToolsActivePort fallback.
|
|
175
|
+
let lastErr: Result<CdpEndpoint, CdpError> | null = null;
|
|
176
|
+
for (const c of ordered) {
|
|
177
|
+
const ready = await waitForPort(c.port);
|
|
178
|
+
if (!ready.success) {
|
|
179
|
+
lastErr = err(ready.error);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const liveUrl = await queryLiveWsUrl(c.port);
|
|
183
|
+
return ok({ wsUrl: liveUrl ?? `ws://127.0.0.1:${c.port}${c.path}`, userDataDir: c.userDataDir });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const port of fallbackPorts()) {
|
|
187
|
+
if (byPort.has(port) || !(await isPortLive(port))) continue;
|
|
188
|
+
const liveUrl = await queryLiveWsUrl(port);
|
|
189
|
+
if (liveUrl) return ok({ wsUrl: liveUrl });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return lastErr ?? err(cdpError("discovery_failed", "no live DevTools endpoint among discovered candidates"));
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
|
|
196
|
+
const endpoint = await discoverEndpoint();
|
|
197
|
+
return endpoint.success ? ok(endpoint.data.wsUrl) : endpoint;
|
|
198
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type CdpErrorKind =
|
|
2
|
+
| "transport_closed"
|
|
3
|
+
| "timeout"
|
|
4
|
+
| "session_not_found"
|
|
5
|
+
| "remote_error"
|
|
6
|
+
| "discovery_failed"
|
|
7
|
+
| "invalid_response";
|
|
8
|
+
|
|
9
|
+
export type CdpError = {
|
|
10
|
+
readonly kind: CdpErrorKind;
|
|
11
|
+
readonly message: string;
|
|
12
|
+
readonly method?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Chrome reports a dead session as a generic protocol error, so the message text is the only signal that a reattach — not a retry — is what will fix it.
|
|
16
|
+
export const classifyRemoteError = (message: string): CdpErrorKind =>
|
|
17
|
+
message.includes("Session with given id not found") ? "session_not_found" : "remote_error";
|
|
18
|
+
|
|
19
|
+
export const cdpError = (
|
|
20
|
+
kind: CdpErrorKind,
|
|
21
|
+
message: string,
|
|
22
|
+
method?: string,
|
|
23
|
+
): CdpError => ({ kind, message, ...(method !== undefined ? { method } : {}) });
|