Skip to content

Commit 6b63445

Browse files
committed
refactor: enhance build and hot-reload process
- Updated package.json scripts for improved build and clean processes. - Introduced a new `clean:all` script to remove the entire build directory. - Added `build:full` script for a complete build without cleaning the build directory. - Modified `post-build.ts` to compile chrome-devtools-frontend only when necessary. - Implemented content hashing in `extension-watcher.ts` and `mcp-server-watcher.ts` to prevent unnecessary rebuilds. - Removed unused output panel and reload MCP server tools. - Added new tsconfig.build.json for optimized TypeScript compilation.
1 parent c129d1c commit 6b63445

11 files changed

Lines changed: 311 additions & 640 deletions

package.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
"bin": "./build/src/index.js",
77
"main": "index.js",
88
"scripts": {
9-
"clean": "node -e \"require('fs').rmSync('build', {recursive: true, force: true})\"",
10-
"bundle": "pnpm run clean && pnpm run build && rollup -c rollup.config.mjs && node -e \"require('fs').rmSync('build/node_modules', {recursive: true, force: true})\"",
11-
"build": "node -e \"require('fs').rmSync('build', {recursive: true, force: true})\" && tsc && node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/post-build.ts",
9+
"clean": "node -e \"const fs=require('fs');if(fs.existsSync('build/src'))fs.rmSync('build/src',{recursive:true,force:true});for(const f of['build/tsconfig.tsbuildinfo','build/tsconfig.build.tsbuildinfo'])try{fs.unlinkSync(f)}catch{}\"",
10+
"clean:all": "node -e \"require('fs').rmSync('build', {recursive: true, force: true})\"",
11+
"bundle": "pnpm run clean:all && pnpm run build:full && rollup -c rollup.config.mjs && node -e \"require('fs').rmSync('build/node_modules', {recursive: true, force: true})\"",
12+
"build": "pnpm run clean && tsc -p tsconfig.build.json && node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/post-build.ts",
13+
"build:full": "pnpm run clean:all && tsc && node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/post-build.ts",
1214
"typecheck": "tsc --noEmit",
13-
"dev": "pnpm run clean && tsc && node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/post-build.ts && tsc --watch --preserveWatchOutput",
15+
"dev": "pnpm run clean && tsc -p tsconfig.build.json && node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/post-build.ts && tsc -p tsconfig.build.json --watch --preserveWatchOutput",
1416
"format": "eslint --cache --fix . && prettier --write --cache .",
1517
"check-format": "eslint --cache . && prettier --check --cache .;",
1618
"docs": "pnpm run build && pnpm run docs:generate && pnpm run format",

scripts/post-build.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7+
import {execSync} from 'node:child_process';
78
import * as fs from 'node:fs';
89
import * as path from 'node:path';
910

@@ -15,10 +16,24 @@ const BUILD_DIR = path.join(process.cwd(), 'build');
1516
* @param content The content to write.
1617
*/
1718
function writeFile(filePath: string, content: string): void {
19+
fs.mkdirSync(path.dirname(filePath), {recursive: true});
1820
fs.writeFileSync(filePath, content, 'utf-8');
1921
}
2022

2123
function main(): void {
24+
// chrome-devtools-frontend ships as .ts source — it must be compiled to .js
25+
// for runtime. These files never change between pnpm installs, so we compile
26+
// them once and preserve build/node_modules/ across rebuilds (clean only
27+
// deletes build/src/). If the vendor output is missing, compile it now.
28+
const vendorMarker = path.join(
29+
BUILD_DIR, 'node_modules', 'chrome-devtools-frontend', 'mcp', 'mcp.js',
30+
);
31+
if (!fs.existsSync(vendorMarker)) {
32+
console.log('Vendor build not found — compiling chrome-devtools-frontend (one-time)…');
33+
execSync('tsc --noCheck', {cwd: process.cwd(), stdio: 'inherit'});
34+
console.log('Vendor build complete.');
35+
}
36+
2237
const devtoolsThirdPartyPath =
2338
'node_modules/chrome-devtools-frontend/front_end/third_party';
2439
const devtoolsFrontEndCorePath =

src/client-pipe.ts

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -96,15 +96,6 @@ export interface TerminalSessionInfo {
9696
command?: string;
9797
}
9898

99-
export interface OutputChannelsResult {
100-
channels: string[];
101-
}
102-
103-
export interface OutputReadResult {
104-
lines: string[];
105-
warning?: string;
106-
}
107-
10899
export interface CommandExecuteResult {
109100
result: unknown;
110101
}
@@ -212,28 +203,6 @@ function sendClientRequest(
212203
});
213204
}
214205

