363 lines
13 KiB
JavaScript
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
|