@sanohiro/casty 0.5.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/bin/casty.js ADDED
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env node
2
+ // casty - TTY web browser using raw CDP and Kitty graphics protocol
3
+
4
+ import { startBrowser, setupPage, startScreencast, stopScreencast } from '../lib/browser.js';
5
+ import { sendFrame, resetFrameCache, clearScreen, hideCursor, showCursor, cleanup as cleanupTmp, transport } from '../lib/kitty.js';
6
+ import { enableMouse, disableMouse, startInputHandling } from '../lib/input.js';
7
+ import { loadKeyBindings } from '../lib/keys.js';
8
+ import { loadConfig } from '../lib/config.js';
9
+
10
+ const config = loadConfig();
11
+ const bindings = loadKeyBindings();
12
+ const url = process.argv[2] || config.homeUrl;
13
+
14
+ const TERM_QUERY_TIMEOUT = 1000; // CSI 14t response timeout (ms)
15
+
16
+ // Delayed capture timings after page navigation (ms)
17
+ const DELAYED_CAPTURE_MS = [0, 300, 1000];
18
+
19
+ // Reference cell size (96 DPI, standard terminal font)
20
+ // Larger cells → zoom in, smaller cells → zoom out
21
+ const REF_CELL_WIDTH = 8;
22
+
23
+ // Auto-calculate zoom from cell size
24
+ function calcZoom(cellWidth) {
25
+ return cellWidth / REF_CELL_WIDTH;
26
+ }
27
+
28
+ // Query terminal pixel size via CSI 14t
29
+ // keepAlive: true when called during operation (SIGWINCH) — don't touch stdin state
30
+ function queryTermPixelSize({ keepAlive = false } = {}) {
31
+ if (!process.stdin.isTTY) return Promise.resolve(null);
32
+
33
+ const { promise, resolve } = Promise.withResolvers();
34
+ const wasRaw = process.stdin.isRaw;
35
+ const timeout = setTimeout(() => {
36
+ process.stdin.removeListener('data', onData);
37
+ if (!keepAlive) {
38
+ process.stdin.setRawMode(wasRaw);
39
+ process.stdin.pause();
40
+ }
41
+ resolve(null);
42
+ }, TERM_QUERY_TIMEOUT);
43
+
44
+ if (!keepAlive) {
45
+ process.stdin.setRawMode(true);
46
+ process.stdin.resume();
47
+ }
48
+
49
+ let buf = '';
50
+ const onData = (data) => {
51
+ buf += data.toString();
52
+ const match = buf.match(/\x1b\[4;(\d+);(\d+)t/);
53
+ if (match) {
54
+ clearTimeout(timeout);
55
+ process.stdin.removeListener('data', onData);
56
+ if (!keepAlive) process.stdin.setRawMode(wasRaw);
57
+ resolve({ height: parseInt(match[1]), width: parseInt(match[2]) });
58
+ }
59
+ };
60
+ process.stdin.on('data', onData);
61
+
62
+ process.stdout.write('\x1b[14t');
63
+ return promise;
64
+ }
65
+
66
+ // Get terminal info
67
+ // keepAlive: true during operation (SIGWINCH) to avoid killing stdin
68
+ async function getTermInfo({ keepAlive = false } = {}) {
69
+ const cols = process.stdout.columns || 80;
70
+ const rows = process.stdout.rows || 24;
71
+
72
+ const pixelSize = await queryTermPixelSize({ keepAlive });
73
+ if (pixelSize) {
74
+ const cellWidth = pixelSize.width / cols;
75
+ const cellHeight = pixelSize.height / rows;
76
+ const zoom = calcZoom(cellWidth);
77
+ return { cols, rows, width: pixelSize.width, height: pixelSize.height, cellWidth, cellHeight, zoom };
78
+ }
79
+
80
+ const cellWidth = parseInt(process.env.CASTY_CELL_WIDTH) || 10;
81
+ const cellHeight = parseInt(process.env.CASTY_CELL_HEIGHT) || 20;
82
+ const zoom = calcZoom(cellWidth);
83
+ return {
84
+ cols, rows,
85
+ width: cols * cellWidth,
86
+ height: rows * cellHeight,
87
+ cellWidth, cellHeight, zoom,
88
+ };
89
+ }
90
+
91
+ async function main() {
92
+ // Phase 1: Launch Chrome and get terminal info in parallel
93
+ // getTermInfo() must complete fully (prevent CSI 14t response leak)
94
+ const browserP = startBrowser();
95
+ const term = await getTermInfo();
96
+ const browser = await browserP;
97
+
98
+ // Reserve line 1 for URL bar, use the rest for browser display
99
+ const barHeight = Math.round(term.cellHeight);
100
+ const viewHeight = term.height - barHeight;
101
+
102
+ // Phase 2: CDP connection + page setup
103
+ const { client, cssWidth, cssHeight } = await setupPage(browser, { ...term, height: viewHeight });
104
+ const chromeProcess = browser.proc;
105
+
106
+ // Log WebSocket errors to stderr (prevent unhandled crash)
107
+ client.on('error', (err) => { console.error('casty: CDP error:', err.message); });
108
+
109
+ // Navigate immediately (before screencast) to avoid showing previous session's page
110
+ client.send('Page.navigate', { url }).catch(e => console.error('casty: navigate error:', e.message));
111
+
112
+ let renderPaused = false;
113
+ const pauseRender = (p = true) => { renderPaused = p; };
114
+
115
+ hideCursor();
116
+ clearScreen();
117
+ enableMouse();
118
+
119
+ // Mouse coordinates in device pixels
120
+ // Chrome headless-shell ignores deviceScaleFactor for Input.dispatchMouseEvent
121
+ const cssCellW = term.cellWidth;
122
+ const cssCellH = term.cellHeight;
123
+ // format: auto → PNG for inline, JPEG (adaptive) for file transfer
124
+ // jpeg mode: fast JPEG during activity, PNG refinement when static
125
+ const fmt = config.format || 'auto';
126
+ const screenshotFormat = fmt === 'auto'
127
+ ? (transport === 'file' ? 'jpeg' : 'png')
128
+ : fmt;
129
+
130
+ console.error(`casty: ${term.width}x${term.height} cell=${term.cellWidth.toFixed(0)}x${term.cellHeight.toFixed(0)} zoom=${term.zoom.toFixed(2)} transport=${transport} format=${screenshotFormat}${screenshotFormat === 'jpeg' ? ' (adaptive)' : ''}`);
131
+
132
+ // Frame callback for screencast / captureScreenshot
133
+ // sendFrame includes cursor positioning (single write)
134
+ function onFrame(data) {
135
+ if (renderPaused) return;
136
+ sendFrame(data);
137
+ urlBar.renderIfDirty();
138
+ }
139
+
140
+ // Phase 3: Start screencast
141
+ let { forceCapture, cleanup: screencastCleanup } = await startScreencast(client, {
142
+ width: cssWidth,
143
+ height: cssHeight,
144
+ format: screenshotFormat,
145
+ onFrame,
146
+ });
147
+
148
+ const urlBar = startInputHandling(client, cssCellW, cssCellH, bindings, pauseRender, forceCapture);
149
+ urlBar.render();
150
+
151
+ // Force capture on page load events (debounced — multiple events fire close together)
152
+ let delayedTimers = [];
153
+ function delayedCapture() {
154
+ for (const t of delayedTimers) clearTimeout(t);
155
+ delayedTimers = [];
156
+ for (const ms of DELAYED_CAPTURE_MS) {
157
+ if (ms === 0) forceCapture();
158
+ else delayedTimers.push(setTimeout(() => forceCapture(), ms));
159
+ }
160
+ }
161
+ client.on('Page.domContentEventFired', delayedCapture);
162
+ client.on('Page.loadEventFired', delayedCapture);
163
+ client.on('Page.frameNavigated', ({ frame }) => {
164
+ if (!frame.parentId) delayedCapture(); // Main frame only
165
+ });
166
+
167
+ let shuttingDown = false;
168
+ async function shutdown() {
169
+ if (shuttingDown) return;
170
+ shuttingDown = true;
171
+ console.error('casty: shutting down...');
172
+ renderPaused = true; // Stop rendering first
173
+ try {
174
+ await stopScreencast(client, screencastCleanup); // Stop screencast (disables pending captures)
175
+ await client.send('Browser.close').catch(() => {});
176
+ } catch {}
177
+ client.close();
178
+ chromeProcess.kill();
179
+ disableMouse();
180
+ showCursor();
181
+ try { process.stdin.setRawMode(false); } catch {}
182
+ clearScreen(); // Clear after everything is stopped — no re-render risk
183
+ cleanupTmp();
184
+ process.exit(0);
185
+ }
186
+
187
+ process.on('SIGINT', shutdown);
188
+ process.on('SIGTERM', shutdown);
189
+
190
+ // SIGWINCH: Follow resize + font size changes
191
+ // Debounced (150ms) + guarded with pending flag to catch late resizes
192
+ let resizeTimer = null;
193
+ let resizing = false;
194
+ let pendingResize = false;
195
+ process.on('SIGWINCH', () => {
196
+ clearTimeout(resizeTimer);
197
+ resizeTimer = setTimeout(handleResize, 150);
198
+ });
199
+ async function handleResize() {
200
+ if (resizing) { pendingResize = true; return; }
201
+ resizing = true;
202
+ try {
203
+ const t = await getTermInfo({ keepAlive: true });
204
+ const vh = t.height - Math.round(t.cellHeight);
205
+ const cw = Math.round(t.width / t.zoom);
206
+ const ch = Math.round(vh / t.zoom);
207
+ console.error(`casty: resize ${cw}x${ch} (dev:${t.width}x${vh}) zoom:${t.zoom.toFixed(2)}`);
208
+
209
+ urlBar.updateCellSize(t.cellWidth, t.cellHeight);
210
+ clearScreen();
211
+ resetFrameCache();
212
+
213
+ await stopScreencast(client, screencastCleanup);
214
+ await client.send('Emulation.setDeviceMetricsOverride', {
215
+ width: cw, height: ch, deviceScaleFactor: t.zoom, mobile: false,
216
+ });
217
+
218
+ ({ forceCapture, cleanup: screencastCleanup } = await startScreencast(client, {
219
+ width: cw,
220
+ height: ch,
221
+ format: screenshotFormat,
222
+ onFrame,
223
+ }));
224
+ } catch (err) {
225
+ console.error('casty: resize error:', err.message);
226
+ }
227
+ resizing = false;
228
+ if (pendingResize) {
229
+ pendingResize = false;
230
+ handleResize();
231
+ }
232
+ }
233
+ }
234
+
235
+ try {
236
+ await main();
237
+ } catch (err) {
238
+ // Restore stdin from raw mode (prevent CSI 14t response leak)
239
+ try { process.stdin.setRawMode(false); process.stdin.pause(); } catch {}
240
+ console.error('casty: error:', err.message);
241
+ disableMouse();
242
+ showCursor();
243
+ cleanupTmp();
244
+ process.exit(1);
245
+ }
@@ -0,0 +1,32 @@
1
+ // Bookmarks
2
+ // Reads ~/.casty/bookmarks.json, searchable via /b in the address bar
3
+
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { loadJsonFile } from './config.js';
7
+
8
+ const bookmarkPath = join(homedir(), '.casty', 'bookmarks.json');
9
+
10
+ // Load bookmarks (normalized to [{ name, url }, ...])
11
+ export function loadBookmarks() {
12
+ const data = loadJsonFile(bookmarkPath, []);
13
+ // { "name": "url", ... } object format
14
+ if (!Array.isArray(data) && typeof data === 'object') {
15
+ return Object.entries(data).map(([name, url]) => ({ name, url }));
16
+ }
17
+ // [{ name, url }, ...] array format
18
+ if (Array.isArray(data)) {
19
+ return data.filter(e => e.name && e.url);
20
+ }
21
+ return [];
22
+ }
23
+
24
+ // Search bookmarks by query (case-insensitive partial match on name/URL)
25
+ export function searchBookmarks(query) {
26
+ const bookmarks = loadBookmarks();
27
+ if (!query) return bookmarks;
28
+ const q = query.toLowerCase();
29
+ return bookmarks.filter(b =>
30
+ b.name.toLowerCase().includes(q) || b.url.toLowerCase().includes(q)
31
+ );
32
+ }
package/lib/browser.js ADDED
@@ -0,0 +1,305 @@
1
+ // Raw CDP browser control
2
+ // Runtime.enable must never be sent
3
+
4
+ import { join } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { CDPClient } from './cdp.js';
7
+ import { launchChrome } from './chrome.js';
8
+ import { loadConfig } from './config.js';
9
+
10
+ const CHROME_VERSION = '145.0.7632.6';
11
+ const USER_AGENT = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROME_VERSION} Safari/537.36`;
12
+ const PROFILE_DIR = join(homedir(), '.casty', 'profile');
13
+
14
+ // WEBGL_debug_renderer_info extension constants
15
+ const GL_UNMASKED_VENDOR = 0x9245; // UNMASKED_VENDOR_WEBGL
16
+ const GL_UNMASKED_RENDERER = 0x9246; // UNMASKED_RENDERER_WEBGL
17
+
18
+ // Screencast settings (low-res stream for change detection)
19
+ const SCREENCAST_SCALE = 4; // Downscale factor (1/4 resolution)
20
+ const SCREENCAST_NTH_FRAME = 1; // Every frame (no decimation, faster change detection)
21
+
22
+ // Capture control
23
+ const CAPTURE_MIN_INTERVAL = 50; // ms (~20fps)
24
+ const CAPTURE_STUCK_RESET = 5000; // Reset stuck capturing flag (ms)
25
+
26
+ // Build stealth script with locale-dependent language settings
27
+ // lang: primary language (e.g. "ja", "en-US")
28
+ function buildStealthScript(lang) {
29
+ // Build Accept-Language style list: primary, then en-US/en fallbacks
30
+ const langBase = lang.split('-')[0]; // "ja", "en", "zh", etc.
31
+ const languages = [lang];
32
+ if (lang !== 'en-US' && lang !== 'en') {
33
+ languages.push('en-US', 'en');
34
+ } else if (lang === 'en-US') {
35
+ languages.push('en');
36
+ }
37
+ const langArray = JSON.stringify(languages);
38
+
39
+ return `
40
+ // navigator.plugins (headless exposes empty array)
41
+ Object.defineProperty(navigator, 'plugins', {
42
+ get: () => {
43
+ const arr = [
44
+ { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
45
+ { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
46
+ { name: 'Native Client', filename: 'internal-nacl-plugin', description: '' },
47
+ ];
48
+ arr.refresh = () => {};
49
+ return arr;
50
+ },
51
+ });
52
+
53
+ // navigator.mimeTypes
54
+ Object.defineProperty(navigator, 'mimeTypes', {
55
+ get: () => {
56
+ const arr = [
57
+ { type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' },
58
+ ];
59
+ arr.refresh = () => {};
60
+ return arr;
61
+ },
62
+ });
63
+
64
+ // navigator.languages (derived from system locale or config)
65
+ Object.defineProperty(navigator, 'languages', {
66
+ get: () => ${langArray},
67
+ });
68
+
69
+ // navigator.language
70
+ Object.defineProperty(navigator, 'language', {
71
+ get: () => '${lang}',
72
+ });
73
+
74
+ // window.chrome (undefined in headless)
75
+ if (!window.chrome) {
76
+ window.chrome = {
77
+ app: { isInstalled: false, InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }, RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' } },
78
+ runtime: { OnInstalledReason: {}, OnRestartRequiredReason: {}, PlatformArch: {}, PlatformNaclArch: {}, PlatformOs: {}, RequestUpdateCheckStatus: {}, connect: function(){}, id: undefined, sendMessage: function(){} },
79
+ csi: function() { return {}; },
80
+ loadTimes: function() { return {}; },
81
+ };
82
+ }
83
+
84
+ // Permissions API (headless behaves differently)
85
+ const origQuery = navigator.permissions.query.bind(navigator.permissions);
86
+ navigator.permissions.query = (params) => {
87
+ if (params.name === 'notifications') {
88
+ return Promise.resolve({ state: Notification.permission });
89
+ }
90
+ return origQuery(params);
91
+ };
92
+
93
+ // WebGL vendor/renderer (hide SwiftShader)
94
+ // 0x9245 = UNMASKED_VENDOR_WEBGL, 0x9246 = UNMASKED_RENDERER_WEBGL
95
+ const getParam = WebGLRenderingContext.prototype.getParameter;
96
+ WebGLRenderingContext.prototype.getParameter = function(p) {
97
+ if (p === 0x9245) return 'Google Inc. (Apple)';
98
+ if (p === 0x9246) return 'ANGLE (Apple, Apple M1, OpenGL 4.1)';
99
+ return getParam.call(this, p);
100
+ };
101
+
102
+ // navigator.connection (can be undefined in headless)
103
+ if (!navigator.connection) {
104
+ Object.defineProperty(navigator, 'connection', {
105
+ get: () => ({ effectiveType: '4g', rtt: 50, downlink: 10, saveData: false }),
106
+ });
107
+ }
108
+ `;
109
+ }
110
+
111
+ // Build Accept-Language header from primary language
112
+ function buildAcceptLanguage(lang) {
113
+ if (lang === 'en-US') return 'en-US,en;q=0.9';
114
+ if (lang === 'en') return 'en';
115
+ return `${lang},en-US;q=0.9,en;q=0.8`;
116
+ }
117
+
118
+
119
+ // HTTP GET → JSON (using global fetch)
120
+ async function fetchJson(url) {
121
+ const res = await fetch(url);
122
+ if (!res.ok) return null;
123
+ try { return await res.json(); }
124
+ catch { return null; }
125
+ }
126
+
127
+ // Phase 1: Launch Chrome only (no terminal info needed, for parallel execution)
128
+ export async function startBrowser() {
129
+ return launchChrome({ userDataDir: PROFILE_DIR });
130
+ }
131
+
132
+ // Phase 2: CDP connection + page setup (navigation is done by the caller)
133
+ // Tries /json/new first, then /json/list, then Target.createTarget via browser CDP
134
+ export async function setupPage({ port, wsUrl }, { width, height, zoom = 1 } = {}) {
135
+ const config = loadConfig();
136
+ const lang = config.language;
137
+ const baseUrl = `http://127.0.0.1:${port}`;
138
+
139
+ // Try /json/new (single HTTP request, fastest)
140
+ let target = await fetchJson(`${baseUrl}/json/new?about:blank`);
141
+ if (!target?.webSocketDebuggerUrl) {
142
+ // Check /json/list for existing pages
143
+ const targets = await fetchJson(`${baseUrl}/json/list`);
144
+ target = targets?.find(t => t.type === 'page');
145
+ }
146
+ if (!target?.webSocketDebuggerUrl) {
147
+ // Fallback: create page via browser CDP (headless-shell needs this)
148
+ const browserClient = new CDPClient();
149
+ await browserClient.connect(wsUrl);
150
+ const { targetId } = await browserClient.send('Target.createTarget', { url: 'about:blank' });
151
+ browserClient.close();
152
+ // Construct WS URL directly from targetId (avoids extra /json/list HTTP request)
153
+ target = { webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/page/${targetId}` };
154
+ }
155
+
156
+ const client = new CDPClient();
157
+ await client.connect(target.webSocketDebuggerUrl);
158
+
159
+ const cssWidth = Math.round(width / zoom);
160
+ const cssHeight = Math.round(height / zoom);
161
+
162
+ // Run CDP commands in parallel (all must complete before navigation)
163
+ await Promise.all([
164
+ client.send('Page.enable'),
165
+ client.send('Emulation.setDeviceMetricsOverride', {
166
+ width: cssWidth, height: cssHeight, deviceScaleFactor: zoom, mobile: false,
167
+ }),
168
+ client.send('Network.setUserAgentOverride', {
169
+ userAgent: USER_AGENT, platform: 'MacIntel',
170
+ acceptLanguage: buildAcceptLanguage(lang),
171
+ }),
172
+ client.send('Page.addScriptToEvaluateOnNewDocument', { source: buildStealthScript(lang) }),
173
+ client.send('Browser.setDownloadBehavior', {
174
+ behavior: 'allowAndName', downloadPath: join(homedir(), 'Downloads'),
175
+ eventsEnabled: true,
176
+ }),
177
+ ]);
178
+
179
+ return { client, cssWidth, cssHeight };
180
+ }
181
+
182
+ // Adaptive frame capture with quality refinement:
183
+ //
184
+ // Screencast at 1/4 resolution as change-detection trigger,
185
+ // then Page.captureScreenshot for full DPR-aware hi-res frame.
186
+ // CAPTURE_MIN_INTERVAL throttles to keep CPU usage low.
187
+ //
188
+ // Adaptive format (JPEG → PNG refinement):
189
+ // During rapid updates: JPEG for speed (3-5x smaller than PNG)
190
+ // After activity stops: PNG refinement for crisp text (lossless)
191
+ // Only active when format='jpeg'; PNG mode is already lossless.
192
+ //
193
+ // format: 'jpeg' (file transfer, adaptive) or 'png' (inline, always lossless)
194
+ export async function startScreencast(client, { width, height, onFrame, format = 'png', quality = 85 }) {
195
+ let capturing = false;
196
+ let lastCaptureTime = 0;
197
+ let throttleTimer = null;
198
+ let refineTimer = null;
199
+
200
+ // Fast capture opts (configured format)
201
+ const fastOpts = { format, optimizeForSpeed: true, captureBeyondViewport: false };
202
+ if (format === 'jpeg') fastOpts.quality = quality;
203
+
204
+ // PNG refinement opts (lossless, for crisp text after activity stops)
205
+ const REFINE_DELAY = 500; // ms after last frame update
206
+ const refineOpts = format === 'jpeg'
207
+ ? { format: 'png', captureBeyondViewport: false }
208
+ : null;
209
+
210
+ // PNG refinement: capture one lossless frame after activity settles
211
+ async function refineCapture() {
212
+ if (capturing) return;
213
+ capturing = true;
214
+ try {
215
+ const { data } = await client.send('Page.captureScreenshot', refineOpts);
216
+ if (data) onFrame(data);
217
+ } catch {}
218
+ lastCaptureTime = Date.now();
219
+ capturing = false;
220
+ }
221
+
222
+ function scheduleRefine() {
223
+ if (!refineOpts) return;
224
+ clearTimeout(refineTimer);
225
+ refineTimer = setTimeout(refineCapture, REFINE_DELAY);
226
+ }
227
+
228
+ async function capture() {
229
+ if (capturing) return;
230
+
231
+ const now = Date.now();
232
+ const elapsed = now - lastCaptureTime;
233
+ if (elapsed < CAPTURE_MIN_INTERVAL) {
234
+ if (!throttleTimer) {
235
+ throttleTimer = setTimeout(() => {
236
+ throttleTimer = null;
237
+ capture();
238
+ }, CAPTURE_MIN_INTERVAL - elapsed);
239
+ }
240
+ return;
241
+ }
242
+
243
+ capturing = true;
244
+ lastCaptureTime = Date.now();
245
+ try {
246
+ const { data } = await client.send('Page.captureScreenshot', fastOpts);
247
+ if (data) onFrame(data);
248
+ } catch {}
249
+ capturing = false;
250
+ scheduleRefine();
251
+ }
252
+
253
+ function onScreencastFrame({ data, sessionId }) {
254
+ client.send('Page.screencastFrameAck', { sessionId }).catch(() => {});
255
+ capture();
256
+ }
257
+ client.on('Page.screencastFrame', onScreencastFrame);
258
+
259
+ // Low-res change-detection screencast + hi-res captureScreenshot
260
+ await client.send('Page.startScreencast', {
261
+ format: 'jpeg',
262
+ quality: 30,
263
+ maxWidth: Math.round(width / SCREENCAST_SCALE),
264
+ maxHeight: Math.round(height / SCREENCAST_SCALE),
265
+ everyNthFrame: SCREENCAST_NTH_FRAME,
266
+ });
267
+
268
+ // Capture first frame immediately
269
+ capture();
270
+
271
+ // Force capture (bypasses throttle, always uses captureScreenshot)
272
+ async function forceCapture() {
273
+ if (capturing) return;
274
+ capturing = true;
275
+ try {
276
+ const { data } = await client.send('Page.captureScreenshot', fastOpts);
277
+ if (data) onFrame(data);
278
+ } catch {}
279
+ lastCaptureTime = Date.now();
280
+ capturing = false;
281
+ scheduleRefine();
282
+ }
283
+
284
+ // Prevent stuck capturing flag
285
+ const stuckInterval = setInterval(() => { capturing = false; }, CAPTURE_STUCK_RESET);
286
+
287
+ function cleanup() {
288
+ clearInterval(stuckInterval);
289
+ clearTimeout(throttleTimer);
290
+ clearTimeout(refineTimer);
291
+ client.removeListener('Page.screencastFrame', onScreencastFrame);
292
+ }
293
+
294
+ return { forceCapture, cleanup };
295
+ }
296
+
297
+ // Stop frame capture (cleanupFn from startScreencast return value)
298
+ export async function stopScreencast(client, cleanupFn) {
299
+ try {
300
+ if (cleanupFn) cleanupFn();
301
+ await client.send('Page.stopScreencast');
302
+ } catch {
303
+ // Ignore if already closed
304
+ }
305
+ }
package/lib/cdp.js ADDED
@@ -0,0 +1,76 @@
1
+ // CDP WebSocket client
2
+ // Lightweight CDP client that never sends Runtime.enable
3
+
4
+ import { EventEmitter, once } from 'node:events';
5
+ import WebSocket from 'ws';
6
+
7
+ const CDP_TIMEOUT = 10000; // ms — reject pending commands after this
8
+
9
+ export class CDPClient extends EventEmitter {
10
+ constructor() {
11
+ super();
12
+ this._ws = null;
13
+ this._id = 0;
14
+ this._pending = new Map();
15
+ }
16
+
17
+ // Connect via WebSocket
18
+ async connect(wsUrl) {
19
+ this._ws = new WebSocket(wsUrl, { perMessageDeflate: false });
20
+ this._ws.on('message', (data) => {
21
+ let msg;
22
+ try { msg = JSON.parse(data); }
23
+ catch { return; } // Ignore non-JSON frames
24
+ if (msg.id !== undefined) {
25
+ // Command response
26
+ const p = this._pending.get(msg.id);
27
+ if (p) {
28
+ clearTimeout(p.timer);
29
+ this._pending.delete(msg.id);
30
+ if (msg.error) p.reject(new Error(msg.error.message));
31
+ else p.resolve(msg.result || {});
32
+ }
33
+ } else if (msg.method) {
34
+ // CDP event
35
+ this.emit(msg.method, msg.params || {});
36
+ }
37
+ });
38
+ this._ws.on('close', () => this.emit('close'));
39
+ this._ws.on('error', (err) => this.emit('error', err));
40
+ // once() rejects on 'error' if it fires before 'open'
41
+ await once(this._ws, 'open');
42
+ }
43
+
44
+ // Send CDP command (with timeout to prevent forever-pending)
45
+ send(method, params = {}) {
46
+ const id = ++this._id;
47
+ const { promise, resolve, reject } = Promise.withResolvers();
48
+ const timer = setTimeout(() => {
49
+ this._pending.delete(id);
50
+ reject(new Error(`CDP timeout: ${method}`));
51
+ }, CDP_TIMEOUT);
52
+ this._pending.set(id, { resolve, reject, timer });
53
+ try {
54
+ this._ws.send(JSON.stringify({ id, method, params }));
55
+ } catch (err) {
56
+ // ws.send() can throw synchronously if socket is closed
57
+ clearTimeout(timer);
58
+ this._pending.delete(id);
59
+ reject(err);
60
+ }
61
+ return promise;
62
+ }
63
+
64
+ // Disconnect
65
+ close() {
66
+ if (this._ws) {
67
+ this._ws.close();
68
+ this._ws = null;
69
+ }
70
+ for (const p of this._pending.values()) {
71
+ clearTimeout(p.timer);
72
+ p.reject(new Error('Connection closed'));
73
+ }
74
+ this._pending.clear();
75
+ }
76
+ }