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/dist/cdp-relay.js
CHANGED
|
@@ -9,6 +9,9 @@ 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
16
|
import { VERSION, EXTENSION_IDS, shouldAutoEnablePlaywriter } from './utils.js';
|
|
14
17
|
import { createCdpLogger } from './cdp-log.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;
|
|
@@ -613,6 +601,11 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
613
601
|
});
|
|
614
602
|
}
|
|
615
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
|
+
});
|
|
616
609
|
// CORS middleware for HTTP endpoints - only allows our specific extension IDs.
|
|
617
610
|
// This prevents other extensions from reading responses via fetch/XHR.
|
|
618
611
|
// WebSocket connections have their own separate origin validation.
|
|
@@ -1094,7 +1087,7 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
1094
1087
|
const connectionId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1095
1088
|
return {
|
|
1096
1089
|
onOpen(_event, ws) {
|
|
1097
|
-
const stableKey = buildStableExtensionKey(incomingExtensionInfo, connectionId);
|
|
1090
|
+
const stableKey = relayState.buildStableExtensionKey(incomingExtensionInfo, connectionId);
|
|
1098
1091
|
// Check for existing connection with same stableKey and close it
|
|
1099
1092
|
const existingExt = relayState.findExtensionByStableKey(store.getState(), stableKey);
|
|
1100
1093
|
if (existingExt && existingExt.id !== connectionId) {
|
|
@@ -1542,8 +1535,26 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
1542
1535
|
if (!existingExecutor) {
|
|
1543
1536
|
return c.json({ text: `Session ${sessionId} not found. Run 'playwriter session new' first.`, images: [], screenshots: [], isError: true }, 404);
|
|
1544
1537
|
}
|
|
1545
|
-
|
|
1546
|
-
|
|
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) });
|
|
1547
1558
|
}
|
|
1548
1559
|
catch (error) {
|
|
1549
1560
|
logger?.error('Execute endpoint error:', error);
|
|
@@ -1585,6 +1596,37 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
1585
1596
|
const body = (await c.req.json().catch(() => ({})));
|
|
1586
1597
|
const sessionId = String(nextSessionNumber++);
|
|
1587
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
|
+
}
|
|
1588
1630
|
// Direct CDP mode: skip extension lookup, pass direct WebSocket URL to executor
|
|
1589
1631
|
if (body.cdpEndpoint) {
|
|
1590
1632
|
if (!body.cdpEndpoint.startsWith('ws://') && !body.cdpEndpoint.startsWith('wss://')) {
|
|
@@ -1592,6 +1634,9 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
1592
1634
|
}
|
|
1593
1635
|
// Use first profile from discovery for session metadata (if available)
|
|
1594
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;
|
|
1595
1640
|
const manager = await getExecutorManager();
|
|
1596
1641
|
const executor = manager.getExecutor({
|
|
1597
1642
|
sessionId,
|
|
@@ -1602,8 +1647,21 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
1602
1647
|
browser: body.browser || null,
|
|
1603
1648
|
profile: firstProfile ? { email: firstProfile.email, id: firstProfile.name } : null,
|
|
1604
1649
|
},
|
|
1650
|
+
cloudSession: body.cloud ? { timeoutAt: cloudTimeoutAt, blockProxyResources: body.cloud.blockProxyResources } : undefined,
|
|
1605
1651
|
});
|
|
1606
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
|
+
}
|
|
1607
1665
|
return c.json({
|
|
1608
1666
|
id: sessionId,
|
|
1609
1667
|
mode: 'direct',
|
|
@@ -1658,10 +1716,27 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
1658
1716
|
return c.json({ error: 'sessionId is required' }, 400);
|
|
1659
1717
|
}
|
|
1660
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
|
+
}
|
|
1661
1725
|
const deleted = manager.deleteExecutor(sessionId);
|
|
1662
1726
|
if (!deleted) {
|
|
1663
1727
|
return c.json({ error: `Session ${sessionId} not found` }, 404);
|
|
1664
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
|
+
}
|
|
1665
1740
|
return c.json({ success: true });
|
|
1666
1741
|
}
|
|
1667
1742
|
catch (error) {
|
|
@@ -1724,6 +1799,156 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
1724
1799
|
const result = await relay.cancelRecording(cancelParams);
|
|
1725
1800
|
return c.json(result);
|
|
1726
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);
|
|
1727
1952
|
// Use createAdaptorServer instead of serve() so we control the listen()
|
|
1728
1953
|
// timing. This lets us inject WebSocket upgrade handlers before binding and
|
|
1729
1954
|
// await the bind to surface EADDRINUSE as a catchable error (issue #75).
|
|
@@ -1742,6 +1967,10 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
1742
1967
|
server.once('error', onError);
|
|
1743
1968
|
server.listen(port, host);
|
|
1744
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();
|
|
1745
1974
|
const wsHost = `ws://${host}:${port}`;
|
|
1746
1975
|
const cdpEndpoint = `${wsHost}/cdp`;
|
|
1747
1976
|
const extensionEndpoint = `${wsHost}/extension`;
|
|
@@ -1762,11 +1991,18 @@ export async function startPlayWriterCDPRelayServer({ port = 19988, host = '127.
|
|
|
1762
1991
|
}
|
|
1763
1992
|
ext.ws?.close(1000, 'Server stopped');
|
|
1764
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
|
+
});
|
|
1765
1998
|
// Reset store state
|
|
1766
1999
|
store.setState({
|
|
1767
2000
|
extensions: new Map(),
|
|
1768
2001
|
playwrightClients: new Map(),
|
|
1769
2002
|
});
|
|
2003
|
+
clearInterval(cloudIdleInterval);
|
|
2004
|
+
cloudSessionTracking.clear();
|
|
2005
|
+
persistCloudSessions(); // Remove the file on graceful shutdown
|
|
1770
2006
|
server.close();
|
|
1771
2007
|
emitter.removeAllListeners();
|
|
1772
2008
|
},
|