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.
- package/dist/bippy.js +5 -5
- package/dist/browser-config.d.ts.map +1 -1
- package/dist/browser-config.js +8 -2
- package/dist/browser-config.js.map +1 -1
- package/dist/browser-install.d.ts +16 -0
- package/dist/browser-install.d.ts.map +1 -0
- package/dist/browser-install.js +237 -0
- package/dist/browser-install.js.map +1 -0
- package/dist/cdp-relay.d.ts.map +1 -1
- package/dist/cdp-relay.js +254 -18
- package/dist/cdp-relay.js.map +1 -1
- package/dist/chrome-discovery.d.ts.map +1 -1
- package/dist/chrome-discovery.js +8 -0
- package/dist/chrome-discovery.js.map +1 -1
- package/dist/cli.js +568 -6
- package/dist/cli.js.map +1 -1
- package/dist/cloud-client.d.ts +56 -0
- package/dist/cloud-client.d.ts.map +1 -0
- package/dist/cloud-client.js +120 -0
- package/dist/cloud-client.js.map +1 -0
- package/dist/executor.d.ts +46 -2
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +245 -22
- package/dist/executor.js.map +1 -1
- package/dist/extension/background.js +106 -23
- package/dist/extension/manifest.json +1 -1
- package/dist/playwright-import.d.ts +19 -0
- package/dist/playwright-import.d.ts.map +1 -0
- package/dist/playwright-import.js +39 -0
- package/dist/playwright-import.js.map +1 -0
- package/dist/prompt.md +32 -0
- package/dist/readability.js +1 -1
- package/dist/relay-state.d.ts +1 -0
- package/dist/relay-state.d.ts.map +1 -1
- package/dist/relay-state.js +18 -0
- package/dist/relay-state.js.map +1 -1
- package/dist/relay-state.test.js +22 -0
- package/dist/relay-state.test.js.map +1 -1
- package/dist/selector-generator.js +1 -1
- package/package.json +3 -1
- package/src/browser-config.ts +11 -2
- package/src/browser-install.ts +283 -0
- package/src/cdp-relay.ts +300 -19
- package/src/chrome-discovery.ts +9 -0
- package/src/cli.ts +635 -7
- package/src/cloud-client.ts +172 -0
- package/src/executor.ts +291 -23
- package/src/playwright-import.ts +58 -0
- package/src/relay-state.test.ts +32 -0
- package/src/relay-state.ts +19 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
},
|
package/src/chrome-discovery.ts
CHANGED
|
@@ -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(
|