Files
2026-03-03 23:49:13 +01:00

363 lines
13 KiB
JavaScript

/**
* A class for viewing and editing web page scripts via Chrome DevTools Protocol.
* Provides a Claude Code-like interface: list, read, edit, grep.
*
* Edits are in-memory only and persist until page reload. They modify the running
* V8 instance but are not saved to disk or server.
*
* @example
* ```ts
* const cdp = await getCDPSession({ page })
* const editor = new Editor({ cdp })
* await editor.enable()
*
* // List available scripts
* const scripts = editor.list({ search: 'app' })
*
* // Read a script
* const { content } = await editor.read({ url: 'https://example.com/app.js' })
*
* // Edit a script
* await editor.edit({
* url: 'https://example.com/app.js',
* oldString: 'console.log("old")',
* newString: 'console.log("new")'
* })
* ```
*/
export class Editor {
cdp;
enabled = false;
scripts = new Map();
stylesheets = new Map();
sourceCache = new Map();
constructor({ cdp }) {
this.cdp = cdp;
this.setupEventListeners();
}
setupEventListeners() {
this.cdp.on('Debugger.scriptParsed', (params) => {
if (!params.url.startsWith('chrome') && !params.url.startsWith('devtools')) {
const url = params.url || `inline://${params.scriptId}`;
this.scripts.set(url, params.scriptId);
this.sourceCache.delete(params.scriptId);
}
});
this.cdp.on('CSS.styleSheetAdded', (params) => {
const header = params.header;
if (header.sourceURL?.startsWith('chrome') || header.sourceURL?.startsWith('devtools')) {
return;
}
const url = header.sourceURL || `inline-css://${header.styleSheetId}`;
this.stylesheets.set(url, header.styleSheetId);
this.sourceCache.delete(header.styleSheetId);
});
}
/**
* Enables the editor. Must be called before other methods.
* Scripts are collected from Debugger.scriptParsed events.
* Reload the page after enabling to capture all scripts.
*/
async enable() {
if (this.enabled) {
return;
}
await this.cdp.send('Debugger.disable');
await this.cdp.send('CSS.disable');
this.scripts.clear();
this.stylesheets.clear();
this.sourceCache.clear();
const resourcesReady = new Promise((resolve) => {
let timeout;
const listener = () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
this.cdp.off('Debugger.scriptParsed', listener);
this.cdp.off('CSS.styleSheetAdded', listener);
resolve();
}, 100);
};
this.cdp.on('Debugger.scriptParsed', listener);
this.cdp.on('CSS.styleSheetAdded', listener);
timeout = setTimeout(() => {
this.cdp.off('Debugger.scriptParsed', listener);
this.cdp.off('CSS.styleSheetAdded', listener);
resolve();
}, 100);
});
await this.cdp.send('Debugger.enable');
await this.cdp.send('DOM.enable');
await this.cdp.send('CSS.enable');
await resourcesReady;
this.enabled = true;
}
getIdByUrl(url) {
const scriptId = this.scripts.get(url);
if (scriptId) {
return { scriptId };
}
const styleSheetId = this.stylesheets.get(url);
if (styleSheetId) {
return { styleSheetId };
}
const allUrls = [...Array.from(this.scripts.keys()), ...Array.from(this.stylesheets.keys())];
const available = allUrls.slice(0, 5);
throw new Error(`Resource not found: ${url}\nAvailable: ${available.join(', ')}${allUrls.length > 5 ? '...' : ''}`);
}
/**
* Lists available script and stylesheet URLs. Use pattern to filter by regex.
* Automatically enables the editor if not already enabled.
*
* @param options - Options
* @param options.pattern - Optional regex to filter URLs
* @returns Array of URLs
*
* @example
* ```ts
* // List all scripts and stylesheets
* const urls = await editor.list()
*
* // List only JS files
* const jsFiles = await editor.list({ pattern: /\.js/ })
*
* // List only CSS files
* const cssFiles = await editor.list({ pattern: /\.css/ })
*
* // Search for specific scripts
* const appScripts = await editor.list({ pattern: /app/ })
* ```
*/
async list({ pattern } = {}) {
await this.enable();
const urls = [...Array.from(this.scripts.keys()), ...Array.from(this.stylesheets.keys())];
if (!pattern) {
return urls;
}
return urls.filter((url) => {
const matches = pattern.test(url);
pattern.lastIndex = 0;
return matches;
});
}
/**
* Reads a script or stylesheet's source code by URL.
* Returns line-numbered content like Claude Code's Read tool.
* For inline scripts, use the `inline://` URL from list() or grep().
*
* @param options - Options
* @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs)
* @param options.offset - Line number to start from (0-based, default 0)
* @param options.limit - Number of lines to return (default 2000)
* @returns Content with line numbers, total lines, and range info
*
* @example
* ```ts
* // Read by URL
* const { content, totalLines } = await editor.read({
* url: 'https://example.com/app.js'
* })
*
* // Read a CSS file
* const { content } = await editor.read({ url: 'https://example.com/styles.css' })
*
* // Read lines 100-200
* const { content } = await editor.read({
* url: 'https://example.com/app.js',
* offset: 100,
* limit: 100
* })
* ```
*/
async read({ url, offset = 0, limit = 2000 }) {
await this.enable();
const id = this.getIdByUrl(url);
const source = await this.getSource(id);
const lines = source.split('\n');
const totalLines = lines.length;
const startLine = Math.min(offset, totalLines);
const endLine = Math.min(offset + limit, totalLines);
const selectedLines = lines.slice(startLine, endLine);
const content = selectedLines.map((line, i) => `${String(startLine + i + 1).padStart(5)}| ${line}`).join('\n');
return {
content,
totalLines,
startLine: startLine + 1,
endLine,
};
}
async getSource(id) {
if ('styleSheetId' in id) {
const cached = this.sourceCache.get(id.styleSheetId);
if (cached) {
return cached;
}
const response = await this.cdp.send('CSS.getStyleSheetText', { styleSheetId: id.styleSheetId });
this.sourceCache.set(id.styleSheetId, response.text);
return response.text;
}
const cached = this.sourceCache.get(id.scriptId);
if (cached) {
return cached;
}
const response = await this.cdp.send('Debugger.getScriptSource', { scriptId: id.scriptId });
this.sourceCache.set(id.scriptId, response.scriptSource);
return response.scriptSource;
}
/**
* Edits a script or stylesheet by replacing oldString with newString.
* Like Claude Code's Edit tool - performs exact string replacement.
*
* @param options - Options
* @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs)
* @param options.oldString - Exact string to find and replace
* @param options.newString - Replacement string
* @param options.dryRun - If true, validate without applying (default false)
* @returns Result with success status
*
* @example
* ```ts
* // Replace a string in JS
* await editor.edit({
* url: 'https://example.com/app.js',
* oldString: 'const DEBUG = false',
* newString: 'const DEBUG = true'
* })
*
* // Edit CSS
* await editor.edit({
* url: 'https://example.com/styles.css',
* oldString: 'color: red',
* newString: 'color: blue'
* })
* ```
*/
async edit({ url, oldString, newString, dryRun = false, }) {
await this.enable();
const id = this.getIdByUrl(url);
const source = await this.getSource(id);
const matchCount = source.split(oldString).length - 1;
if (matchCount === 0) {
throw new Error(`oldString not found in ${url}`);
}
if (matchCount > 1) {
throw new Error(`oldString found ${matchCount} times in ${url}. Provide more context to make it unique.`);
}
const newSource = source.replace(oldString, newString);
return this.setSource(id, newSource, dryRun);
}
async setSource(id, content, dryRun = false) {
if ('styleSheetId' in id) {
await this.cdp.send('CSS.setStyleSheetText', { styleSheetId: id.styleSheetId, text: content });
if (!dryRun) {
this.sourceCache.set(id.styleSheetId, content);
}
return { success: true };
}
// Chrome deprecated Debugger.setScriptSource in Chrome 142+ (Feb 2026)
// Use Runtime.evaluate as fallback to re-execute the modified script
// This works for scripts that define functions at global scope
try {
const response = await this.cdp.send('Debugger.setScriptSource', {
scriptId: id.scriptId,
scriptSource: content,
dryRun,
});
if (!dryRun) {
this.sourceCache.set(id.scriptId, content);
}
return { success: true, stackChanged: response.stackChanged };
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Check if setScriptSource is deprecated/unavailable
if (errorMessage.includes('setScriptSource') || errorMessage.includes('-32000')) {
if (dryRun) {
// For dry run, just validate the syntax by parsing
await this.cdp.send('Runtime.compileScript', {
expression: content,
sourceURL: 'dry-run-validation',
persistScript: false,
});
return { success: true };
}
// Re-execute the entire script to override global functions
await this.cdp.send('Runtime.evaluate', {
expression: content,
returnByValue: false,
});
this.sourceCache.set(id.scriptId, content);
return { success: true, stackChanged: true };
}
throw error;
}
}
/**
* Searches for a regex across all scripts and stylesheets.
* Like Claude Code's Grep tool - returns matching lines with context.
*
* @param options - Options
* @param options.regex - Regular expression to search for in file contents
* @param options.pattern - Optional regex to filter which URLs to search
* @returns Array of matches with url, line number, and line content
*
* @example
* ```ts
* // Search all scripts and stylesheets for "color"
* const matches = await editor.grep({ regex: /color/ })
*
* // Search only CSS files
* const matches = await editor.grep({
* regex: /background-color/,
* pattern: /\.css/
* })
*
* // Regex search for console methods in JS
* const matches = await editor.grep({
* regex: /console\.(log|error|warn)/,
* pattern: /\.js/
* })
* ```
*/
async grep({ regex, pattern }) {
await this.enable();
const matches = [];
const urls = await this.list({ pattern });
for (const url of urls) {
let source;
try {
const id = this.getIdByUrl(url);
source = await this.getSource(id);
}
catch {
continue;
}
const lines = source.split('\n');
for (let i = 0; i < lines.length; i++) {
if (regex.test(lines[i])) {
matches.push({
url,
lineNumber: i + 1,
lineContent: lines[i].trim().slice(0, 200),
});
regex.lastIndex = 0;
}
}
}
return matches;
}
/**
* Writes entire content to a script or stylesheet, replacing all existing code.
* Use with caution - prefer edit() for targeted changes.
*
* @param options - Options
* @param options.url - Script or stylesheet URL (inline scripts have `inline://{id}` URLs)
* @param options.content - New content
* @param options.dryRun - If true, validate without applying (default false, only works for JS)
*/
async write({ url, content, dryRun = false, }) {
await this.enable();
const id = this.getIdByUrl(url);
return this.setSource(id, content, dryRun);
}
}
//# sourceMappingURL=editor.js.map