215-
// ── Output Methods ───────────────────────────────────────
216-
217-
/**
218-
* List available output channels.
219-
*/
220-
export async function outputListChannels(): Promise<OutputChannelsResult> {
221-
const result = await sendClientRequest('output.listChannels', {});
222-
assertResult<OutputChannelsResult>(result, 'output.listChannels');
223-
return result;
224-
}
225-
226-
/**
227-
* Read content from an output channel.
228-
*/
229-
export async function outputRead(
230-
channel: string,
231-
): Promise<OutputReadResult> {
232-
const result = await sendClientRequest('output.read', {channel});
233-
assertResult<OutputReadResult>(result, 'output.read');
234-
return result;
235-
}
236-
237206
// ── Command Methods ──────────────────────────────────────
238207

239208
/**

src/extension-watcher.ts

Lines changed: 119 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,29 @@
88
/**
99
* Extension folder change detection for hot-reload.
1010
*
11-
* Timestamp strategy:
12-
* - Read the most recent mtimeMs across all tracked extension files.
13-
* - Compare it against the debug window session start time.
14-
* - If newest mtimeMs > sessionStartMs, trigger extension hot-reload.
11+
* Two-phase detection strategy:
12+
* 1. Fast mtime check: compares newest source mtime against newest dist/ mtime.
13+
* 2. Content hash verification: when mtime suggests staleness, computes a
14+
* SHA-256 fingerprint of all source file contents and compares against
15+
* the stored fingerprint. This prevents false positives from operations
16+
* that update file metadata without changing content (e.g. `git add`).
1517
*
1618
* Tracked files are filtered by:
1719
* 1. Built-in ignore defaults (node_modules, dist, .git, *.vsix)
1820
* 2. Optional `<extensionRoot>/.devtoolsignore` patterns
1921
*/
2022

21-
import {existsSync, readdirSync, readFileSync, statSync} from 'node:fs';
23+
import {createHash} from 'node:crypto';
24+
import {existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync} from 'node:fs';
2225
import path, {extname, join, relative} from 'node:path';
2326

2427
import {logger} from './logger.js';
2528

2629
const IGNORE_DIRS = new Set(['node_modules', 'dist', '.git']);
2730
const IGNORE_EXTENSIONS = new Set(['.vsix']);
2831
const DEVTOOLS_IGNORE_FILENAME = '.devtoolsignore';
32+
const EXT_FINGERPRINT_DIR = '.devtools';
33+
const EXT_FINGERPRINT_FILE = 'ext-source-fingerprint.json';
2934

