playwriter 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/bippy.js +5 -5
  2. package/dist/browser-config.d.ts.map +1 -1
  3. package/dist/browser-config.js +8 -2
  4. package/dist/browser-config.js.map +1 -1
  5. package/dist/browser-install.d.ts +16 -0
  6. package/dist/browser-install.d.ts.map +1 -0
  7. package/dist/browser-install.js +237 -0
  8. package/dist/browser-install.js.map +1 -0
  9. package/dist/cdp-relay.d.ts.map +1 -1
  10. package/dist/cdp-relay.js +254 -18
  11. package/dist/cdp-relay.js.map +1 -1
  12. package/dist/chrome-discovery.d.ts.map +1 -1
  13. package/dist/chrome-discovery.js +8 -0
  14. package/dist/chrome-discovery.js.map +1 -1
  15. package/dist/cli.js +568 -6
  16. package/dist/cli.js.map +1 -1
  17. package/dist/cloud-client.d.ts +56 -0
  18. package/dist/cloud-client.d.ts.map +1 -0
  19. package/dist/cloud-client.js +120 -0
  20. package/dist/cloud-client.js.map +1 -0
  21. package/dist/executor.d.ts +46 -2
  22. package/dist/executor.d.ts.map +1 -1
  23. package/dist/executor.js +245 -22
  24. package/dist/executor.js.map +1 -1
  25. package/dist/extension/background.js +106 -23
  26. package/dist/extension/manifest.json +1 -1
  27. package/dist/playwright-import.d.ts +19 -0
  28. package/dist/playwright-import.d.ts.map +1 -0
  29. package/dist/playwright-import.js +39 -0
  30. package/dist/playwright-import.js.map +1 -0
  31. package/dist/prompt.md +32 -0
  32. package/dist/readability.js +1 -1
  33. package/dist/relay-state.d.ts +1 -0
  34. package/dist/relay-state.d.ts.map +1 -1
  35. package/dist/relay-state.js +18 -0
  36. package/dist/relay-state.js.map +1 -1
  37. package/dist/relay-state.test.js +22 -0
  38. package/dist/relay-state.test.js.map +1 -1
  39. package/dist/selector-generator.js +1 -1
  40. package/package.json +3 -1
  41. package/src/browser-config.ts +11 -2
  42. package/src/browser-install.ts +283 -0
  43. package/src/cdp-relay.ts +300 -19
  44. package/src/chrome-discovery.ts +9 -0
  45. package/src/cli.ts +635 -7
  46. package/src/cloud-client.ts +172 -0
  47. package/src/executor.ts +291 -23
  48. package/src/playwright-import.ts +58 -0
  49. package/src/relay-state.test.ts +32 -0
  50. package/src/relay-state.ts +19 -1
  51. package/src/skill.md +154 -14
package/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
- const result = await existingExecutor.execute(code, timeout);
1546
- return c.json(result);
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
  },