playwriter 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/bippy.js +5 -5
  2. package/dist/browser-config.d.ts.map +1 -1
  3. package/dist/browser-config.js +8 -2
  4. package/dist/browser-config.js.map +1 -1
  5. package/dist/browser-install.d.ts +16 -0
  6. package/dist/browser-install.d.ts.map +1 -0
  7. package/dist/browser-install.js +237 -0
  8. package/dist/browser-install.js.map +1 -0
  9. package/dist/cdp-relay.d.ts.map +1 -1
  10. package/dist/cdp-relay.js +254 -18
  11. package/dist/cdp-relay.js.map +1 -1
  12. package/dist/chrome-discovery.d.ts.map +1 -1
  13. package/dist/chrome-discovery.js +8 -0
  14. package/dist/chrome-discovery.js.map +1 -1
  15. package/dist/cli.js +568 -6
  16. package/dist/cli.js.map +1 -1
  17. package/dist/cloud-client.d.ts +56 -0
  18. package/dist/cloud-client.d.ts.map +1 -0
  19. package/dist/cloud-client.js +120 -0
  20. package/dist/cloud-client.js.map +1 -0
  21. package/dist/executor.d.ts +46 -2
  22. package/dist/executor.d.ts.map +1 -1
  23. package/dist/executor.js +245 -22
  24. package/dist/executor.js.map +1 -1
  25. package/dist/extension/background.js +106 -23
  26. package/dist/extension/manifest.json +1 -1
  27. package/dist/playwright-import.d.ts +19 -0
  28. package/dist/playwright-import.d.ts.map +1 -0
  29. package/dist/playwright-import.js +39 -0
  30. package/dist/playwright-import.js.map +1 -0
  31. package/dist/prompt.md +32 -0
  32. package/dist/readability.js +1 -1
  33. package/dist/relay-state.d.ts +1 -0
  34. package/dist/relay-state.d.ts.map +1 -1
  35. package/dist/relay-state.js +18 -0
  36. package/dist/relay-state.js.map +1 -1
  37. package/dist/relay-state.test.js +22 -0
  38. package/dist/relay-state.test.js.map +1 -1
  39. package/dist/selector-generator.js +1 -1
  40. package/package.json +3 -1
  41. package/src/browser-config.ts +11 -2
  42. package/src/browser-install.ts +283 -0
  43. package/src/cdp-relay.ts +300 -19
  44. package/src/chrome-discovery.ts +9 -0
  45. package/src/cli.ts +635 -7
  46. package/src/cloud-client.ts +172 -0
  47. package/src/executor.ts +291 -23
  48. package/src/playwright-import.ts +58 -0
  49. package/src/relay-state.test.ts +32 -0
  50. package/src/relay-state.ts +19 -1
  51. package/src/skill.md +154 -14
package/src/cdp-relay.ts CHANGED
@@ -24,6 +24,9 @@ Buffer.prototype[util.inspect.custom] = function () {
24
24
  return `<Buffer ${this.length} bytes>`
25
25
  }
26
26
 
27
+ import fs from 'node:fs'
28
+ import os from 'node:os'
29
+ import path from 'node:path'
27
30
  import { EventEmitter } from 'node:events'
28
31
  import { VERSION, EXTENSION_IDS, shouldAutoEnablePlaywriter } from './utils.js'
29
32
  import { createCdpLogger, type CdpLogEntry, type CdpLogger } from './cdp-log.js'