3035
interface IgnoreRule {
3136
pattern: string;
@@ -191,6 +196,89 @@ export function getNewestTrackedChangeTime(extensionDir: string): number {
191196
return result.newestMtimeMs;
192197
}
193198

199+
// ── Content Hashing ──────────────────────────────────────
200+
201+
function hashDirectoryContents(
202+
dir: string,
203+
rootDir: string,
204+
rules: IgnoreRule[],
205+
hash: ReturnType<typeof createHash>,
206+
): void {
207+
let entries: string[];
208+
try {
209+
entries = readdirSync(dir, {encoding: 'utf8'});
210+
} catch {
211+
return;
212+
}
213+
entries.sort();
214+
215+
for (const name of entries) {
216+
const fullPath = join(dir, name);
217+
let stat: ReturnType<typeof statSync>;
218+
try {
219+
stat = statSync(fullPath);
220+
} catch {
221+
continue;
222+
}
223+
224+
if (stat.isDirectory()) {
225+
if (shouldIgnorePath(rootDir, fullPath, true, rules)) continue;
226+
hashDirectoryContents(fullPath, rootDir, rules, hash);
227+
} else if (stat.isFile()) {
228+
if (shouldIgnorePath(rootDir, fullPath, false, rules)) continue;
229+
const rel = path.posix.normalize(relative(rootDir, fullPath).replaceAll('\\', '/'));
230+
hash.update(rel);
231+
try {
232+
hash.update(readFileSync(fullPath));
233+
} catch {
234+
// skip unreadable files
235+
}
236+
}
237+
}
238+
}
239+
240+
function computeExtSourceFingerprint(extensionDir: string, rules: IgnoreRule[]): string {
241+
const hash = createHash('sha256');
242+
hashDirectoryContents(extensionDir, extensionDir, rules, hash);
243+
return hash.digest('hex');
244+
}
245+
246+
function readExtFingerprint(extensionDir: string): string | null {
247+
const fp = join(extensionDir, EXT_FINGERPRINT_DIR, EXT_FINGERPRINT_FILE);
248+
if (!existsSync(fp)) return null;
249+
try {
250+
const raw = readFileSync(fp, 'utf8');
251+
const data: unknown = JSON.parse(raw);
252+
if (typeof data === 'object' && data !== null && 'hash' in data) {
253+
const hash = (data as Record<string, unknown>).hash;
254+
if (typeof hash === 'string') return hash;
255+
}
256+
return null;
257+
} catch {
258+
return null;
259+
}
260+
}
261+
262+
/**
263+
* Persist the current extension source fingerprint so future checks can skip
264+
* rebuilds when only file metadata (not content) changed.
265+
*/
266+
export function writeExtSourceFingerprint(extensionDir: string): void {
267+
const rules = parseIgnoreRules(extensionDir);
268+
const hash = computeExtSourceFingerprint(extensionDir, rules);
269+
const dir = join(extensionDir, EXT_FINGERPRINT_DIR);
270+
try {
271+
mkdirSync(dir, {recursive: true});
272+
writeFileSync(
273+
join(dir, EXT_FINGERPRINT_FILE),
274+
JSON.stringify({hash, computedAt: Date.now()}),
275+
);
276+
logger(`[hot-reload] Extension fingerprint written: ${hash.slice(0, 12)}…`);
277+
} catch (err) {
278+
logger(`[hot-reload] Failed to write extension fingerprint: ${err}`);
279+
}
280+
}
281+
194282
/**
195283
* Check whether extension files changed after the debug window started.
196284
*
@@ -269,35 +357,50 @@ export function getNewestBuildMtime(extensionDir: string): number {
269357
/**
270358
* Check if the extension build is stale (source files newer than build output).
271359
*
272-
* This compares the newest source file mtime against the newest dist/ file mtime.
273-
* If source is newer, the build is stale and needs hot-reload.
360+
* Uses a two-phase approach:
361+
* 1. Fast mtime comparison of source vs dist/ output.
362+
* 2. Content hash verification when mtime suggests staleness, to filter
363+
* out metadata-only changes (e.g. git staging).
274364
*
275365
* @param extensionDir Extension root directory
276-
* @returns true if source files are newer than build output (needs rebuild)
366+
* @returns true if source content has actually changed since last build
277367
*/
278368
export function isBuildStale(extensionDir: string): boolean {
279369
const sourceNewest = getNewestTrackedChangeTime(extensionDir);
280370
const buildNewest = getNewestBuildMtime(extensionDir);
281371

282-
// If no build exists, definitely stale
283372
if (buildNewest === 0) {
284373
logger(`[hot-reload] No build found in dist/ — build is stale`);
285374
return true;
286375
}
287376

288-
const stale = sourceNewest > buildNewest;
289-
290-
if (stale) {
377+
if (sourceNewest <= buildNewest) {
291378
logger(
292-
`[hot-reload] Build STALE: source=${new Date(sourceNewest).toISOString()} > build=${new Date(buildNewest).toISOString()}`,
379+
`[hot-reload] Build up-to-date: source=${new Date(sourceNewest).toISOString()} <= build=${new Date(buildNewest).toISOString()}`,
293380
);
294-
} else {
381+
return false;
382+
}
383+
384+
// Mtime says stale — verify with content hash
385+
logger(
386+
`[hot-reload] Mtime suggests stale: source=${new Date(sourceNewest).toISOString()} > build=${new Date(buildNewest).toISOString()} — verifying content…`,
387+
);
388+
389+
const rules = parseIgnoreRules(extensionDir);
390+
const currentHash = computeExtSourceFingerprint(extensionDir, rules);
391+
const storedHash = readExtFingerprint(extensionDir);
392+
393+
if (storedHash && currentHash === storedHash) {
295394
logger(
296-
`[hot-reload] Build up-to-date: source=${new Date(sourceNewest).toISOString()} <= build=${new Date(buildNewest).toISOString()}`,
395+
`[hot-reload] Content unchanged (fingerprint=${currentHash.slice(0, 12)}…) — metadata-only change, skipping rebuild`,
297396
);
397+
return false;
298398
}
299399

300-
return stale;
400+
logger(
401+
`[hot-reload] Content changed: ${storedHash ? `${storedHash.slice(0, 12)}… → ${currentHash.slice(0, 12)}…` : `new fingerprint ${currentHash.slice(0, 12)}…`}`,
402+
);
403+
return true;
301404
}
302405

303406
/**

src/logger.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ function formatLogChunks(chunks: unknown[]): string {
3131
// Always enable the mcp:log namespace so output is visible.
3232
// By default, write to stderr in a clean timestamped format.
3333
// stderr output appears in the host VS Code's MCP output channel
34-
// as "[server stderr]" entries, giving full visibility via read_host_output.
34+
// as "[server stderr]" entries, giving full visibility via read_output_channels.
3535
debug.enable(namespacesToEnable.join(','));
3636
debug.log = function (...chunks: unknown[]) {
3737
const ts = new Date().toISOString();

src/main.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
import './polyfill.js';
88

99
import {exec} from 'node:child_process';
10+
import path from 'node:path';
1011
import process from 'node:process';
1112

1213
import {parseArguments} from './cli.js';
1314
import {loadConfig, type ResolvedConfig} from './config.js';
14-
import {hasBuildChangedSinceWindowStart, isBuildStale} from './extension-watcher.js';
15+
import {hasBuildChangedSinceWindowStart, isBuildStale, writeExtSourceFingerprint} from './extension-watcher.js';
1516
import {restartMcpServer, showHostNotification} from './host-pipe.js';
1617
import {loadIssueDescriptions} from './issue-descriptions.js';
1718
import {logger, saveLogsToFile} from './logger.js';
@@ -22,6 +23,7 @@ import {
2223
hasBuildChangedSinceProcessStart,
2324
hasMcpServerSourceChanged,
2425
writeHotReloadMarker,
26+
writeSourceFingerprint,
2527
} from './mcp-server-watcher.js';
2628
import {startMcpSocketServer} from './mcp-socket-server.js';
2729
import {Mutex} from './Mutex.js';
@@ -59,21 +61,24 @@ let mcpServerRestartScheduled = false;
5961
let extensionHotReloadInfo: {builtAt: number} | null = null;
6062

6163
/**
62-
* Run an incremental build for hot-reload: `tsc` + `post-build` WITHOUT
63-
* `rmSync('build')`. The full `pnpm run build` cleans the build dir first,
64-
* but the running MCP server process IS loaded from `build/src/` — on
65-
* Windows the running process holds file locks, causing EPERM.
64+
* Run an incremental build for hot-reload using the fast tsconfig.build.json
65+
* (src-only, noCheck) WITHOUT `rmSync('build')`.
66+
*
67+
* Uses tsconfig.build.json which only compiles src/ files with noCheck,
68+
* skipping the 800+ chrome-devtools-frontend files and type-checking.
69+
* This reduces build time from ~18s to ~2s.
6670
*
6771
* Incremental tsc overwrites files in-place, which works fine even while
6872
* the old code is loaded (Node.js has already read them into memory).
6973
*/
7074
function runMcpServerBuild(): Promise<{stdout: string; stderr: string}> {
7175
return new Promise((resolve, reject) => {
7276
logger(`[mcp-hot-reload] Running incremental build in ${mcpServerDir}`);
73-
const cmd = 'npx tsc && node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/post-build.ts';
77+
const tscBin = path.join(mcpServerDir, 'node_modules', '.bin', process.platform === 'win32' ? 'tsc.cmd' : 'tsc');
78+
const cmd = `"${tscBin}" -p tsconfig.build.json && node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/post-build.ts`;
7479
exec(
7580
cmd,
76-
{cwd: mcpServerDir, timeout: 120_000},
81+
{cwd: mcpServerDir, timeout: 30_000, shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/sh'},
7782
(err, stdout, stderr) => {
7883
if (err) {
7984
const output = [stdout, stderr].filter(Boolean).join('\n').trim();
@@ -392,6 +397,7 @@ function registerTool(tool: ToolDefinition): void {
392397

393398
try {
394399
await runMcpServerBuild();
400+
writeSourceFingerprint(mcpServerDir);
395401
writeHotReloadMarker(mcpServerDir);
396402
scheduleMcpServerRestart();
397403

@@ -487,6 +493,7 @@ function registerTool(tool: ToolDefinition): void {
487493
const reason = stale ? 'source stale' : 'manual build detected';
488494
logger(`[tool:${tool.name}] Extension needs hot-reload (${reason}) — reloading…`);
489495
await lifecycleService.handleHotReload();
496+
writeExtSourceFingerprint(config.extensionBridgePath);
490497
extensionHotReloadInfo = {builtAt: Date.now()};
491498
logger(`[tool:${tool.name}] Hot-reload complete — reconnected`);
492499
}

0 commit comments

Comments
 (0)