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/dist/cdp-relay.js
CHANGED
|
@@ -9,8 +9,11 @@ import util from 'node:util';
|
|
|
9
9
|
Buffer.prototype[util.inspect.custom] = function () {
|
|
10
10
|
return `<Buffer ${this.length} bytes>`;
|
|
11
11
|
};
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
12
15
|
import { EventEmitter } from 'node:events';
|
|
13
|
-
import { VERSION, EXTENSION_IDS } from './utils.js';
|
|
16
|
+
import { VERSION, EXTENSION_IDS, shouldAutoEnablePlaywriter } from './utils.js';
|
|
14
17
|
import { createCdpLogger } from './cdp-log.js';
|
|
15
18
|
import { RecordingRelay } from './recording-relay.js';
|
|
16
19
|
import { appendSessionToWsUrl } from './chrome-discovery.js';
|
|
@@ -105,21 +108,6 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
105
108
|
}
|
|
106
109
|
return null;
|
|
107
110
|
};
|
|
108
|
-
const buildStableExtensionKey = (info, connectionId) => {
|
|
109
|
-
if (info.id) {
|
|
110
|
-
return `profile:${info.id}`;
|
|
111
|
-
}
|
|
112
|
-
if (info.email) {
|
|
113
|
-
return `email:${info.email}`;
|
|
114
|
-
}
|
|
115
|
-
if (info.installId) {
|
|
116
|
-
return `install:${info.browser || 'unknown'}:${info.installId}`;
|
|
117
|
-
}
|
|
118
|
-
if (info.browser) {
|
|
119
|
-
return `browser:${info.browser}`;
|
|
120
|
-
}
|
|
121
|
-
return `connection:${connectionId}`;
|
|
122
|
-
};
|
|
123
111
|
const normalizeSessionId = (value) => {
|
|
124
112
|
if (value === undefined || value === null) {
|
|
125
113
|
return null;
|
|
@@ -368,11 +356,10 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
368
356
|
}
|
|
369
357
|
return recordingRelays.get(connId) || null;
|
|
370
358
|
};
|
|
371
|
-
// Auto-create initial tab when
|
|
372
|
-
//
|
|
373
|
-
async function maybeAutoCreateInitialTab(
|
|
374
|
-
|
|
375
|
-
if (!autoEnable && !process.env.PLAYWRITER_AUTO_ENABLE) {
|
|
359
|
+
// Auto-create an initial blank tab when no targets exist. Set
|
|
360
|
+
// PLAYWRITER_AUTO_ENABLE=false to require manually enabled tabs instead.
|
|
361
|
+
async function maybeAutoCreateInitialTab(extensionId) {
|
|
362
|
+
if (!shouldAutoEnablePlaywriter()) {
|
|
376
363
|
return;
|
|
377
364
|
}
|
|
378
365
|
const conn = getExtensionConnection(extensionId);
|
|
@@ -462,7 +449,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
462
449
|
}
|
|
463
450
|
}));
|
|
464
451
|
}
|
|
465
|
-
async function routeCdpCommand({ extensionId, method, params, sessionId, source,
|
|
452
|
+
async function routeCdpCommand({ extensionId, method, params, sessionId, source, }) {
|
|
466
453
|
const conn = getExtensionConnection(extensionId);
|
|
467
454
|
const connectedTargets = conn?.connectedTargets || new Map();
|
|
468
455
|
const resolvedExtensionId = conn?.id || extensionId;
|
|
@@ -499,7 +486,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
499
486
|
break;
|
|
500
487
|
}
|
|
501
488
|
if (conn) {
|
|
502
|
-
await maybeAutoCreateInitialTab(
|
|
489
|
+
await maybeAutoCreateInitialTab(conn.id);
|
|
503
490
|
}
|
|
504
491
|
// Forward auto-attach so Chrome emits iframe Target.attachedToTarget events.
|
|
505
492
|
// Playwright relies on these (with parentFrameId) when reconnecting over CDP.
|
|
@@ -614,6 +601,11 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
614
601
|
});
|
|
615
602
|
}
|
|
616
603
|
const app = new Hono();
|
|
604
|
+
// Global error handler — ensures server errors are logged, not silently swallowed
|
|
605
|
+
app.onError((err, c) => {
|
|
606
|
+
logger?.error('Unhandled route error:', err);
|
|
607
|
+
return c.json({ error: err.message }, 500);
|
|
608
|
+
});
|
|
617
609
|
// CORS middleware for HTTP endpoints - only allows our specific extension IDs.
|
|
618
610
|
// This prevents other extensions from reading responses via fetch/XHR.
|
|
619
611
|
// WebSocket connections have their own separate origin validation.
|
|
@@ -858,7 +850,6 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
858
850
|
const clientId = c.req.param('clientId') || 'default';
|
|
859
851
|
const url = new URL(c.req.url, 'http://localhost');
|
|
860
852
|
const requestedExtensionId = url.searchParams.get('extensionId');
|
|
861
|
-
const autoEnable = url.searchParams.get('autoEnable') === '1';
|
|
862
853
|
// When extensionId is explicit, resolve directly. Otherwise use fallback which
|
|
863
854
|
// handles single-extension and uniquely-active-extension cases (#52).
|
|
864
855
|
const resolvedExtension = requestedExtensionId
|
|
@@ -935,7 +926,6 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
935
926
|
params,
|
|
936
927
|
sessionId,
|
|
937
928
|
source,
|
|
938
|
-
autoEnable,
|
|
939
929
|
});
|
|
940
930
|
if (method === 'Target.setAutoAttach' && !sessionId) {
|
|
941
931
|
// Re-read state after async routeCdpCommand — targets may have changed
|
|
@@ -1097,7 +1087,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
1097
1087
|
const connectionId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1098
1088
|
return {
|
|
1099
1089
|
onOpen(_event, ws) {
|
|
1100
|
-
const stableKey = buildStableExtensionKey(incomingExtensionInfo, connectionId);
|
|
1090
|
+
const stableKey = relayState.buildStableExtensionKey(incomingExtensionInfo, connectionId);
|
|
1101
1091
|
// Check for existing connection with same stableKey and close it
|
|
1102
1092
|
const existingExt = relayState.findExtensionByStableKey(store.getState(), stableKey);
|
|
1103
1093
|
if (existingExt && existingExt.id !== connectionId) {
|
|
@@ -1545,8 +1535,26 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
1545
1535
|
if (!existingExecutor) {
|
|
1546
1536
|
return c.json({ text: `Session ${sessionId} not found. Run 'playwriter session new' first.`, images: [], screenshots: [], isError: true }, 404);
|
|
1547
1537
|
}
|
|
1548
|
-
|
|
1549
|
-
|
|
1538
|
+
// Touch cloud session activity tracking if this session is cloud-backed
|
|
1539
|
+
const cloudTracking = cloudSessionTracking.get(sessionId);
|
|
1540
|
+
if (cloudTracking) {
|
|
1541
|
+
cloudTracking.lastActivityAt = Date.now();
|
|
1542
|
+
cloudTracking.activeExecutions++;
|
|
1543
|
+
}
|
|
1544
|
+
let result;
|
|
1545
|
+
try {
|
|
1546
|
+
result = await existingExecutor.execute(code, timeout);
|
|
1547
|
+
}
|
|
1548
|
+
finally {
|
|
1549
|
+
if (cloudTracking) {
|
|
1550
|
+
cloudTracking.activeExecutions--;
|
|
1551
|
+
cloudTracking.lastActivityAt = Date.now();
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
// Use the cloudTracking snapshot captured before execute (not a fresh
|
|
1555
|
+
// map lookup) so long-running executes that outlive idle cleanup still
|
|
1556
|
+
// report isCloud correctly.
|
|
1557
|
+
return c.json({ ...result, isCloud: Boolean(cloudTracking) });
|
|
1550
1558
|
}
|
|
1551
1559
|
catch (error) {
|
|
1552
1560
|
logger?.error('Execute endpoint error:', error);
|
|
@@ -1588,6 +1596,37 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
1588
1596
|
const body = (await c.req.json().catch(() => ({})));
|
|
1589
1597
|
const sessionId = String(nextSessionNumber++);
|
|
1590
1598
|
const cwd = body.cwd;
|
|
1599
|
+
// Headless mode: launch Chrome via chromium.launch(), no extension needed.
|
|
1600
|
+
// Force connection immediately so missing Chrome errors surface at creation time,
|
|
1601
|
+
// not on first execute call.
|
|
1602
|
+
if (body.headless) {
|
|
1603
|
+
const manager = await getExecutorManager();
|
|
1604
|
+
const executor = manager.getExecutor({
|
|
1605
|
+
sessionId,
|
|
1606
|
+
cwd,
|
|
1607
|
+
cdpConfig: { headless: true },
|
|
1608
|
+
sessionMetadata: {
|
|
1609
|
+
extensionId: null,
|
|
1610
|
+
browser: 'Chrome (Headless)',
|
|
1611
|
+
profile: null,
|
|
1612
|
+
},
|
|
1613
|
+
});
|
|
1614
|
+
try {
|
|
1615
|
+
await executor.reset();
|
|
1616
|
+
}
|
|
1617
|
+
catch (error) {
|
|
1618
|
+
manager.deleteExecutor(sessionId);
|
|
1619
|
+
return c.json({ error: error instanceof Error ? error.message : String(error) }, 500);
|
|
1620
|
+
}
|
|
1621
|
+
const metadata = executor.getSessionMetadata();
|
|
1622
|
+
return c.json({
|
|
1623
|
+
id: sessionId,
|
|
1624
|
+
mode: 'headless',
|
|
1625
|
+
extensionId: metadata.extensionId,
|
|
1626
|
+
browser: metadata.browser,
|
|
1627
|
+
profile: metadata.profile,
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1591
1630
|
// Direct CDP mode: skip extension lookup, pass direct WebSocket URL to executor
|
|
1592
1631
|
if (body.cdpEndpoint) {
|
|
1593
1632
|
if (!body.cdpEndpoint.startsWith('ws://') && !body.cdpEndpoint.startsWith('wss://')) {
|
|
@@ -1595,6 +1634,9 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
1595
1634
|
}
|
|
1596
1635
|
// Use first profile from discovery for session metadata (if available)
|
|
1597
1636
|
const firstProfile = body.profiles?.[0];
|
|
1637
|
+
const cloudTimeoutAt = body.cloud?.timeoutAt
|
|
1638
|
+
? (typeof body.cloud.timeoutAt === 'string' ? new Date(body.cloud.timeoutAt).getTime() : body.cloud.timeoutAt)
|
|
1639
|
+
: undefined;
|
|
1598
1640
|
const manager = await getExecutorManager();
|
|
1599
1641
|
const executor = manager.getExecutor({
|
|
1600
1642
|
sessionId,
|
|
@@ -1605,8 +1647,21 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
1605
1647
|
browser: body.browser || null,
|
|
1606
1648
|
profile: firstProfile ? { email: firstProfile.email, id: firstProfile.name } : null,
|
|
1607
1649
|
},
|
|
1650
|
+
cloudSession: body.cloud ? { timeoutAt: cloudTimeoutAt, blockProxyResources: body.cloud.blockProxyResources } : undefined,
|
|
1608
1651
|
});
|
|
1609
1652
|
const metadata = executor.getSessionMetadata();
|
|
1653
|
+
// Register cloud session tracking if cloud metadata was provided
|
|
1654
|
+
if (body.cloud) {
|
|
1655
|
+
cloudSessionTracking.set(sessionId, {
|
|
1656
|
+
cloudSessionId: body.cloud.cloudSessionId,
|
|
1657
|
+
cloudBaseUrl: body.cloud.cloudBaseUrl,
|
|
1658
|
+
cloudToken: body.cloud.cloudToken,
|
|
1659
|
+
lastActivityAt: Date.now(),
|
|
1660
|
+
activeExecutions: 0,
|
|
1661
|
+
timeoutAt: cloudTimeoutAt,
|
|
1662
|
+
});
|
|
1663
|
+
persistCloudSessions();
|
|
1664
|
+
}
|
|
1610
1665
|
return c.json({
|
|
1611
1666
|
id: sessionId,
|
|
1612
1667
|
mode: 'direct',
|
|
@@ -1629,7 +1684,6 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
1629
1684
|
const executor = manager.getExecutor({
|
|
1630
1685
|
sessionId,
|
|
1631
1686
|
cwd,
|
|
1632
|
-
cdpConfig: { host: '127.0.0.1', port, token, extensionId: conn.stableKey, autoEnable: body.autoEnable === true },
|
|
1633
1687
|
sessionMetadata: {
|
|
1634
1688
|
extensionId: conn.stableKey,
|
|
1635
1689
|
browser: conn.info.browser || null,
|
|
@@ -1662,10 +1716,27 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
1662
1716
|
return c.json({ error: 'sessionId is required' }, 400);
|
|
1663
1717
|
}
|
|
1664
1718
|
const manager = await getExecutorManager();
|
|
1719
|
+
const executor = manager.getSession(sessionId);
|
|
1720
|
+
// Close headless context before deleting to prevent context/page leaks
|
|
1721
|
+
// on the shared headless browser. Only affects headless sessions.
|
|
1722
|
+
if (executor) {
|
|
1723
|
+
await executor.closeHeadlessContext();
|
|
1724
|
+
}
|
|
1665
1725
|
const deleted = manager.deleteExecutor(sessionId);
|
|
1666
1726
|
if (!deleted) {
|
|
1667
1727
|
return c.json({ error: `Session ${sessionId} not found` }, 404);
|
|
1668
1728
|
}
|
|
1729
|
+
// If this was a cloud-backed session, stop the VM only if no other
|
|
1730
|
+
// relay session is still using the same cloud VM (reference counting).
|
|
1731
|
+
const cloudTracking = cloudSessionTracking.get(sessionId);
|
|
1732
|
+
if (cloudTracking) {
|
|
1733
|
+
const shouldStopVm = !hasOtherCloudReferences(sessionId, cloudTracking.cloudSessionId);
|
|
1734
|
+
cloudSessionTracking.delete(sessionId);
|
|
1735
|
+
persistCloudSessions();
|
|
1736
|
+
if (shouldStopVm) {
|
|
1737
|
+
disconnectCloudVm(cloudTracking);
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1669
1740
|
return c.json({ success: true });
|
|
1670
1741
|
}
|
|
1671
1742
|
catch (error) {
|
|
@@ -1728,6 +1799,156 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
1728
1799
|
const result = await relay.cancelRecording(cancelParams);
|
|
1729
1800
|
return c.json(result);
|
|
1730
1801
|
});
|
|
1802
|
+
const cloudSessionTracking = new Map();
|
|
1803
|
+
const CLOUD_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
1804
|
+
/** Check if any OTHER relay session references the same cloud VM.
|
|
1805
|
+
* Used to prevent stopping a VM that's still used by another relay session
|
|
1806
|
+
* (e.g. user attached twice via `session new --browser cloud-1`). */
|
|
1807
|
+
function hasOtherCloudReferences(relaySessionId, cloudSessionId) {
|
|
1808
|
+
for (const [otherId, tracking] of cloudSessionTracking) {
|
|
1809
|
+
if (otherId !== relaySessionId && tracking.cloudSessionId === cloudSessionId) {
|
|
1810
|
+
return true;
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
return false;
|
|
1814
|
+
}
|
|
1815
|
+
/** Disconnect a cloud VM via the website API (best-effort, non-blocking). */
|
|
1816
|
+
function disconnectCloudVm(tracking) {
|
|
1817
|
+
fetch(new URL('/api/cloud/disconnect', tracking.cloudBaseUrl).toString(), {
|
|
1818
|
+
method: 'POST',
|
|
1819
|
+
headers: {
|
|
1820
|
+
Authorization: `Bearer ${tracking.cloudToken}`,
|
|
1821
|
+
'Content-Type': 'application/json',
|
|
1822
|
+
},
|
|
1823
|
+
body: JSON.stringify({ cloudSessionId: tracking.cloudSessionId }),
|
|
1824
|
+
}).catch((err) => {
|
|
1825
|
+
logger?.error('[Cloud] Failed to disconnect cloud session:', err);
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
// ── Cloud session crash recovery ──────────────────────────────────
|
|
1829
|
+
// Persist cloud session IDs to disk so orphaned VMs can be cleaned up
|
|
1830
|
+
// if the relay process crashes. On startup, read the file and disconnect
|
|
1831
|
+
// any leftover VMs (best-effort).
|
|
1832
|
+
const CLOUD_SESSIONS_FILE = path.join(os.homedir(), '.playwriter', 'cloud-sessions.json');
|
|
1833
|
+
function persistCloudSessions() {
|
|
1834
|
+
// Dedupe by cloudSessionId — multiple relay sessions can reference the same VM
|
|
1835
|
+
const seen = new Set();
|
|
1836
|
+
const entries = [];
|
|
1837
|
+
for (const t of cloudSessionTracking.values()) {
|
|
1838
|
+
if (seen.has(t.cloudSessionId))
|
|
1839
|
+
continue;
|
|
1840
|
+
seen.add(t.cloudSessionId);
|
|
1841
|
+
entries.push({
|
|
1842
|
+
cloudSessionId: t.cloudSessionId,
|
|
1843
|
+
cloudBaseUrl: t.cloudBaseUrl,
|
|
1844
|
+
cloudToken: t.cloudToken,
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
try {
|
|
1848
|
+
const dir = path.dirname(CLOUD_SESSIONS_FILE);
|
|
1849
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1850
|
+
if (entries.length > 0) {
|
|
1851
|
+
// Atomic write: write to temp file then rename, so a crash mid-write
|
|
1852
|
+
// doesn't leave corrupt JSON that blocks future cleanup.
|
|
1853
|
+
const tmpFile = CLOUD_SESSIONS_FILE + '.tmp';
|
|
1854
|
+
fs.writeFileSync(tmpFile, JSON.stringify(entries), { encoding: 'utf-8', mode: 0o600 });
|
|
1855
|
+
fs.renameSync(tmpFile, CLOUD_SESSIONS_FILE);
|
|
1856
|
+
}
|
|
1857
|
+
else {
|
|
1858
|
+
// No active sessions — remove file to avoid stale data
|
|
1859
|
+
try {
|
|
1860
|
+
fs.unlinkSync(CLOUD_SESSIONS_FILE);
|
|
1861
|
+
}
|
|
1862
|
+
catch { /* already gone */ }
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
catch {
|
|
1866
|
+
// Best-effort: don't crash relay if disk write fails
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
function cleanupOrphanedCloudSessions() {
|
|
1870
|
+
let raw;
|
|
1871
|
+
try {
|
|
1872
|
+
raw = fs.readFileSync(CLOUD_SESSIONS_FILE, 'utf-8');
|
|
1873
|
+
}
|
|
1874
|
+
catch {
|
|
1875
|
+
return; // No file — nothing to clean up
|
|
1876
|
+
}
|
|
1877
|
+
let entries;
|
|
1878
|
+
try {
|
|
1879
|
+
const parsed = JSON.parse(raw);
|
|
1880
|
+
if (!Array.isArray(parsed))
|
|
1881
|
+
return;
|
|
1882
|
+
// Validate shape: each entry must have cloudSessionId and cloudBaseUrl
|
|
1883
|
+
entries = parsed.filter((e) => {
|
|
1884
|
+
return e && typeof e.cloudSessionId === 'string' && typeof e.cloudBaseUrl === 'string' && typeof e.cloudToken === 'string';
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
catch {
|
|
1888
|
+
// Corrupt JSON (e.g. crash during non-atomic write) — just remove it
|
|
1889
|
+
try {
|
|
1890
|
+
fs.unlinkSync(CLOUD_SESSIONS_FILE);
|
|
1891
|
+
}
|
|
1892
|
+
catch { /* ignore */ }
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
if (!entries.length) {
|
|
1896
|
+
try {
|
|
1897
|
+
fs.unlinkSync(CLOUD_SESSIONS_FILE);
|
|
1898
|
+
}
|
|
1899
|
+
catch { /* ignore */ }
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
logger?.log(pc.yellow(`[Cloud] Found ${entries.length} orphaned cloud session(s) from previous relay. Cleaning up...`));
|
|
1903
|
+
// Remove file after we've read it — disconnect calls are best-effort async.
|
|
1904
|
+
// If they fail, the BU VM will eventually hit its own timeout anyway.
|
|
1905
|
+
try {
|
|
1906
|
+
fs.unlinkSync(CLOUD_SESSIONS_FILE);
|
|
1907
|
+
}
|
|
1908
|
+
catch { /* ignore */ }
|
|
1909
|
+
for (const entry of entries) {
|
|
1910
|
+
disconnectCloudVm({
|
|
1911
|
+
cloudSessionId: entry.cloudSessionId,
|
|
1912
|
+
cloudBaseUrl: entry.cloudBaseUrl,
|
|
1913
|
+
cloudToken: entry.cloudToken,
|
|
1914
|
+
lastActivityAt: 0,
|
|
1915
|
+
activeExecutions: 0,
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
const cloudIdleInterval = setInterval(async () => {
|
|
1920
|
+
const now = Date.now();
|
|
1921
|
+
// Collect idle sessions first, then process — avoid mutating map during iteration
|
|
1922
|
+
const idleSessions = [];
|
|
1923
|
+
for (const [sessionId, tracking] of cloudSessionTracking) {
|
|
1924
|
+
// VM already past BU hard timeout — schedule for cleanup regardless of activity
|
|
1925
|
+
if (tracking.timeoutAt && tracking.timeoutAt <= now) {
|
|
1926
|
+
idleSessions.push([sessionId, tracking]);
|
|
1927
|
+
continue;
|
|
1928
|
+
}
|
|
1929
|
+
// Timeout warnings are handled by the executor on each execute() call
|
|
1930
|
+
// (deduped by minute bucket) — no need to enqueue from the relay interval.
|
|
1931
|
+
if (tracking.activeExecutions > 0)
|
|
1932
|
+
continue;
|
|
1933
|
+
if (now - tracking.lastActivityAt > CLOUD_IDLE_TIMEOUT_MS) {
|
|
1934
|
+
idleSessions.push([sessionId, tracking]);
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
if (idleSessions.length > 0) {
|
|
1938
|
+
for (const [sessionId, tracking] of idleSessions) {
|
|
1939
|
+
logger?.log(pc.yellow(`[Cloud] Stopping idle relay session ${sessionId} (idle > 10 min)`));
|
|
1940
|
+
// Check if other relay sessions reference the same cloud VM.
|
|
1941
|
+
// Only stop the VM when this is the last relay session for it.
|
|
1942
|
+
const shouldStopVm = !hasOtherCloudReferences(sessionId, tracking.cloudSessionId);
|
|
1943
|
+
cloudSessionTracking.delete(sessionId);
|
|
1944
|
+
executorManager?.deleteExecutor(sessionId);
|
|
1945
|
+
if (shouldStopVm) {
|
|
1946
|
+
disconnectCloudVm(tracking);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
persistCloudSessions();
|
|
1950
|
+
}
|
|
1951
|
+
}, 60_000);
|
|
1731
1952
|
// Use createAdaptorServer instead of serve() so we control the listen()
|
|
1732
1953
|
// timing. This lets us inject WebSocket upgrade handlers before binding and
|
|
1733
1954
|
// await the bind to surface EADDRINUSE as a catchable error (issue #75).
|
|
@@ -1746,6 +1967,10 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
1746
1967
|
server.once('error', onError);
|
|
1747
1968
|
server.listen(port, host);
|
|
1748
1969
|
});
|
|
1970
|
+
// Clean up orphaned cloud sessions from a previous relay crash.
|
|
1971
|
+
// Must run AFTER successful listen — if another relay is already running,
|
|
1972
|
+
// we'd fail with EADDRINUSE but only after killing its live VMs.
|
|
1973
|
+
cleanupOrphanedCloudSessions();
|
|
1749
1974
|
const wsHost = `ws://${host}:${port}`;
|
|
1750
1975
|
const cdpEndpoint = `${wsHost}/cdp`;
|
|
1751
1976
|
const extensionEndpoint = `${wsHost}/extension`;
|
|
@@ -1766,11 +1991,18 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
1766
1991
|
}
|
|
1767
1992
|
ext.ws?.close(1000, 'Server stopped');
|
|
1768
1993
|
}
|
|
1994
|
+
// Close shared headless browser if any headless sessions were created (fire-and-forget)
|
|
1995
|
+
void import('./executor.js').then(({ PlaywrightExecutor }) => {
|
|
1996
|
+
return PlaywrightExecutor.closeSharedHeadlessBrowser();
|
|
1997
|
+
});
|
|
1769
1998
|
// Reset store state
|
|
1770
1999
|
store.setState({
|
|
1771
2000
|
extensions: new Map(),
|
|
1772
2001
|
playwrightClients: new Map(),
|
|
1773
2002
|
});
|
|
2003
|
+
clearInterval(cloudIdleInterval);
|
|
2004
|
+
cloudSessionTracking.clear();
|
|
2005
|
+
persistCloudSessions(); // Remove the file on graceful shutdown
|
|
1774
2006
|
server.close();
|
|
1775
2007
|
emitter.removeAllListeners();
|
|
1776
2008
|
},
|