@@ -156,22 +159,6 @@ export async function startPlayWriterCDPRelayServer({
156
159
  return null
157
160
  }
158
161
 
159
- const buildStableExtensionKey = (info: relayState.ExtensionInfo, connectionId: string): string => {
160
- if (info.id) {
161
- return `profile:${info.id}`
162
- }
163
- if (info.email) {
164
- return `email:${info.email}`
165
- }
166
- if (info.installId) {
167
- return `install:${info.browser || 'unknown'}:${info.installId}`
168
- }
169
- if (info.browser) {
170
- return `browser:${info.browser}`
171
- }
172
- return `connection:${connectionId}`
173
- }
174
-
175
162
  const normalizeSessionId = (value: string | number | null | undefined): string | null => {
176
163
  if (value === undefined || value === null) {
177
164
  return null
@@ -837,6 +824,13 @@ export async function startPlayWriterCDPRelayServer({
837
824
  }
838
825
 
839
826
  const app = new Hono()
827
+
828
+ // Global error handler — ensures server errors are logged, not silently swallowed
829
+ app.onError((err, c) => {
830
+ logger?.error('Unhandled route error:', err)
831
+ return c.json({ error: err.message }, 500)
832
+ })
833
+
840
834
  // CORS middleware for HTTP endpoints - only allows our specific extension IDs.
841
835
  // This prevents other extensions from reading responses via fetch/XHR.
842
836
  // WebSocket connections have their own separate origin validation.
@@ -1398,7 +1392,7 @@ export async function startPlayWriterCDPRelayServer({
1398
1392
  const connectionId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
1399
1393
  return {
1400
1394
  onOpen(_event, ws) {
1401
- const stableKey = buildStableExtensionKey(incomingExtensionInfo, connectionId)
1395
+ const stableKey = relayState.buildStableExtensionKey(incomingExtensionInfo, connectionId)
1402
1396
 
1403
1397
  // Check for existing connection with same stableKey and close it
1404
1398
  const existingExt = relayState.findExtensionByStableKey(store.getState(), stableKey)
@@ -1930,9 +1924,27 @@ export async function startPlayWriterCDPRelayServer({
1930
1924
  404,
1931
1925
  )
1932
1926
  }
1933
- const result = await existingExecutor.execute(code, timeout)
1927
+ // Touch cloud session activity tracking if this session is cloud-backed
1928
+ const cloudTracking = cloudSessionTracking.get(sessionId)
1929
+ if (cloudTracking) {
1930
+ cloudTracking.lastActivityAt = Date.now()
1931
+ cloudTracking.activeExecutions++
1932
+ }
1933
+
1934
+ let result: Awaited<ReturnType<typeof existingExecutor.execute>>
1935
+ try {
1936
+ result = await existingExecutor.execute(code, timeout)
1937
+ } finally {
1938
+ if (cloudTracking) {
1939
+ cloudTracking.activeExecutions--
1940
+ cloudTracking.lastActivityAt = Date.now()
1941
+ }
1942
+ }
1934
1943
 
1935
- return c.json(result)
1944
+ // Use the cloudTracking snapshot captured before execute (not a fresh
1945
+ // map lookup) so long-running executes that outlive idle cleanup still
1946
+ // report isCloud correctly.
1947
+ return c.json({ ...result, isCloud: Boolean(cloudTracking) })
1936
1948
  } catch (error: any) {
1937
1949
  logger?.error('Execute endpoint error:', error)
1938
1950
  return c.json({ text: `Server error: ${error.message}`, images: [], screenshots: [], isError: true }, 500)
@@ -1981,14 +1993,57 @@ export async function startPlayWriterCDPRelayServer({
1981
1993
  cwd?: string
1982
1994
  /** Direct CDP WebSocket URL — bypasses extension, connects straight to Chrome */
1983
1995
  cdpEndpoint?: string
1996
+ /** Launch a headless Chrome via chromium.launch() — no extension or relay CDP routing */
1997
+ headless?: boolean
1984
1998
  /** Browser name from discovery (e.g. "Chrome", "Brave") */
1985
1999
  browser?: string
1986
2000
  /** Profile info from discovery */
1987
2001
  profiles?: Array<{ name: string; email: string }>
2002
+ /** Cloud session tracking metadata (set by CLI when connecting to a cloud browser) */
2003
+ cloud?: {
2004
+ cloudSessionId: string
2005
+ cloudBaseUrl: string
2006
+ cloudToken: string
2007
+ /** BU VM hard timeout (ISO string or epoch ms) */
2008
+ timeoutAt?: string | number
2009
+ /** Block images/video/fonts to save proxy bandwidth */
2010
+ blockProxyResources?: boolean
2011
+ }
1988
2012
  }
1989
2013
  const sessionId = String(nextSessionNumber++)
1990
2014
  const cwd = body.cwd
1991
2015
 
2016
+ // Headless mode: launch Chrome via chromium.launch(), no extension needed.
2017
+ // Force connection immediately so missing Chrome errors surface at creation time,
2018
+ // not on first execute call.
2019
+ if (body.headless) {
2020
+ const manager = await getExecutorManager()
2021
+ const executor = manager.getExecutor({
2022
+ sessionId,
2023
+ cwd,
2024
+ cdpConfig: { headless: true },
2025
+ sessionMetadata: {
2026
+ extensionId: null,
2027
+ browser: 'Chrome (Headless)',
2028
+ profile: null,
2029
+ },
2030
+ })
2031
+ try {
2032
+ await executor.reset()
2033
+ } catch (error) {
2034
+ manager.deleteExecutor(sessionId)
2035
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 500)
2036
+ }
2037
+ const metadata = executor.getSessionMetadata()
2038
+ return c.json({
2039
+ id: sessionId,
2040
+ mode: 'headless' as const,
2041
+ extensionId: metadata.extensionId,
2042
+ browser: metadata.browser,
2043
+ profile: metadata.profile,
2044
+ })
2045
+ }
2046
+
1992
2047
  // Direct CDP mode: skip extension lookup, pass direct WebSocket URL to executor
1993
2048
  if (body.cdpEndpoint) {
1994
2049
  if (!body.cdpEndpoint.startsWith('ws://') && !body.cdpEndpoint.startsWith('wss://')) {
@@ -1996,6 +2051,9 @@ export async function startPlayWriterCDPRelayServer({
1996
2051
  }
1997
2052
  // Use first profile from discovery for session metadata (if available)
1998
2053
  const firstProfile = body.profiles?.[0]
2054
+ const cloudTimeoutAt = body.cloud?.timeoutAt
2055
+ ? (typeof body.cloud.timeoutAt === 'string' ? new Date(body.cloud.timeoutAt).getTime() : body.cloud.timeoutAt)
2056
+ : undefined
1999
2057
  const manager = await getExecutorManager()
2000
2058
  const executor = manager.getExecutor({
2001
2059
  sessionId,
@@ -2006,8 +2064,23 @@ export async function startPlayWriterCDPRelayServer({
2006
2064
  browser: body.browser || null,
2007
2065
  profile: firstProfile ? { email: firstProfile.email, id: firstProfile.name } : null,
2008
2066
  },
2067
+ cloudSession: body.cloud ? { timeoutAt: cloudTimeoutAt, blockProxyResources: body.cloud.blockProxyResources } : undefined,
2009
2068
  })
2010
2069
  const metadata = executor.getSessionMetadata()
2070
+
2071
+ // Register cloud session tracking if cloud metadata was provided
2072
+ if (body.cloud) {
2073
+ cloudSessionTracking.set(sessionId, {
2074
+ cloudSessionId: body.cloud.cloudSessionId,
2075
+ cloudBaseUrl: body.cloud.cloudBaseUrl,
2076
+ cloudToken: body.cloud.cloudToken,
2077
+ lastActivityAt: Date.now(),
2078
+ activeExecutions: 0,
2079
+ timeoutAt: cloudTimeoutAt,
2080
+ })
2081
+ persistCloudSessions()
2082
+ }
2083
+
2011
2084
  return c.json({
2012
2085
  id: sessionId,
2013
2086
  mode: 'direct' as const,
@@ -2067,11 +2140,32 @@ export async function startPlayWriterCDPRelayServer({
2067
2140
  }
2068
2141
 
2069
2142
  const manager = await getExecutorManager()
2143
+ const executor = manager.getSession(sessionId)
2144
+
2145
+ // Close headless context before deleting to prevent context/page leaks
2146
+ // on the shared headless browser. Only affects headless sessions.
2147
+ if (executor) {
2148
+ await executor.closeHeadlessContext()
2149
+ }
2150
+
2070
2151
  const deleted = manager.deleteExecutor(sessionId)
2071
2152
 
2072
2153
  if (!deleted) {
2073
2154
  return c.json({ error: `Session ${sessionId} not found` }, 404)
2074
2155
  }
2156
+
2157
+ // If this was a cloud-backed session, stop the VM only if no other
2158
+ // relay session is still using the same cloud VM (reference counting).
2159
+ const cloudTracking = cloudSessionTracking.get(sessionId)
2160
+ if (cloudTracking) {
2161
+ const shouldStopVm = !hasOtherCloudReferences(sessionId, cloudTracking.cloudSessionId)
2162
+ cloudSessionTracking.delete(sessionId)
2163
+ persistCloudSessions()
2164
+ if (shouldStopVm) {
2165
+ disconnectCloudVm(cloudTracking)
2166
+ }
2167
+ }
2168
+
2075
2169
  return c.json({ success: true })
2076
2170
  } catch (error: any) {
2077
2171
  logger?.error('Delete session endpoint error:', error)
@@ -2146,6 +2240,180 @@ export async function startPlayWriterCDPRelayServer({
2146
2240
  return c.json(result)
2147
2241
  })
2148
2242
 
2243
+ // ============================================================================
2244
+ // Cloud session idle tracking
2245
+ //
2246
+ // Tracks lastActivityAt for cloud-backed sessions (those created via
2247
+ // cdpEndpoint pointing to Browser Use VMs). A background interval checks
2248
+ // every 60s and disconnects sessions idle > 10 minutes by calling the
2249
+ // website's /api/cloud/disconnect endpoint.
2250
+ // ============================================================================
2251
+
2252
+ interface CloudSessionTracking {
2253
+ cloudSessionId: string
2254
+ /** Website base URL for disconnect calls */
2255
+ cloudBaseUrl: string
2256
+ /** Bearer token for website API */
2257
+ cloudToken: string
2258
+ lastActivityAt: number
2259
+ /** Number of currently running execute calls — skip idle timeout while > 0 */
2260
+ activeExecutions: number
2261
+ /** BU VM hard timeout (epoch ms) — used to warn users before expiration */
2262
+ timeoutAt?: number
2263
+ }
2264
+
2265
+ const cloudSessionTracking = new Map<string, CloudSessionTracking>()
2266
+ const CLOUD_IDLE_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes
2267
+
2268
+ /** Check if any OTHER relay session references the same cloud VM.
2269
+ * Used to prevent stopping a VM that's still used by another relay session
2270
+ * (e.g. user attached twice via `session new --browser cloud-1`). */
2271
+ function hasOtherCloudReferences(relaySessionId: string, cloudSessionId: string): boolean {
2272
+ for (const [otherId, tracking] of cloudSessionTracking) {
2273
+ if (otherId !== relaySessionId && tracking.cloudSessionId === cloudSessionId) {
2274
+ return true
2275
+ }
2276
+ }
2277
+ return false
2278
+ }
2279
+
2280
+ /** Disconnect a cloud VM via the website API (best-effort, non-blocking). */
2281
+ function disconnectCloudVm(tracking: CloudSessionTracking): void {
2282
+ fetch(new URL('/api/cloud/disconnect', tracking.cloudBaseUrl).toString(), {
2283
+ method: 'POST',
2284
+ headers: {
2285
+ Authorization: `Bearer ${tracking.cloudToken}`,
2286
+ 'Content-Type': 'application/json',
2287
+ },
2288
+ body: JSON.stringify({ cloudSessionId: tracking.cloudSessionId }),
2289
+ }).catch((err) => {
2290
+ logger?.error('[Cloud] Failed to disconnect cloud session:', err)
2291
+ })
2292
+ }
2293
+
2294
+ // ── Cloud session crash recovery ──────────────────────────────────
2295
+ // Persist cloud session IDs to disk so orphaned VMs can be cleaned up
2296
+ // if the relay process crashes. On startup, read the file and disconnect
2297
+ // any leftover VMs (best-effort).
2298
+
2299
+ const CLOUD_SESSIONS_FILE = path.join(os.homedir(), '.playwriter', 'cloud-sessions.json')
2300
+
2301
+ interface PersistedCloudSession {
2302
+ cloudSessionId: string
2303
+ cloudBaseUrl: string
2304
+ cloudToken: string
2305
+ }
2306
+
2307
+ function persistCloudSessions(): void {
2308
+ // Dedupe by cloudSessionId — multiple relay sessions can reference the same VM
2309
+ const seen = new Set<string>()
2310
+ const entries: PersistedCloudSession[] = []
2311
+ for (const t of cloudSessionTracking.values()) {
2312
+ if (seen.has(t.cloudSessionId)) continue
2313
+ seen.add(t.cloudSessionId)
2314
+ entries.push({
2315
+ cloudSessionId: t.cloudSessionId,
2316
+ cloudBaseUrl: t.cloudBaseUrl,
2317
+ cloudToken: t.cloudToken,
2318
+ })
2319
+ }
2320
+ try {
2321
+ const dir = path.dirname(CLOUD_SESSIONS_FILE)
2322
+ fs.mkdirSync(dir, { recursive: true })
2323
+ if (entries.length > 0) {
2324
+ // Atomic write: write to temp file then rename, so a crash mid-write
2325
+ // doesn't leave corrupt JSON that blocks future cleanup.
2326
+ const tmpFile = CLOUD_SESSIONS_FILE + '.tmp'
2327
+ fs.writeFileSync(tmpFile, JSON.stringify(entries), { encoding: 'utf-8', mode: 0o600 })
2328
+ fs.renameSync(tmpFile, CLOUD_SESSIONS_FILE)
2329
+ } else {
2330
+ // No active sessions — remove file to avoid stale data
2331
+ try { fs.unlinkSync(CLOUD_SESSIONS_FILE) } catch { /* already gone */ }
2332
+ }
2333
+ } catch {
2334
+ // Best-effort: don't crash relay if disk write fails
2335
+ }
2336
+ }
2337
+
2338
+ function cleanupOrphanedCloudSessions(): void {
2339
+ let raw: string
2340
+ try {
2341
+ raw = fs.readFileSync(CLOUD_SESSIONS_FILE, 'utf-8')
2342
+ } catch {
2343
+ return // No file — nothing to clean up
2344
+ }
2345
+
2346
+ let entries: PersistedCloudSession[]
2347
+ try {
2348
+ const parsed = JSON.parse(raw)
2349
+ if (!Array.isArray(parsed)) return
2350
+ // Validate shape: each entry must have cloudSessionId and cloudBaseUrl
2351
+ entries = parsed.filter((e): e is PersistedCloudSession => {
2352
+ return e && typeof e.cloudSessionId === 'string' && typeof e.cloudBaseUrl === 'string' && typeof e.cloudToken === 'string'
2353
+ })
2354
+ } catch {
2355
+ // Corrupt JSON (e.g. crash during non-atomic write) — just remove it
2356
+ try { fs.unlinkSync(CLOUD_SESSIONS_FILE) } catch { /* ignore */ }
2357
+ return
2358
+ }
2359
+ if (!entries.length) {
2360
+ try { fs.unlinkSync(CLOUD_SESSIONS_FILE) } catch { /* ignore */ }
2361
+ return
2362
+ }
2363
+
2364
+ logger?.log(pc.yellow(`[Cloud] Found ${entries.length} orphaned cloud session(s) from previous relay. Cleaning up...`))
2365
+ // Remove file after we've read it — disconnect calls are best-effort async.
2366
+ // If they fail, the BU VM will eventually hit its own timeout anyway.
2367
+ try { fs.unlinkSync(CLOUD_SESSIONS_FILE) } catch { /* ignore */ }
2368
+
2369
+ for (const entry of entries) {
2370
+ disconnectCloudVm({
2371
+ cloudSessionId: entry.cloudSessionId,
2372
+ cloudBaseUrl: entry.cloudBaseUrl,
2373
+ cloudToken: entry.cloudToken,
2374
+ lastActivityAt: 0,
2375
+ activeExecutions: 0,
2376
+ })
2377
+ }
2378
+ }
2379
+
2380
+ const cloudIdleInterval = setInterval(async () => {
2381
+ const now = Date.now()
2382
+ // Collect idle sessions first, then process — avoid mutating map during iteration
2383
+ const idleSessions: Array<[string, CloudSessionTracking]> = []
2384
+ for (const [sessionId, tracking] of cloudSessionTracking) {
2385
+ // VM already past BU hard timeout — schedule for cleanup regardless of activity
2386
+ if (tracking.timeoutAt && tracking.timeoutAt <= now) {
2387
+ idleSessions.push([sessionId, tracking])
2388
+ continue
2389
+ }
2390
+ // Timeout warnings are handled by the executor on each execute() call
2391
+ // (deduped by minute bucket) — no need to enqueue from the relay interval.
2392
+
2393
+ if (tracking.activeExecutions > 0) continue
2394
+ if (now - tracking.lastActivityAt > CLOUD_IDLE_TIMEOUT_MS) {
2395
+ idleSessions.push([sessionId, tracking])
2396
+ }
2397
+ }
2398
+
2399
+ if (idleSessions.length > 0) {
2400
+ for (const [sessionId, tracking] of idleSessions) {
2401
+ logger?.log(
2402
+ pc.yellow(`[Cloud] Stopping idle relay session ${sessionId} (idle > 10 min)`),
2403
+ )
2404
+ // Check if other relay sessions reference the same cloud VM.
2405
+ // Only stop the VM when this is the last relay session for it.
2406
+ const shouldStopVm = !hasOtherCloudReferences(sessionId, tracking.cloudSessionId)
2407
+ cloudSessionTracking.delete(sessionId)
2408
+ executorManager?.deleteExecutor(sessionId)
2409
+ if (shouldStopVm) {
2410
+ disconnectCloudVm(tracking)
2411
+ }
2412
+ }
2413
+ persistCloudSessions()
2414
+ }
2415
+ }, 60_000)
2416
+
2149
2417
  // Use createAdaptorServer instead of serve() so we control the listen()
2150
2418
  // timing. This lets us inject WebSocket upgrade handlers before binding and
2151
2419
  // await the bind to surface EADDRINUSE as a catchable error (issue #75).
@@ -2166,6 +2434,11 @@ export async function startPlayWriterCDPRelayServer({
2166
2434
  server.listen(port, host)
2167
2435
  })
2168
2436
 
2437
+ // Clean up orphaned cloud sessions from a previous relay crash.
2438
+ // Must run AFTER successful listen — if another relay is already running,
2439
+ // we'd fail with EADDRINUSE but only after killing its live VMs.
2440
+ cleanupOrphanedCloudSessions()
2441
+
2169
2442
  const wsHost = `ws://${host}:${port}`
2170
2443
  const cdpEndpoint = `${wsHost}/cdp`
2171
2444
  const extensionEndpoint = `${wsHost}/extension`
@@ -2191,11 +2464,19 @@ export async function startPlayWriterCDPRelayServer({
2191
2464
  ext.ws?.close(1000, 'Server stopped')
2192
2465
  }
2193
2466
 
2467
+ // Close shared headless browser if any headless sessions were created (fire-and-forget)
2468
+ void import('./executor.js').then(({ PlaywrightExecutor }) => {
2469
+ return PlaywrightExecutor.closeSharedHeadlessBrowser()
2470
+ })
2471
+
2194
2472
  // Reset store state
2195
2473
  store.setState({
2196
2474
  extensions: new Map(),
2197
2475
  playwrightClients: new Map(),
2198
2476
  })
2477
+ clearInterval(cloudIdleInterval)
2478
+ cloudSessionTracking.clear()
2479
+ persistCloudSessions() // Remove the file on graceful shutdown
2199
2480
  server.close()
2200
2481
  emitter.removeAllListeners()
2201
2482
  },
@@ -170,6 +170,15 @@ export async function resolveDirectInput(input: string): Promise<string> {
170
170
  return input
171
171
  }
172
172
 
173
+ // Cloud browser providers (Browser Use, Browserbase) return https:// CDP URLs.
174
+ // Convert to wss:// since the WebSocket connection uses the same hostname.
175
+ if (input.startsWith('https://')) {
176
+ return 'wss://' + input.slice('https://'.length)
177
+ }
178
+ if (input.startsWith('http://')) {
179
+ return 'ws://' + input.slice('http://'.length)
180
+ }
181
+
173
182
  const match = input.match(/^([^:]+):(\d+)$/)
174
183
  if (!match) {
175
184
  throw new Error(