playwriter 0.3.0 → 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 +261 -29
- 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 +578 -17
- 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 -3
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +249 -26
- 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-session.test.js +1 -1
- package/dist/relay-session.test.js.map +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/dist/utils.d.ts +2 -2
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +4 -4
- package/dist/utils.js.map +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 +306 -32
- package/src/chrome-discovery.ts +9 -0
- package/src/cli.ts +645 -19
- package/src/cloud-client.ts +172 -0
- package/src/executor.ts +295 -28
- package/src/playwright-import.ts +58 -0
- package/src/relay-session.test.ts +1 -1
- package/src/relay-state.test.ts +32 -0
- package/src/relay-state.ts +19 -1
- package/src/skill.md +154 -14
- package/src/utils.ts +4 -5
package/src/cdp-relay.ts
CHANGED
|
@@ -24,8 +24,11 @@ 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
|
-
import { VERSION, EXTENSION_IDS } from './utils.js'
|
|
31
|
+
import { VERSION, EXTENSION_IDS, shouldAutoEnablePlaywriter } from './utils.js'
|
|
29
32
|
import { createCdpLogger, type CdpLogEntry, type CdpLogger } from './cdp-log.js'
|
|
30
33
|
import { RecordingRelay } from './recording-relay.js'
|
|
31
34
|
import { appendSessionToWsUrl } from './chrome-discovery.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
|
|
@@ -521,11 +508,10 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
521
508
|
return recordingRelays.get(connId) || null
|
|
522
509
|
}
|
|
523
510
|
|
|
524
|
-
// Auto-create initial tab when
|
|
525
|
-
//
|
|
526
|
-
async function maybeAutoCreateInitialTab(
|
|
527
|
-
|
|
528
|
-
if (!autoEnable && !process.env.PLAYWRITER_AUTO_ENABLE) {
|
|
511
|
+
// Auto-create an initial blank tab when no targets exist. Set
|
|
512
|
+
// PLAYWRITER_AUTO_ENABLE=false to require manually enabled tabs instead.
|
|
513
|
+
async function maybeAutoCreateInitialTab(extensionId: string): Promise<void> {
|
|
514
|
+
if (!shouldAutoEnablePlaywriter()) {
|
|
529
515
|
return
|
|
530
516
|
}
|
|
531
517
|
const conn = getExtensionConnection(extensionId)
|
|
@@ -655,14 +641,12 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
655
641
|
params,
|
|
656
642
|
sessionId,
|
|
657
643
|
source,
|
|
658
|
-
autoEnable,
|
|
659
644
|
}: {
|
|
660
645
|
extensionId: string | null
|
|
661
646
|
method: CDPCommand['method'] | (string & {})
|
|
662
647
|
params: CDPCommand['params']
|
|
663
648
|
sessionId?: CDPCommand['sessionId']
|
|
664
649
|
source?: CDPCommand['source']
|
|
665
|
-
autoEnable: boolean
|
|
666
650
|
}) {
|
|
667
651
|
const conn = getExtensionConnection(extensionId)
|
|
668
652
|
const connectedTargets = conn?.connectedTargets || new Map<string, relayState.ConnectedTarget>()
|
|
@@ -702,7 +686,7 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
702
686
|
break
|
|
703
687
|
}
|
|
704
688
|
if (conn) {
|
|
705
|
-
await maybeAutoCreateInitialTab(
|
|
689
|
+
await maybeAutoCreateInitialTab(conn.id)
|
|
706
690
|
}
|
|
707
691
|
// Forward auto-attach so Chrome emits iframe Target.attachedToTarget events.
|
|
708
692
|
// Playwright relies on these (with parentFrameId) when reconnecting over CDP.
|
|
@@ -840,6 +824,13 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
840
824
|
}
|
|
841
825
|
|
|
842
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
|
+
|
|
843
834
|
// CORS middleware for HTTP endpoints - only allows our specific extension IDs.
|
|
844
835
|
// This prevents other extensions from reading responses via fetch/XHR.
|
|
845
836
|
// WebSocket connections have their own separate origin validation.
|
|
@@ -1112,7 +1103,6 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
1112
1103
|
const clientId = c.req.param('clientId') || 'default'
|
|
1113
1104
|
const url = new URL(c.req.url, 'http://localhost')
|
|
1114
1105
|
const requestedExtensionId = url.searchParams.get('extensionId')
|
|
1115
|
-
const autoEnable = url.searchParams.get('autoEnable') === '1'
|
|
1116
1106
|
// When extensionId is explicit, resolve directly. Otherwise use fallback which
|
|
1117
1107
|
// handles single-extension and uniquely-active-extension cases (#52).
|
|
1118
1108
|
const resolvedExtension = requestedExtensionId
|
|
@@ -1204,7 +1194,6 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
1204
1194
|
params,
|
|
1205
1195
|
sessionId,
|
|
1206
1196
|
source,
|
|
1207
|
-
autoEnable,
|
|
1208
1197
|
})
|
|
1209
1198
|
|
|
1210
1199
|
if (method === 'Target.setAutoAttach' && !sessionId) {
|
|
@@ -1403,7 +1392,7 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
1403
1392
|
const connectionId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
|
1404
1393
|
return {
|
|
1405
1394
|
onOpen(_event, ws) {
|
|
1406
|
-
const stableKey = buildStableExtensionKey(incomingExtensionInfo, connectionId)
|
|
1395
|
+
const stableKey = relayState.buildStableExtensionKey(incomingExtensionInfo, connectionId)
|
|
1407
1396
|
|
|
1408
1397
|
// Check for existing connection with same stableKey and close it
|
|
1409
1398
|
const existingExt = relayState.findExtensionByStableKey(store.getState(), stableKey)
|
|
@@ -1935,9 +1924,27 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
1935
1924
|
404,
|
|
1936
1925
|
)
|
|
1937
1926
|
}
|
|
1938
|
-
|
|
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
|
+
}
|
|
1939
1943
|
|
|
1940
|
-
|
|
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) })
|
|
1941
1948
|
} catch (error: any) {
|
|
1942
1949
|
logger?.error('Execute endpoint error:', error)
|
|
1943
1950
|
return c.json({ text: `Server error: ${error.message}`, images: [], screenshots: [], isError: true }, 500)
|
|
@@ -1983,18 +1990,60 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
1983
1990
|
app.post('/cli/session/new', async (c) => {
|
|
1984
1991
|
const body = (await c.req.json().catch(() => ({}))) as {
|
|
1985
1992
|
extensionId?: string | null
|
|
1986
|
-
autoEnable?: boolean
|
|
1987
1993
|
cwd?: string
|
|
1988
1994
|
/** Direct CDP WebSocket URL — bypasses extension, connects straight to Chrome */
|
|
1989
1995
|
cdpEndpoint?: string
|
|
1996
|
+
/** Launch a headless Chrome via chromium.launch() — no extension or relay CDP routing */
|
|
1997
|
+
headless?: boolean
|
|
1990
1998
|
/** Browser name from discovery (e.g. "Chrome", "Brave") */
|
|
1991
1999
|
browser?: string
|
|
1992
2000
|
/** Profile info from discovery */
|
|
1993
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
|
+
}
|
|
1994
2012
|
}
|
|
1995
2013
|
const sessionId = String(nextSessionNumber++)
|
|
1996
2014
|
const cwd = body.cwd
|
|
1997
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
|
+
|
|
1998
2047
|
// Direct CDP mode: skip extension lookup, pass direct WebSocket URL to executor
|
|
1999
2048
|
if (body.cdpEndpoint) {
|
|
2000
2049
|
if (!body.cdpEndpoint.startsWith('ws://') && !body.cdpEndpoint.startsWith('wss://')) {
|
|
@@ -2002,6 +2051,9 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
2002
2051
|
}
|
|
2003
2052
|
// Use first profile from discovery for session metadata (if available)
|
|
2004
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
|
|
2005
2057
|
const manager = await getExecutorManager()
|
|
2006
2058
|
const executor = manager.getExecutor({
|
|
2007
2059
|
sessionId,
|
|
@@ -2012,8 +2064,23 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
2012
2064
|
browser: body.browser || null,
|
|
2013
2065
|
profile: firstProfile ? { email: firstProfile.email, id: firstProfile.name } : null,
|
|
2014
2066
|
},
|
|
2067
|
+
cloudSession: body.cloud ? { timeoutAt: cloudTimeoutAt, blockProxyResources: body.cloud.blockProxyResources } : undefined,
|
|
2015
2068
|
})
|
|
2016
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
|
+
|
|
2017
2084
|
return c.json({
|
|
2018
2085
|
id: sessionId,
|
|
2019
2086
|
mode: 'direct' as const,
|
|
@@ -2037,7 +2104,6 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
2037
2104
|
const executor = manager.getExecutor({
|
|
2038
2105
|
sessionId,
|
|
2039
2106
|
cwd,
|
|
2040
|
-
cdpConfig: { host: '127.0.0.1', port, token, extensionId: conn.stableKey, autoEnable: body.autoEnable === true },
|
|
2041
2107
|
sessionMetadata: {
|
|
2042
2108
|
extensionId: conn.stableKey,
|
|
2043
2109
|
browser: conn.info.browser || null,
|
|
@@ -2074,11 +2140,32 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
2074
2140
|
}
|
|
2075
2141
|
|
|
2076
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
|
+
|
|
2077
2151
|
const deleted = manager.deleteExecutor(sessionId)
|
|
2078
2152
|
|
|
2079
2153
|
if (!deleted) {
|
|
2080
2154
|
return c.json({ error: `Session ${sessionId} not found` }, 404)
|
|
2081
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
|
+
|
|
2082
2169
|
return c.json({ success: true })
|
|
2083
2170
|
} catch (error: any) {
|
|
2084
2171
|
logger?.error('Delete session endpoint error:', error)
|
|
@@ -2153,6 +2240,180 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
2153
2240
|
return c.json(result)
|
|
2154
2241
|
})
|
|
2155
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
|
+
|
|
2156
2417
|
// Use createAdaptorServer instead of serve() so we control the listen()
|
|
2157
2418
|
// timing. This lets us inject WebSocket upgrade handlers before binding and
|
|
2158
2419
|
// await the bind to surface EADDRINUSE as a catchable error (issue #75).
|
|
@@ -2173,6 +2434,11 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
2173
2434
|
server.listen(port, host)
|
|
2174
2435
|
})
|
|
2175
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
|
+
|
|
2176
2442
|
const wsHost = `ws://${host}:${port}`
|
|
2177
2443
|
const cdpEndpoint = `${wsHost}/cdp`
|
|
2178
2444
|
const extensionEndpoint = `${wsHost}/extension`
|
|
@@ -2198,11 +2464,19 @@ export async function startPlayWriterCDPRelayServer({
|
|
|
2198
2464
|
ext.ws?.close(1000, 'Server stopped')
|
|
2199
2465
|
}
|
|
2200
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
|
+
|
|
2201
2472
|
// Reset store state
|
|
2202
2473
|
store.setState({
|
|
2203
2474
|
extensions: new Map(),
|
|
2204
2475
|
playwrightClients: new Map(),
|
|
2205
2476
|
})
|
|
2477
|
+
clearInterval(cloudIdleInterval)
|
|
2478
|
+
cloudSessionTracking.clear()
|
|
2479
|
+
persistCloudSessions() // Remove the file on graceful shutdown
|
|
2206
2480
|
server.close()
|
|
2207
2481
|
emitter.removeAllListeners()
|
|
2208
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(
|