@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 CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@thoughtflow/browser",
3
- "version": "0.2.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": "0.2.0",
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()