TL;DR: My first injection strategy serialized bridge classes with class.toString() and eval'd the result into an isolated world. It worked—until it didn't. It was brittle, undebuggable, and race-prone. I replaced it with a pre-bundled bridge that's auto-loaded into a named isolated world via CDP and discovered through Runtime.executionContextCreated events. The result: flakiness dropped, real source maps, fewer CDP round-trips, and better navigation resilience—without changing the public MCP surface.
GitHub: https://github.com/verdexhq/verdex-mcp
The Problem: When String Concatenation Meets Production
When I first started working on Verdex, I needed to inject analysis code into isolated worlds (Chrome's mechanism for running code with DOM access but a separate JavaScript heap). The approach seemed straightforward: build the bridge out of classes and modules, serialize everything with class.toString(), concatenate it into one giant IIFE string, and Runtime.evaluate the result into an isolated world.
It worked. The bridge ran. Tests passed. I shipped it.
Then I started actually using it to author tests in a real application. The first refactor took two hours to debug—I'd added a dependency to one class and forgot to update the string concatenation order. The error message: ReferenceError: Cannot access 'Helper' before initialization pointing somewhere deep in a 30,000-character concatenated string. Every navigation meant rebuilding and re-evaling the entire blob. Around SPA transitions, race conditions appeared where the old world was dying and the new one wasn't ready. My code had to poll and retry with arbitrary sleeps. When I tried to debug a selector generation issue, the breakpoint landed in the eval'd code with no source mapping to show which original TypeScript file was at fault.
The system worked, mostly. But "mostly" isn't good enough when you're building test authoring tooling.
What I Built Instead
I added esbuild to produce a single IIFE bundle with inline source maps and a baked-in version number:
// build/bundle-bridge.ts
const DEV = process.env.NODE_ENV !== "production";
const result = await esbuild.build({
entryPoints: [path.join(__dirname, "../src/browser/bridge-entry.ts")],
bundle: true,
format: "iife",
platform: "browser",
target: "es2020",
minify: !DEV,
sourcemap: DEV ? "inline" : false,
sourcesContent: DEV,
banner: {
js: [
`// Verdex Bridge Bundle v${version}`,
`// Generated: ${new Date().toISOString()}`,
].join("\n"),
},
define: {
__VERSION__: JSON.stringify(version),
},
write: false,
});
The output gets written to src/runtime/bridge-bundle.ts:
export const BRIDGE_BUNDLE = "/* ~30KB bundled IIFE */";
export const BRIDGE_VERSION = "1.2.3";
The bridge entry point exposes a minimal factory inside the isolated world:
// src/browser/bridge-entry.ts
import { BridgeFactory } from "./bridge/BridgeFactory.js";
// Replaced at build time by esbuild `define`
declare const __VERSION__: string;
export const __VERDEX_BRIDGE_VERSION__ = __VERSION__;
export function createBridge(config?: BridgeConfig): IBridge {
return BridgeFactory.create(config);
}
// Expose factory to the isolated world, with version checking for upgrades
(function expose() {
const factory = Object.freeze({
create: createBridge,
version: __VERDEX_BRIDGE_VERSION__,
} satisfies VerdexBridgeFactory);
const existing = globalThis.__VerdexBridgeFactory__;
if (!existing || existing.version !== __VERDEX_BRIDGE_VERSION__) {
if (existing) {
delete (globalThis as any).__VerdexBridgeFactory__;
}
Object.defineProperty(globalThis, "__VerdexBridgeFactory__", {
value: factory,
writable: false,
enumerable: false,
configurable: true, // Allow redefinition for version upgrades
});
}
})();
Then I register it once with CDP and let Chrome handle persistence:
// BridgeInjector.setupAutoInjection(cdp, mainFrameId)
await cdp.send('Page.enable');
await cdp.send('Runtime.enable');
// auxData contains CDP metadata like frameId
cdp.on('Runtime.executionContextCreated', (evt) => {
const ctx = evt.context;
const aux = ctx.auxData ?? {};
const matchesWorld = ctx.name === worldName || aux.name === worldName;
const matchesTop = !this.mainFrameId || aux.frameId === this.mainFrameId;
if (matchesWorld && matchesTop) {
this.contextId = ctx.id;
this.navigationInProgress = false;
this.resolveContextReady();
}
});
// Three-tier fallback for CDP compatibility
try {
const { identifier } = await cdp.send('Page.addScriptToEvaluateOnNewDocument', {
source: BRIDGE_BUNDLE,
worldName: 'verdex_isolated',
runImmediately: true,
} as any);
this.scriptId = identifier;
} catch {
// Fallback for older Chrome versions without runImmediately
try {
const { identifier } = await cdp.send('Page.addScriptToEvaluateOnNewDocument', {
source: BRIDGE_BUNDLE,
worldName: 'verdex_isolated',
} as any);
this.scriptId = identifier;
} catch {
// Very old Chromium: fallback to manual per-navigation reinjection
this.manualInjectionMode = true;
}
}
The runImmediately: true flag executes the bundle in the current document immediately and in all future documents after navigation. Named worlds persist across navigations—Chrome destroys the old execution context and creates a new one with the same name, automatically running the registered script.
For navigation safety, I added guards:
cdp.on('Page.frameStartedLoading', (evt) => {
if (this.isTopFrame(evt.frameId)) {
this.navigationInProgress = true;
this.contextId = null;
this.bridgeObjectId = null;
}
});
cdp.on('Page.navigatedWithinDocument', (evt) => {
if (this.isTopFrame(evt.frameId)) {
// SPA route change: keep context alive, just invalidate instance handle
this.bridgeObjectId = null;
}
});
cdp.on('Runtime.executionContextCreated', (evt) => {
const ctx = evt.context;
const aux = ctx.auxData ?? {};
const matchesWorld = ctx.name === worldName || aux.name === worldName;
const matchesTop = !this.mainFrameId || aux.frameId === this.mainFrameId;
if (matchesWorld && matchesTop) {
this.contextId = ctx.id;
this.navigationInProgress = false;
this.resolveContextReady();
}
});
async callBridgeMethod(cdp: CDPSession, method: string, args: any[]) {
await this.waitForNavToClear();
// ... execute the call
}
And version enforcement in getBridgeHandle():
async getBridgeHandle(cdp: CDPSession): Promise<string> {
await this.waitForNavToClear();
if (this.bridgeObjectId) {
const alive = await this.healthCheck(cdp);
if (alive) return this.bridgeObjectId;
this.bridgeObjectId = null;
}
await this.waitForContextReady();
if (!this.contextId) {
throw new Error("No execution context available for the bridge world");
}
// Verify factory exists
const { result: factoryType } = await cdp.send('Runtime.evaluate', {
expression: 'typeof globalThis.__VerdexBridgeFactory__',
contextId: this.contextId,
returnByValue: true,
});
if (factoryType.value !== 'object') {
throw new Error(
`Bridge factory not available in context (got: ${factoryType.value})`
);
}
// Verify version matches
const { result: versionCheck } = await cdp.send('Runtime.evaluate', {
expression: 'globalThis.__VerdexBridgeFactory__?.version',
contextId: this.contextId,
returnByValue: true,
});
if (versionCheck.value !== BRIDGE_VERSION) {
throw new Error(
`Bridge version mismatch: got ${versionCheck.value}, expected ${BRIDGE_VERSION}`
);
}
// Create bridge instance
const { result } = await cdp.send('Runtime.evaluate', {
expression: `(function(config){ return globalThis.__VerdexBridgeFactory__.create(config); })(${JSON.stringify(
this.config
)})`,
contextId: this.contextId,
returnByValue: false,
});
if (!result.objectId) {
throw new Error("Failed to create bridge instance (no objectId)");
}
this.bridgeObjectId = result.objectId;
return this.bridgeObjectId;
}
Before and After
Old flow:
// 1. Serialize classes to strings
const bridgeString = [
BridgeFactory.toString(),
DOMAnalyzer.toString(),
StructuralAnalyzer.toString(),
// ... more classes
].join(';\n');
// 2. Wrap in IIFE
const iifeString = `(function() { ${bridgeString}; return BridgeFactory; })()`;
// 3. Create isolated world manually
const { executionContextId } = await cdp.send('Page.createIsolatedWorld', {
frameId: mainFrameId,
worldName: 'verdex_isolated',
});
// 4. Evaluate the string
const { result } = await cdp.send('Runtime.evaluate', {
expression: iifeString,
contextId: executionContextId,
});
// 5. On every navigation: repeat steps 2-4
New flow:
// 1. At build time: esbuild produces BRIDGE_BUNDLE
// 2. At runtime, once:
await cdp.send('Page.addScriptToEvaluateOnNewDocument', {
source: BRIDGE_BUNDLE,
worldName: 'verdex_isolated',
runImmediately: true,
});
// 3. Wait for event (happens automatically on navigation)
cdp.on('Runtime.executionContextCreated', (evt) => {
const ctx = evt.context;
const aux = ctx.auxData ?? {};
if (ctx.name === 'verdex_isolated' || aux.name === 'verdex_isolated') {
this.contextId = ctx.id;
}
});
// 4. Create bridge via factory
const { result } = await cdp.send('Runtime.callFunctionOn', {
functionDeclaration: `
function() {
return globalThis.__VerdexBridgeFactory__.create(arguments[0]);
}
`,
contextId: this.contextId,
arguments: [{ value: config }],
});
What Changed
Flakiness dropped from roughly 5% on navigation-heavy tests to zero. The old approach failed with "Cannot find execution context with given id" during race windows. The event-driven lifecycle eliminated those races entirely.
Stack traces went from "(eval):1:15000" to "DOMAnalyzer.ts:42". I can set breakpoints in the original TypeScript files and step through execution. DevTools treats the bundled code as first-class source because of the inline source maps (in development mode).
Re-injection overhead disappeared. The old approach added 50-100ms per navigation. The new approach: zero. The bundle loads once and persists automatically.
The implementation collapsed. Manual world lifecycle tracking, string concatenation logic, import ordering requirements, and retry loops scattered through multiple files became a single BridgeInjector class that registers once and listens for events.
The API Stayed Identical
This was entirely internal. Tool handlers work exactly as before:
const snapshot = await bridgeInjector.callBridgeMethod(cdp, 'snapshot', []);
const ancestors = await bridgeInjector.callBridgeMethod(cdp, 'get_ancestors', [ref]);
const siblings = await bridgeInjector.callBridgeMethod(cdp, 'get_siblings', [ref, level]);
const descendants = await bridgeInjector.callBridgeMethod(cdp, 'get_descendants', [ref, level]);
Users calling browser_snapshot(), resolve_container(), inspect_pattern(), and extract_anchors() saw no change. The MCP protocol surface stayed stable. Tests kept passing. Prompts kept working.
Multi-Role Still Works
Each role can get its own named world by configuring the BridgeInjector with a role-specific world name. This allows multiple isolated contexts to coexist without interfering with each other—useful when you need different analysis modes or want to maintain separate state per role:
new BridgeInjector({
worldName: `verdex_${role}`,
config: roleConfig
});
The bridge survives reloads and SPA transitions per role. Storage isolation prevents cookie/localStorage leakage. World isolation prevents bridge interference.
Security Holds
The factory isn't visible from the page's main world:
const leaked = await page.evaluate(() => {
return typeof (globalThis as any).__VerdexBridgeFactory__;
});
expect(leaked).toBe('undefined');
Isolated worlds give DOM access without exposing internals to application code.
What I Learned
Strings obfuscate. String concatenation becomes impossible to debug when your codebase grows beyond trivial.
Events beat polling.
Runtime.executionContextCreatedis the canonical source of truth for "your world exists now."Version pinning prevents hours of debugging subtle runtime failures caused by mismatches.
Source maps are non-negotiable for any injected code longer than a few lines.
Keep the API stable. Implementation details should be invisible.
Trade-Offs
The bundle adds ~50KB to memory per browser context. For a tool managing 3-5 contexts, this is negligible.
Build-time bundling adds a development step. esbuild finishes in ~100ms and I've integrated it into the watch process, but it's still an extra step.
Top-frame only currently. Supporting iframes would require tracking auxData.frameId and maintaining a per-frame context ID map. Architecturally straightforward, not yet implemented.
Conclusion
The primitives—resolve_container(), inspect_pattern(), extract_anchors()—are only as reliable as the infrastructure delivering them. The old approach worked in controlled scenarios but broke under real-world stress. The new approach handles navigation, development iteration, and production debugging deterministically because it's built on browser-provided lifecycle guarantees rather than polling and string manipulation.
The code is cleaner. The debugging is better. The reliability is higher. And users don't have to care about any of it.
Top comments (0)