@thoughtflow/browser 0.2.0 → 0.3.1
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/package.json +4 -3
- package/src/tools/workspace/browser-session-pool.ts +376 -0
- package/src/tools/workspace/browser.tool.ts +362 -97
- package/src/tools/workspace/vision-browser.tool.ts +158 -4
- package/tsconfig.json +4 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -8
- package/dist/index.js.map +0 -1
- package/dist/tools/workspace/browser.tool.d.ts +0 -23
- package/dist/tools/workspace/browser.tool.d.ts.map +0 -1
- package/dist/tools/workspace/browser.tool.js +0 -170
- package/dist/tools/workspace/browser.tool.js.map +0 -1
- package/dist/tools/workspace/vision-browser.tool.d.ts +0 -30
- package/dist/tools/workspace/vision-browser.tool.d.ts.map +0 -1
- package/dist/tools/workspace/vision-browser.tool.js +0 -269
- package/dist/tools/workspace/vision-browser.tool.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thoughtflow/browser",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"types": "dist/index.d.ts",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"build": "tsc -p tsconfig.build.json"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@thoughtflow/core": "
|
|
11
|
-
"playwright": "^1.59.1"
|
|
10
|
+
"@thoughtflow/core": "*",
|
|
11
|
+
"playwright": "^1.59.1",
|
|
12
|
+
"zod": "^3.24.3"
|
|
12
13
|
}
|
|
13
14
|
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrowserSessionPool — manages multiple headless browser sessions with
|
|
3
|
+
* isolated pages, console logs, and network activity per session.
|
|
4
|
+
*
|
|
5
|
+
* Auto-cleans up sessions after 5 minutes of inactivity.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
interface ConsoleEntry {
|
|
9
|
+
type: string
|
|
10
|
+
text: string
|
|
11
|
+
location: string
|
|
12
|
+
timestamp: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface NetworkEntry {
|
|
16
|
+
idx: number
|
|
17
|
+
method: string
|
|
18
|
+
url: string
|
|
19
|
+
status?: number
|
|
20
|
+
statusText?: string
|
|
21
|
+
duration?: number
|
|
22
|
+
error?: string
|
|
23
|
+
reqHeaders?: Record<string, string>
|
|
24
|
+
reqBody?: string
|
|
25
|
+
resHeaders?: Record<string, string>
|
|
26
|
+
resBody?: string
|
|
27
|
+
timestamp: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface BrowserPage {
|
|
31
|
+
id: string
|
|
32
|
+
page: import("playwright").Page
|
|
33
|
+
consoleEntries: ConsoleEntry[]
|
|
34
|
+
networkEntries: NetworkEntry[]
|
|
35
|
+
netIdx: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface BrowserSession {
|
|
39
|
+
browser: import("playwright").Browser
|
|
40
|
+
pages: BrowserPage[]
|
|
41
|
+
activePageId: string
|
|
42
|
+
initUrl: string
|
|
43
|
+
createdAt: number
|
|
44
|
+
lastActivity: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SESSION_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
|
48
|
+
|
|
49
|
+
export class BrowserSessionPool {
|
|
50
|
+
private sessions = new Map<string, BrowserSession>()
|
|
51
|
+
private cleanupTimer: ReturnType<typeof setInterval> | null = null
|
|
52
|
+
|
|
53
|
+
/** Create or retrieve a session. If sessionId is empty, creates a new one. */
|
|
54
|
+
async getOrCreate(sessionId?: string): Promise<{ sessionId: string; page: import("playwright").Page }> {
|
|
55
|
+
if (sessionId && this.sessions.has(sessionId)) {
|
|
56
|
+
const session = this.sessions.get(sessionId)!
|
|
57
|
+
session.lastActivity = Date.now()
|
|
58
|
+
const activePage = session.pages.find(p => p.id === session.activePageId)
|
|
59
|
+
if (activePage && !activePage.page.isClosed()) {
|
|
60
|
+
return { sessionId, page: activePage.page }
|
|
61
|
+
}
|
|
62
|
+
// Active page closed — create a new one
|
|
63
|
+
return this.newPage(sessionId)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Create new session
|
|
67
|
+
const id = sessionId ?? `br_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
69
|
+
const { chromium } = require("playwright") as typeof import("playwright")
|
|
70
|
+
const browser = await chromium.launch({ headless: true })
|
|
71
|
+
const page = await browser.newPage()
|
|
72
|
+
|
|
73
|
+
const browserPage: BrowserPage = {
|
|
74
|
+
id: `pg_${Date.now()}`,
|
|
75
|
+
page,
|
|
76
|
+
consoleEntries: [],
|
|
77
|
+
networkEntries: [],
|
|
78
|
+
netIdx: 0,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const session: BrowserSession = {
|
|
82
|
+
browser,
|
|
83
|
+
pages: [browserPage],
|
|
84
|
+
activePageId: browserPage.id,
|
|
85
|
+
initUrl: "",
|
|
86
|
+
createdAt: Date.now(),
|
|
87
|
+
lastActivity: Date.now(),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.sessions.set(id, session)
|
|
91
|
+
this.startCleanup()
|
|
92
|
+
|
|
93
|
+
// Attach listeners
|
|
94
|
+
this.attachListeners(browserPage)
|
|
95
|
+
|
|
96
|
+
return { sessionId: id, page }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Navigate the active page of a session. */
|
|
100
|
+
async getPage(sessionId: string): Promise<import("playwright").Page | null> {
|
|
101
|
+
const session = this.sessions.get(sessionId)
|
|
102
|
+
if (!session) return null
|
|
103
|
+
session.lastActivity = Date.now()
|
|
104
|
+
const active = session.pages.find(p => p.id === session.activePageId)
|
|
105
|
+
if (!active || active.page.isClosed()) return null
|
|
106
|
+
return active.page
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Open a new tab in an existing session. */
|
|
110
|
+
async newPage(sessionId: string): Promise<{ sessionId: string; page: import("playwright").Page }> {
|
|
111
|
+
const session = this.sessions.get(sessionId)
|
|
112
|
+
if (!session) throw new Error(`Session ${sessionId} not found`)
|
|
113
|
+
|
|
114
|
+
session.lastActivity = Date.now()
|
|
115
|
+
const page = await session.browser.newPage()
|
|
116
|
+
|
|
117
|
+
const browserPage: BrowserPage = {
|
|
118
|
+
id: `pg_${Date.now()}`,
|
|
119
|
+
page,
|
|
120
|
+
consoleEntries: [],
|
|
121
|
+
networkEntries: [],
|
|
122
|
+
netIdx: 0,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
session.pages.push(browserPage)
|
|
126
|
+
session.activePageId = browserPage.id
|
|
127
|
+
this.attachListeners(browserPage)
|
|
128
|
+
|
|
129
|
+
return { sessionId, page }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Switch active tab. */
|
|
133
|
+
switchPage(sessionId: string, pageId: string): boolean {
|
|
134
|
+
const session = this.sessions.get(sessionId)
|
|
135
|
+
if (!session) return false
|
|
136
|
+
const exists = session.pages.find(p => p.id === pageId && !p.page.isClosed())
|
|
137
|
+
if (!exists) return false
|
|
138
|
+
session.activePageId = pageId
|
|
139
|
+
session.lastActivity = Date.now()
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Close a tab (or the active one if pageId omitted). */
|
|
144
|
+
async closePage(sessionId: string, pageId?: string): Promise<boolean> {
|
|
145
|
+
const session = this.sessions.get(sessionId)
|
|
146
|
+
if (!session) return false
|
|
147
|
+
session.lastActivity = Date.now()
|
|
148
|
+
|
|
149
|
+
const targetId = pageId ?? session.activePageId
|
|
150
|
+
const idx = session.pages.findIndex(p => p.id === targetId)
|
|
151
|
+
if (idx === -1) return false
|
|
152
|
+
|
|
153
|
+
const bp = session.pages[idx]!
|
|
154
|
+
await bp.page.close().catch(() => {})
|
|
155
|
+
session.pages.splice(idx, 1)
|
|
156
|
+
|
|
157
|
+
// If we closed the active page, switch to another
|
|
158
|
+
if (session.activePageId === targetId && session.pages.length > 0) {
|
|
159
|
+
session.activePageId = session.pages[0]!.id
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return true
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Destroy a session completely. */
|
|
166
|
+
async destroy(sessionId: string): Promise<void> {
|
|
167
|
+
const session = this.sessions.get(sessionId)
|
|
168
|
+
if (!session) return
|
|
169
|
+
|
|
170
|
+
for (const bp of session.pages) {
|
|
171
|
+
await bp.page.close().catch(() => {})
|
|
172
|
+
}
|
|
173
|
+
await session.browser.close().catch(() => {})
|
|
174
|
+
this.sessions.delete(sessionId)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Destroy all sessions. */
|
|
178
|
+
async destroyAll(): Promise<number> {
|
|
179
|
+
let count = 0
|
|
180
|
+
for (const [id, session] of this.sessions) {
|
|
181
|
+
for (const bp of session.pages) {
|
|
182
|
+
await bp.page.close().catch(() => {})
|
|
183
|
+
}
|
|
184
|
+
await session.browser.close().catch(() => {})
|
|
185
|
+
this.sessions.delete(id)
|
|
186
|
+
count++
|
|
187
|
+
}
|
|
188
|
+
if (this.cleanupTimer) {
|
|
189
|
+
clearInterval(this.cleanupTimer)
|
|
190
|
+
this.cleanupTimer = null
|
|
191
|
+
}
|
|
192
|
+
return count
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Set the initial navigation URL for a session. */
|
|
196
|
+
setInitUrl(sessionId: string, url: string): void {
|
|
197
|
+
const session = this.sessions.get(sessionId)
|
|
198
|
+
if (session && !session.initUrl) {
|
|
199
|
+
session.initUrl = url
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** List all active sessions with metadata. */
|
|
204
|
+
list(): Array<{
|
|
205
|
+
sessionId: string
|
|
206
|
+
initUrl: string
|
|
207
|
+
currentUrl: string
|
|
208
|
+
pageCount: number
|
|
209
|
+
activePageId: string
|
|
210
|
+
createdAt: number
|
|
211
|
+
lastActivity: number
|
|
212
|
+
status: "active" | "idle"
|
|
213
|
+
}> {
|
|
214
|
+
const now = Date.now()
|
|
215
|
+
const result: Array<{
|
|
216
|
+
sessionId: string
|
|
217
|
+
initUrl: string
|
|
218
|
+
currentUrl: string
|
|
219
|
+
pageCount: number
|
|
220
|
+
activePageId: string
|
|
221
|
+
createdAt: number
|
|
222
|
+
lastActivity: number
|
|
223
|
+
status: "active" | "idle"
|
|
224
|
+
}> = []
|
|
225
|
+
|
|
226
|
+
for (const [id, session] of this.sessions) {
|
|
227
|
+
const active = session.pages.find(p => p.id === session.activePageId && !p.page.isClosed())
|
|
228
|
+
const idleMs = now - session.lastActivity
|
|
229
|
+
result.push({
|
|
230
|
+
sessionId: id,
|
|
231
|
+
initUrl: session.initUrl || "(no navigation yet)",
|
|
232
|
+
currentUrl: active ? active.page.url() : "(closed)",
|
|
233
|
+
pageCount: session.pages.filter(p => !p.page.isClosed()).length,
|
|
234
|
+
activePageId: session.activePageId,
|
|
235
|
+
createdAt: session.createdAt,
|
|
236
|
+
lastActivity: session.lastActivity,
|
|
237
|
+
status: idleMs > 60_000 ? "idle" : "active",
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
return result
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Get console logs for active page. */
|
|
244
|
+
getConsole(sessionId: string): ConsoleEntry[] {
|
|
245
|
+
return this.getActivePageData(sessionId)?.consoleEntries ?? []
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Get network entries for active page. */
|
|
249
|
+
getNetwork(sessionId: string): NetworkEntry[] {
|
|
250
|
+
return this.getActivePageData(sessionId)?.networkEntries ?? []
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Clear console logs for active page. */
|
|
254
|
+
clearConsole(sessionId: string): void {
|
|
255
|
+
const bp = this.getActivePageData(sessionId)
|
|
256
|
+
if (bp) bp.consoleEntries = []
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Clear network entries for active page. */
|
|
260
|
+
clearNetwork(sessionId: string): void {
|
|
261
|
+
const bp = this.getActivePageData(sessionId)
|
|
262
|
+
if (bp) bp.networkEntries = []
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Cache a screenshot base64 string for the session. */
|
|
266
|
+
cacheScreenshot(sessionId: string, base64: string): void {
|
|
267
|
+
const session = this.sessions.get(sessionId)
|
|
268
|
+
if (session) (session as any).cachedScreenshot = base64
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Get cached screenshot base64 string. */
|
|
272
|
+
getCachedScreenshot(sessionId: string): string | null {
|
|
273
|
+
const session = this.sessions.get(sessionId)
|
|
274
|
+
return (session as any).cachedScreenshot ?? null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** List all open tabs in a session. */
|
|
278
|
+
listPages(sessionId: string): Array<{ id: string; url: string; title: string }> {
|
|
279
|
+
const session = this.sessions.get(sessionId)
|
|
280
|
+
if (!session) return []
|
|
281
|
+
return session.pages
|
|
282
|
+
.filter(p => !p.page.isClosed())
|
|
283
|
+
.map(p => ({
|
|
284
|
+
id: p.id,
|
|
285
|
+
url: p.page.url(),
|
|
286
|
+
title: "", // Will be filled by caller if needed
|
|
287
|
+
active: p.id === session.activePageId,
|
|
288
|
+
}))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Private ────────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
private getActivePageData(sessionId: string): BrowserPage | null {
|
|
294
|
+
const session = this.sessions.get(sessionId)
|
|
295
|
+
if (!session) return null
|
|
296
|
+
session.lastActivity = Date.now()
|
|
297
|
+
return session.pages.find(p => p.id === session.activePageId && !p.page.isClosed()) ?? null
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private attachListeners(bp: BrowserPage): void {
|
|
301
|
+
// Console
|
|
302
|
+
bp.page.on("console", (msg) => {
|
|
303
|
+
bp.consoleEntries.push({
|
|
304
|
+
type: msg.type(),
|
|
305
|
+
text: msg.text(),
|
|
306
|
+
location: msg.location().url ? `${msg.location().url}:${msg.location().lineNumber}` : "",
|
|
307
|
+
timestamp: Date.now(),
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
// Network
|
|
312
|
+
const timings = new Map<string, number>()
|
|
313
|
+
bp.page.on("request", (req) => {
|
|
314
|
+
const idx = ++bp.netIdx
|
|
315
|
+
timings.set(req.url() + req.method(), Date.now())
|
|
316
|
+
let reqBody: string | undefined
|
|
317
|
+
try { reqBody = req.postData() ?? undefined } catch {}
|
|
318
|
+
bp.networkEntries.push({
|
|
319
|
+
idx, method: req.method(), url: req.url(),
|
|
320
|
+
reqHeaders: req.headers() as Record<string, string>,
|
|
321
|
+
reqBody, timestamp: Date.now(),
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
bp.page.on("response", async (resp) => {
|
|
325
|
+
const key = resp.url() + resp.request().method()
|
|
326
|
+
const start = timings.get(key)
|
|
327
|
+
const duration = start ? Date.now() - start : undefined
|
|
328
|
+
let entry = bp.networkEntries.find(e => e.url === resp.url() && e.method === resp.request().method())
|
|
329
|
+
if (!entry) {
|
|
330
|
+
entry = { idx: ++bp.netIdx, method: resp.request().method(), url: resp.url(), timestamp: Date.now() }
|
|
331
|
+
bp.networkEntries.push(entry)
|
|
332
|
+
}
|
|
333
|
+
entry.status = resp.status()
|
|
334
|
+
entry.statusText = resp.statusText()
|
|
335
|
+
entry.duration = duration
|
|
336
|
+
entry.resHeaders = resp.headers() as Record<string, string>
|
|
337
|
+
try {
|
|
338
|
+
const ct = resp.headers()["content-type"] ?? ""
|
|
339
|
+
if (ct.includes("json") || ct.includes("text") || ct.includes("xml") || ct.includes("javascript")) {
|
|
340
|
+
entry.resBody = await resp.text().catch(() => undefined)
|
|
341
|
+
}
|
|
342
|
+
} catch {}
|
|
343
|
+
})
|
|
344
|
+
bp.page.on("requestfailed", (req) => {
|
|
345
|
+
bp.networkEntries.push({
|
|
346
|
+
idx: ++bp.netIdx, method: req.method(), url: req.url(),
|
|
347
|
+
error: req.failure()?.errorText ?? "Unknown error",
|
|
348
|
+
reqHeaders: req.headers() as Record<string, string>,
|
|
349
|
+
timestamp: Date.now(),
|
|
350
|
+
})
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private startCleanup(): void {
|
|
355
|
+
if (this.cleanupTimer) return
|
|
356
|
+
this.cleanupTimer = setInterval(() => {
|
|
357
|
+
const now = Date.now()
|
|
358
|
+
for (const [id, session] of this.sessions) {
|
|
359
|
+
if (now - session.lastActivity > SESSION_TTL_MS) {
|
|
360
|
+
for (const bp of session.pages) {
|
|
361
|
+
bp.page.close().catch(() => {})
|
|
362
|
+
}
|
|
363
|
+
session.browser.close().catch(() => {})
|
|
364
|
+
this.sessions.delete(id)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (this.sessions.size === 0 && this.cleanupTimer) {
|
|
368
|
+
clearInterval(this.cleanupTimer)
|
|
369
|
+
this.cleanupTimer = null
|
|
370
|
+
}
|
|
371
|
+
}, 60_000).unref()
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Singleton instance */
|
|
376
|
+
export const browserPool = new BrowserSessionPool()
|