/* eslint-disable */
// @ts-nocheck
/**
* Props Agent - MAIN World Script
*
* Runtime hacking agent for React/Vue Props editing.
* Communicates with ISOLATED world via CustomEvent.
*
* Architecture:
* - Transport: CustomEvent-based request/response
* - Locator: Simplified ElementLocator resolution
* - ReactAdapter: DevTools Hook detection/injection + overrideProps
* - VueAdapter: __vueParentComponent + $forceUpdate
* - Serializer: Safe Props serialization with type preservation
* - Handlers: Request operation dispatch
*
* @module props-agent
*/
(() => {
'use strict';
// =============================================================================
// Constants & Guards
// =============================================================================
const GLOBAL_KEY = '__MCP_WEB_EDITOR_PROPS_AGENT__';
if (window[GLOBAL_KEY]) return;
const PROTOCOL_VERSION = 1;
const LOG_PREFIX = '[PropsAgent]';
const EVENT_NAME = Object.freeze({
REQUEST: 'web-editor-props:request',
RESPONSE: 'web-editor-props:response',
CLEANUP: 'web-editor-props:cleanup',
});
const REACT_HOOK_NAME = '__REACT_DEVTOOLS_GLOBAL_HOOK__';
/** @type {'READY' | 'HOOK_PRESENT_NO_RENDERERS' | 'RENDERERS_NO_EDITING' | 'HOOK_MISSING'} */
const HOOK_STATUS = Object.freeze({
READY: 'READY',
HOOK_PRESENT_NO_RENDERERS: 'HOOK_PRESENT_NO_RENDERERS',
RENDERERS_NO_EDITING: 'RENDERERS_NO_EDITING',
HOOK_MISSING: 'HOOK_MISSING',
});
const SERIALIZE_LIMITS = Object.freeze({
maxDepth: 4,
maxEntries: 100,
maxArrayLength: 50,
maxStringLength: 1500,
});
// =============================================================================
// Utilities
// =============================================================================
function isObject(value) {
return value !== null && typeof value === 'object';
}
function safeString(value) {
try {
if (typeof value === 'string') return value;
if (value === null || value === undefined) return '';
return String(value);
} catch {
return '';
}
}
function logWarn(...args) {
try {
console.warn(LOG_PREFIX, ...args);
} catch {
// Silently ignore
}
}
// =============================================================================
// Transport Layer
// =============================================================================
const Transport = {
dispatchResponse(detail) {
try {
window.dispatchEvent(new CustomEvent(EVENT_NAME.RESPONSE, { detail }));
} catch (err) {
logWarn('Failed to dispatch response:', err);
}
},
createResponse(requestId, success, data, error) {
const response = {
v: PROTOCOL_VERSION,
requestId,
success: Boolean(success),
};
if (data !== undefined) response.data = data;
if (error !== undefined) response.error = safeString(error);
return response;
},
normalizeRequest(detail) {
if (!isObject(detail)) return null;
if (detail.v !== PROTOCOL_VERSION) return null;
const requestId = typeof detail.requestId === 'string' ? detail.requestId : '';
const op = typeof detail.op === 'string' ? detail.op : '';
if (!requestId || !op) return null;
return {
v: PROTOCOL_VERSION,
requestId,
op,
locator: detail.locator,
payload: detail.payload,
};
},
};
// =============================================================================
// Locator - Element Resolution
// =============================================================================
const Locator = {
safeQuerySelector(root, selector) {
try {
if (!root || typeof selector !== 'string' || !selector.trim()) return null;
return root.querySelector(selector);
} catch {
return null;
}
},
safeQuerySelectorAll(root, selector) {
try {
if (!root || typeof selector !== 'string' || !selector.trim()) return [];
return Array.from(root.querySelectorAll(selector));
} catch {
return [];
}
},
isSelectorUnique(root, selector) {
return this.safeQuerySelectorAll(root, selector).length === 1;
},
computeFingerprint(element) {
try {
const parts = [];
const tag = element?.tagName ? String(element.tagName).toLowerCase() : 'unknown';
parts.push(tag);
const id = element?.id ? String(element.id).trim() : '';
if (id) parts.push(`id=${id}`);
return parts.join('|');
} catch {
return '';
}
},
verifyFingerprint(element, fingerprint) {
try {
const current = this.computeFingerprint(element);
const storedParts = safeString(fingerprint).split('|');
const currentParts = current.split('|');
// Tag must match
if (storedParts[0] !== currentParts[0]) return false;
// If stored has id, current must have same id
const storedId = storedParts.find((p) => p.startsWith('id='));
const currentId = currentParts.find((p) => p.startsWith('id='));
if (storedId && storedId !== currentId) return false;
return true;
} catch {
return false;
}
},
normalizeStringArray(value) {
if (!Array.isArray(value)) return [];
return value.map((v) => safeString(v).trim()).filter(Boolean);
},
/**
* Resolve ElementLocator to DOM element
* Simplified version for MAIN world (no iframe support yet)
*/
locate(locator, rootDocument = document) {
try {
if (!isObject(locator)) return null;
let queryRoot = rootDocument;
// Traverse Shadow DOM host chain
const shadowHostChain = this.normalizeStringArray(locator.shadowHostChain);
for (const hostSelector of shadowHostChain) {
if (!this.isSelectorUnique(queryRoot, hostSelector)) return null;
const host = this.safeQuerySelector(queryRoot, hostSelector);
if (!host) return null;
const shadowRoot = host.shadowRoot;
if (!shadowRoot) return null;
queryRoot = shadowRoot;
}
// Try each selector candidate
const selectors = this.normalizeStringArray(locator.selectors);
for (const selector of selectors) {
if (!this.isSelectorUnique(queryRoot, selector)) continue;
const element = this.safeQuerySelector(queryRoot, selector);
if (!element) continue;
// Verify fingerprint if provided
const fp = safeString(locator.fingerprint);
if (fp && !this.verifyFingerprint(element, fp)) continue;
return element;
}
} catch {
// Best-effort
}
return null;
},
};
// =============================================================================
// React Adapter
// =============================================================================
const ReactAdapter = {
/** Store original values for reset (fiber -> { renderer, originals: Map }) */
overrideStore: typeof WeakMap === 'function' ? new WeakMap() : null,
/** Flag to avoid repeated hook installation attempts */
hookInstallAttempted: false,
getHook() {
try {
return window[REACT_HOOK_NAME] || null;
} catch {
return null;
}
},
/**
* Install minimal DevTools hook if missing.
* Note: This only helps if React hasn't initialized yet.
* Only attempts once per session to avoid repeated pollution.
*/
installMinimalHook() {
// Only attempt once per session
if (this.hookInstallAttempted) {
return { installed: false, hook: this.getHook(), skipped: true };
}
this.hookInstallAttempted = true;
try {
const existing = window[REACT_HOOK_NAME];
if (existing && typeof existing.inject === 'function') {
return { installed: false, hook: existing };
}
const listeners = Object.create(null);
const hook = {
renderers: new Map(),
supportsFiber: true,
inject(renderer) {
try {
const id = this.renderers.size + 1;
this.renderers.set(id, renderer);
this.emit('renderer', { id, renderer });
return id;
} catch {
return 0;
}
},
// Required lifecycle callbacks (no-ops)
onCommitFiberRoot() {},
onCommitFiberUnmount() {},
onPostCommitFiberRoot() {},
setStrictMode() {},
checkDCE() {},
// Event emitter
on(event, fn) {
if (typeof event !== 'string' || typeof fn !== 'function') return;
if (!listeners[event]) listeners[event] = new Set();
listeners[event].add(fn);
},
off(event, fn) {
if (typeof event !== 'string' || typeof fn !== 'function') return;
listeners[event]?.delete(fn);
},
emit(event, data) {
const set = listeners[event];
if (!set) return;
for (const fn of Array.from(set)) {
try {
fn(data);
} catch {
// Listener errors must not break the hook
}
}
},
sub(event, fn) {
this.on(event, fn);
return () => this.off(event, fn);
},
};
window[REACT_HOOK_NAME] = hook;
return { installed: true, hook };
} catch (err) {
return { installed: false, hook: null, error: err };
}
},
/**
* Normalize hook.renderers to array format
*/
normalizeRenderers(hook) {
const result = [];
if (!hook) return result;
try {
const renderers = hook.renderers;
if (renderers instanceof Map) {
for (const [id, renderer] of renderers.entries()) {
result.push({ id, renderer });
}
} else if (renderers && typeof renderers === 'object') {
for (const [id, renderer] of Object.entries(renderers)) {
result.push({ id, renderer });
}
}
} catch {
// Best-effort
}
return result;
},
/**
* Detect Hook status (4 states)
*/
detectStatus() {
const hook = this.getHook();
if (!hook || typeof hook.inject !== 'function') {
return {
hookStatus: HOOK_STATUS.HOOK_MISSING,
hook: null,
renderers: [],
editableRenderers: [],
};
}
const renderers = this.normalizeRenderers(hook);
if (!renderers.length) {
return {
hookStatus: HOOK_STATUS.HOOK_PRESENT_NO_RENDERERS,
hook,
renderers,
editableRenderers: [],
};
}
const editableRenderers = renderers.filter(
(r) => r?.renderer && typeof r.renderer.overrideProps === 'function',
);
if (editableRenderers.length) {
return {
hookStatus: HOOK_STATUS.READY,
hook,
renderers,
editableRenderers,
};
}
return {
hookStatus: HOOK_STATUS.RENDERERS_NO_EDITING,
hook,
renderers,
editableRenderers: [],
};
},
/**
* Get React version from renderer or global.
* Prioritizes specific renderer version for multi-renderer scenarios.
*
* @param {object} hookInfo - Result from detectStatus()
* @param {object} [specificRenderer] - Specific renderer to prefer (from resolveFiberWithRenderer)
* @returns {string | undefined}
*/
getVersion(hookInfo, specificRenderer) {
try {
// Priority 1: Specific renderer version (for multi-renderer scenarios)
if (specificRenderer) {
const version = specificRenderer.version;
if (typeof version === 'string' && version.trim()) {
return version.trim();
}
}
// Priority 2: Any renderer with version
const renderers = hookInfo?.renderers || [];
for (const item of renderers) {
const version = item?.renderer?.version;
if (typeof version === 'string' && version.trim()) {
return version.trim();
}
}
// Priority 3: Global React object (if exposed)
if (typeof window !== 'undefined' && window.React?.version) {
return String(window.React.version).trim();
}
} catch {
// Best-effort
}
return undefined;
},
/**
* Find React fiber from DOM node
*/
findFiberFromDOM(node) {
try {
if (!node || typeof node !== 'object') return null;
const keys = Object.keys(node);
for (const key of keys) {
if (key.startsWith('__reactFiber$') || key.startsWith('__reactInternalInstance$')) {
return node[key];
}
}
} catch {
// Best-effort
}
return null;
},
/**
* Check if fiber tag is a component (Function/Class/ForwardRef etc.)
*/
isComponentTag(tag) {
// 0=FunctionComponent, 1=ClassComponent, 2=IndeterminateComponent,
// 11=ForwardRef, 14=MemoComponent, 15=SimpleMemoComponent
return tag === 0 || tag === 1 || tag === 2 || tag === 11 || tag === 14 || tag === 15;
},
/**
* Find nearest component fiber by walking up the fiber tree
*/
findNearestComponentFiber(fiber) {
try {
let current = fiber;
for (let i = 0; i < 60 && current; i++) {
if (this.isComponentTag(current.tag)) return current;
current = current.return;
}
} catch {
// Best-effort
}
return null;
},
/**
* Get component display name from fiber
*/
getComponentName(fiber) {
try {
const type = fiber?.type || fiber?.elementType;
if (!type) return 'Anonymous';
if (typeof type === 'string') return type;
return safeString(type.displayName || type.name) || 'Anonymous';
} catch {
return 'Anonymous';
}
},
/**
* Extract debug source from React Fiber.
* Walks up the fiber tree checking _debugSource and _debugOwner._debugSource.
*
* @param {object} fiber - React Fiber node
* @returns {{ file: string, line?: number, column?: number, componentName?: string } | null}
*/
getDebugSource(fiber) {
try {
let current = fiber;
for (let i = 0; i < 40 && current; i++) {
if (!isObject(current)) break;
// Try direct _debugSource first
const src = isObject(current._debugSource) ? current._debugSource : null;
if (src) {
const file = safeString(src.fileName).trim();
if (file) {
return this.buildDebugSourceResult(file, src.lineNumber, src.columnNumber, current);
}
}
// Fallback to _debugOwner._debugSource
const owner = isObject(current._debugOwner) ? current._debugOwner : null;
const ownerSrc = owner && isObject(owner._debugSource) ? owner._debugSource : null;
if (ownerSrc) {
const ownerFile = safeString(ownerSrc.fileName).trim();
if (ownerFile) {
return this.buildDebugSourceResult(
ownerFile,
ownerSrc.lineNumber,
ownerSrc.columnNumber,
owner,
);
}
}
current = current.return;
}
} catch {
// Best-effort extraction
}
return null;
},
/**
* Build debug source result with validated line/column values.
* @private
*/
buildDebugSourceResult(file, lineNumber, columnNumber, fiberForName) {
const line = Number(lineNumber);
const column = Number(columnNumber);
return {
file,
line: Number.isFinite(line) && line > 0 ? line : undefined,
column: Number.isFinite(column) && column > 0 ? column : undefined,
componentName: this.getComponentName(fiberForName),
};
},
/**
* Resolve fiber using renderer.findFiberByHostInstance when available
*/
resolveFiberWithRenderer(element, hookInfo) {
// Prefer renderer API (returns renderer-owned fiber suitable for overrideProps)
try {
const renderers = hookInfo?.renderers || [];
for (const item of renderers) {
const renderer = item?.renderer;
if (!renderer || typeof renderer.findFiberByHostInstance !== 'function') continue;
try {
const fiber = renderer.findFiberByHostInstance(element);
if (fiber) return { fiber, renderer };
} catch {
// Try next renderer
}
}
} catch {
// Best-effort
}
// Fallback: DOM-attached fiber reference
const fallback = this.findFiberFromDOM(element);
return { fiber: fallback, renderer: null };
},
/**
* Record original value for reset
*/
recordOriginal(fiber, renderer, path, existed, value) {
if (!this.overrideStore || !fiber) return;
try {
const key = JSON.stringify(path);
let store = this.overrideStore.get(fiber);
if (!store) {
store = { renderer: renderer || null, originals: new Map() };
this.overrideStore.set(fiber, store);
// Also store by alternate to improve reset hit rate
if (fiber.alternate && typeof fiber.alternate === 'object') {
this.overrideStore.set(fiber.alternate, store);
}
}
if (!store.originals.has(key)) {
store.originals.set(key, { path, existed, value });
}
if (!store.renderer && renderer) {
store.renderer = renderer;
}
} catch {
// Best-effort
}
},
/**
* Get stored originals for fiber
*/
getOriginals(fiber) {
if (!this.overrideStore || !fiber) return null;
return this.overrideStore.get(fiber) || null;
},
/**
* Clear stored originals for fiber
*/
clearOriginals(fiber) {
if (!this.overrideStore || !fiber) return;
const store = this.overrideStore.get(fiber);
if (store?.originals) store.originals.clear();
},
};
// =============================================================================
// Vue Adapter
// =============================================================================
const VueAdapter = {
/** Store original values for reset (instance -> Map) */
overrideStore: typeof WeakMap === 'function' ? new WeakMap() : null,
/**
* Find Vue 3 component instance from DOM node
*/
findInstanceFromDOM(node) {
try {
if (!node || typeof node !== 'object') return null;
return node.__vueParentComponent || null;
} catch {
return null;
}
},
/**
* Get component name from instance
*/
getComponentName(instance) {
try {
const type = instance?.type;
return safeString(type?.name || type?.__name) || 'Anonymous';
} catch {
return 'Anonymous';
}
},
/**
* Check if instance appears to be from dev build
*/
isDevBuild(instance) {
try {
const type = instance?.type;
const file = type?.__file;
return typeof file === 'string' && !!file.trim();
} catch {
return false;
}
},
/**
* Parse Vue inspector location attribute value.
* Format: "src/components/Foo.vue:23:7" or "C:\path\file.vue:10:5" (Windows)
*
* Uses trailing regex to safely handle Windows paths with drive letters.
*
* @param {string} value - The data-v-inspector attribute value
* @returns {{ file: string, line?: number, column?: number } | null}
*/
parseVInspector(value) {
if (typeof value !== 'string') return null;
const raw = value.trim();
if (!raw) return null;
// Match only trailing :line or :line:column to avoid Windows drive letter issues
const match = raw.match(/:([\d]+)(?::([\d]+))?$/);
if (!match) {
// No line info, return file only
return { file: raw };
}
const file = raw.slice(0, match.index).trim();
if (!file) return null;
const line = Number.parseInt(match[1], 10);
const column = match[2] ? Number.parseInt(match[2], 10) : undefined;
return {
file,
line: Number.isFinite(line) && line > 0 ? line : undefined,
column: Number.isFinite(column) && column > 0 ? column : undefined,
};
},
/**
* Walk up DOM tree to find data-v-inspector attribute.
* This attribute is injected by @vitejs/plugin-vue-inspector.
*
* @param {Element} element - Starting DOM element
* @param {number} [maxDepth=15] - Maximum depth to traverse
* @returns {{ file: string, line?: number, column?: number } | null}
*/
findInspectorLocation(element, maxDepth = 15) {
try {
let node = element;
for (let depth = 0; depth < maxDepth && node; depth++) {
if (typeof node.getAttribute === 'function') {
const attr = node.getAttribute('data-v-inspector');
if (attr) {
const parsed = this.parseVInspector(attr);
if (parsed?.file) return parsed;
}
}
node = node.parentElement;
}
} catch {
// Best-effort extraction
}
return null;
},
/**
* Get Vue component debug source.
* Priority: data-v-inspector (has line/column) > type.__file (file only)
*
* @param {object} instance - Vue component instance
* @param {Element} targetElement - DOM element for inspector lookup
* @returns {{ file: string, line?: number, column?: number, componentName?: string } | null}
*/
getDebugSource(instance, targetElement) {
try {
// Priority 1: data-v-inspector attribute (has precise line/column)
const inspector = this.findInspectorLocation(targetElement);
if (inspector?.file) {
return {
file: inspector.file,
line: inspector.line,
column: inspector.column,
componentName: this.getComponentName(instance),
};
}
// Priority 2: type.__file (file only, no line/column)
const typeFile = instance?.type?.__file;
if (typeof typeFile === 'string') {
const file = typeFile.trim();
if (file) {
return {
file,
componentName: this.getComponentName(instance),
};
}
}
} catch {
// Best-effort extraction
}
return null;
},
/**
* Get Vue 3 version from instance.
* Note: This adapter only supports Vue 3 (via __vueParentComponent).
*
* @param {object} instance - Vue 3 component instance
* @returns {string | undefined}
*/
getVersion(instance) {
try {
// Vue 3: Get version from app context
const appVersion = instance?.appContext?.app?.version;
if (typeof appVersion === 'string' && appVersion.trim()) {
return appVersion.trim();
}
} catch {
// Best-effort
}
return undefined;
},
/**
* Get writable props container (vnode.props or instance.props)
* @deprecated Use getWriteContainers for better targeting
*/
getPropsContainer(instance) {
try {
const vnodeProps = instance?.vnode?.props;
if (vnodeProps && typeof vnodeProps === 'object') return vnodeProps;
} catch {
// ignore
}
try {
const props = instance?.props;
if (props && typeof props === 'object') return props;
} catch {
// ignore
}
return null;
},
/**
* Check if a key is a declared prop (vs fallthrough attr).
* Uses component type definition and runtime props object.
*/
isDeclaredProp(instance, key) {
// Check type.props definition first
try {
const opts = instance?.type?.props;
if (Array.isArray(opts)) return opts.includes(key);
if (isObject(opts)) return Object.prototype.hasOwnProperty.call(opts, key);
} catch {
// ignore
}
// Fallback: if key exists in instance.props, treat as declared
try {
const props = instance?.props;
if (isObject(props)) {
return Object.prototype.hasOwnProperty.call(props, key);
}
} catch {
// ignore
}
return false;
},
/**
* Get write container candidates for a prop kind ('props' | 'attrs').
* Returns array of containers to try in order.
*/
getWriteContainers(instance, kind) {
const containers = [];
const seen = typeof Set === 'function' ? new Set() : null;
const addContainer = (obj) => {
if (!obj || typeof obj !== 'object') return;
if (seen) {
if (seen.has(obj)) return;
seen.add(obj);
}
containers.push(obj);
};
if (!instance || typeof instance !== 'object') return containers;
// Primary container based on kind
if (kind === 'attrs') {
try {
addContainer(instance.attrs);
} catch {
// ignore
}
} else {
try {
addContainer(instance.props);
} catch {
// ignore
}
}
// Fallback: vnode.props (often more writable)
try {
addContainer(instance?.vnode?.props);
} catch {
// ignore
}
return containers;
},
/**
* Get logical root for reading a prop kind.
*/
getReadRoot(instance, kind) {
if (kind === 'attrs') {
try {
if (isObject(instance?.attrs)) return instance.attrs;
} catch {
// ignore
}
} else {
try {
if (isObject(instance?.props)) return instance.props;
} catch {
// ignore
}
}
// Fallback
try {
if (isObject(instance?.vnode?.props)) return instance.vnode.props;
} catch {
// ignore
}
return null;
},
/**
* Get raw vnode props object
*/
getVNodeProps(instance) {
try {
const p = instance?.vnode?.props;
return isObject(p) ? p : null;
} catch {
return null;
}
},
/**
* Apply new raw props via instance.next + instance.update() so Vue runs its internal
* updateProps/updateSlots pipeline (closest to a parent-driven props update).
* This is the correct way to trigger Vue3 props update.
*/
applyNextProps(instance, nextRawProps) {
try {
const vnode = instance?.vnode;
if (!vnode || typeof vnode !== 'object') return false;
// Vue3 PatchFlags.FULL_PROPS = 16
const FULL_PROPS = 16;
const prevFlag = typeof vnode.patchFlag === 'number' ? vnode.patchFlag : 0;
const patchFlag = prevFlag >= 0 ? prevFlag | FULL_PROPS : FULL_PROPS;
// Create next vnode with updated props
const nextVNode = Object.assign({}, vnode, {
props: nextRawProps,
patchFlag,
dynamicProps: null,
component: instance,
});
instance.next = nextVNode;
// Trigger update
if (instance && typeof instance.update === 'function') {
instance.update();
return true;
}
const proxy = instance?.proxy;
if (proxy && typeof proxy.$forceUpdate === 'function') {
proxy.$forceUpdate();
return true;
}
} catch {
// ignore
}
return false;
},
/**
* Trigger Vue re-render (fallback, may not work for props changes)
*/
forceUpdate(instance) {
try {
const proxy = instance?.proxy;
if (proxy && typeof proxy.$forceUpdate === 'function') {
proxy.$forceUpdate();
return true;
}
} catch {
// ignore
}
try {
if (instance && typeof instance.update === 'function') {
instance.update();
return true;
}
} catch {
// ignore
}
return false;
},
/**
* Immutable update helper for nested props
*/
copyWithSet(root, path, value) {
if (!Array.isArray(path) || path.length === 0) return value;
const seg = path[0];
const rest = path.slice(1);
const isIndex = typeof seg === 'number';
let base = root;
if (
base === null ||
base === undefined ||
(typeof base !== 'object' && !Array.isArray(base))
) {
base = isIndex ? [] : {};
}
const clone = Array.isArray(base) ? base.slice() : { ...base };
clone[seg] = this.copyWithSet(clone[seg], rest, value);
return clone;
},
/**
* Record original value for reset
* @param {object} instance - Vue component instance
* @param {Array} path - Prop path
* @param {boolean} existed - Whether the prop existed before
* @param {*} value - Original value
* @param {'props'|'attrs'} [targetKind] - Target container kind (for accurate reset)
*/
recordOriginal(instance, path, existed, value, targetKind) {
if (!this.overrideStore || !instance) return;
try {
const key = JSON.stringify(path);
let store = this.overrideStore.get(instance);
if (!store) {
store = new Map();
this.overrideStore.set(instance, store);
}
if (!store.has(key)) {
store.set(key, { path, existed, value, targetKind });
}
} catch {
// Best-effort
}
},
/**
* Get stored originals for instance
*/
getOriginals(instance) {
if (!this.overrideStore || !instance) return null;
return this.overrideStore.get(instance) || null;
},
/**
* Clear stored originals for instance
*/
clearOriginals(instance) {
if (!this.overrideStore || !instance) return;
const store = this.overrideStore.get(instance);
if (store) store.clear();
},
};
// =============================================================================
// Framework Detector
// =============================================================================
const FrameworkDetector = {
/**
* Detect framework for element (walks up DOM tree)
*/
detect(element, maxDepth = 15) {
let node = element;
for (let depth = 0; depth < maxDepth && node; depth++) {
// React first (more common)
const fiber = ReactAdapter.findFiberFromDOM(node);
if (fiber) {
return { framework: 'react', node, data: fiber };
}
// Vue 3
const vue = VueAdapter.findInstanceFromDOM(node);
if (vue) {
return { framework: 'vue', node, data: vue };
}
node = node.parentElement;
}
return { framework: 'unknown', node: null, data: null };
},
};
// =============================================================================
// Serializer
// =============================================================================
const Serializer = {
/**
* Check if value is a React element
*/
isReactElement(value) {
try {
if (!value || typeof value !== 'object') return false;
const t = value.$$typeof;
if (!t) return false;
if (typeof Symbol === 'function' && Symbol.for) {
return (
t === Symbol.for('react.element') ||
t === Symbol.for('react.transitional.element') ||
t === Symbol.for('react.portal')
);
}
// Fallback heuristic
return !!(value.type && value.props);
} catch {
return false;
}
},
/**
* Get React element display string
*/
reactElementDisplay(value) {
try {
const type = value?.type;
if (typeof type === 'string') return `<${type} />`;
if (typeof type === 'function') {
return `<${safeString(type.displayName || type.name) || 'Anonymous'} />`;
}
if (type && typeof type === 'object') {
const name = safeString(type.displayName || type.name) || 'Anonymous';
return `<${name} />`;
}
} catch {
// ignore
}
return '<ReactElement />';
},
/**
* Check if value is an editable primitive
*/
isEditablePrimitive(value) {
if (value === null || value === undefined) return true;
const t = typeof value;
if (t === 'string' || t === 'boolean') return true;
if (t === 'number') return Number.isFinite(value);
return false;
},
/**
* Create serialization context for cycle detection
*/
createContext() {
return {
seen: typeof WeakMap === 'function' ? new WeakMap() : null,
nextId: 1,
};
},
/**
* Serialize a value with type information
*/
serializeValue(value, ctx, depth = 0) {
try {
if (value === null) return { kind: 'null' };
if (value === undefined) return { kind: 'undefined' };
const t = typeof value;
if (t === 'string') {
if (value.length > SERIALIZE_LIMITS.maxStringLength) {
return {
kind: 'string',
value: value.slice(0, SERIALIZE_LIMITS.maxStringLength),
truncated: true,
length: value.length,
};
}
return { kind: 'string', value };
}
if (t === 'number') {
if (Number.isFinite(value)) return { kind: 'number', value };
if (Number.isNaN(value)) return { kind: 'number', special: 'NaN' };
return { kind: 'number', special: value > 0 ? 'Infinity' : '-Infinity' };
}
if (t === 'boolean') return { kind: 'boolean', value };
if (t === 'bigint') return { kind: 'bigint', value: value.toString() };
if (t === 'symbol') return { kind: 'symbol', description: safeString(value) };
if (t === 'function')
return { kind: 'function', name: safeString(value.name) || undefined };
// Object types
if (this.isReactElement(value)) {
return { kind: 'react_element', display: this.reactElementDisplay(value) };
}
if (typeof Element !== 'undefined' && value instanceof Element) {
return {
kind: 'dom_element',
tagName: safeString(value.tagName).toLowerCase(),
id: safeString(value.id) || undefined,
className: safeString(value.className) || undefined,
};
}
if (value instanceof Date) {
let iso = '';
try {
iso = value.toISOString();
} catch {
iso = safeString(value);
}
return { kind: 'date', value: iso };
}
if (value instanceof RegExp) {
return { kind: 'regexp', source: value.source, flags: value.flags };
}
if (value instanceof Error) {
return {
kind: 'error',
name: safeString(value.name) || 'Error',
message: safeString(value.message),
};
}
// Depth limit
if (depth >= SERIALIZE_LIMITS.maxDepth) {
return {
kind: 'max_depth',
type: Object.prototype.toString.call(value),
preview: safeString(value),
};
}
// Circular reference detection
if (ctx?.seen) {
const existingId = ctx.seen.get(value);
if (existingId) return { kind: 'circular', refId: existingId };
ctx.seen.set(value, ctx.nextId++);
}
// Array
if (Array.isArray(value)) {
const max = Math.min(value.length, SERIALIZE_LIMITS.maxArrayLength);
const items = [];
for (let i = 0; i < max; i++) {
items.push(this.serializeValue(value[i], ctx, depth + 1));
}
return {
kind: 'array',
length: value.length,
truncated: value.length > max,
items,
};
}
// Map
if (value instanceof Map) {
const entries = [];
let count = 0;
for (const [k, v] of value.entries()) {
if (count >= SERIALIZE_LIMITS.maxEntries) break;
entries.push({
key: this.serializeValue(k, ctx, depth + 1),
value: this.serializeValue(v, ctx, depth + 1),
});
count++;
}
return {
kind: 'map',
size: value.size,
truncated: value.size > count,
entries,
};
}
// Set
if (value instanceof Set) {
const items = [];
let count = 0;
for (const v of value.values()) {
if (count >= SERIALIZE_LIMITS.maxEntries) break;
items.push(this.serializeValue(v, ctx, depth + 1));
count++;
}
return {
kind: 'set',
size: value.size,
truncated: value.size > count,
items,
};
}
// Plain object
const constructorName = value?.constructor?.name;
const name = typeof constructorName === 'string' ? constructorName : undefined;
const keys = Object.keys(value);
const limitedKeys = keys.slice(0, SERIALIZE_LIMITS.maxEntries);
const entries = limitedKeys.map((k) => ({
key: k,
value: this.serializeValue(value[k], ctx, depth + 1),
}));
return {
kind: 'object',
name: name !== 'Object' ? name : undefined,
truncated: keys.length > limitedKeys.length,
entries,
};
} catch (err) {
return { kind: 'unknown', type: typeof value, preview: safeString(err) };
}
},
/**
* Serialize props object to structured format
* @param {object} props - Props object to serialize
* @param {Record<string, Array<string|number|boolean>>} [enumValuesByKey] - Optional enum values by prop key
*/
serializeProps(props, enumValuesByKey) {
const ctx = this.createContext();
const entries = [];
const enumMap = isObject(enumValuesByKey) ? enumValuesByKey : null;
if (!props || (typeof props !== 'object' && typeof props !== 'function')) {
return { kind: 'props', entries: [] };
}
const keys = Object.keys(props);
const limited = keys.slice(0, SERIALIZE_LIMITS.maxEntries);
for (const key of limited) {
let raw;
try {
raw = props[key];
} catch {
raw = undefined;
}
const entry = {
key,
editable: this.isEditablePrimitive(raw),
value: this.serializeValue(raw, ctx, 0),
};
// Attach enum values if available
const enumValues = enumMap ? enumMap[key] : null;
if (Array.isArray(enumValues) && enumValues.length > 0) {
entry.enumValues = enumValues.slice(0, EnumIntrospection.MAX_ENUM_VALUES);
}
entries.push(entry);
}
const result = { kind: 'props', entries };
if (keys.length > limited.length) result.truncated = true;
return result;
},
};
// =============================================================================
// Enum Introspection (Best-effort)
// =============================================================================
/**
* Best-effort enum value extraction from React/Vue runtime metadata.
*
* React: Relies on __docgenInfo (Storybook/react-docgen output)
* Vue: Relies on explicit values/validator.values in props options
*/
const EnumIntrospection = {
MAX_ENUM_VALUES: 50,
/**
* Normalize a raw enum value to primitive
*/
normalizeEnumValue(raw) {
if (raw === null || raw === undefined) return null;
if (typeof raw === 'boolean') return raw;
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null;
const s = safeString(raw).trim();
if (!s) return null;
// Strip surrounding quotes: "'primary'" -> "primary"
const m = s.match(/^(['"])(.*)\1$/);
const unquoted = m ? m[2] : s;
if (unquoted === 'true') return true;
if (unquoted === 'false') return false;
if (/^-?(?:\d+|\d*\.\d+)$/.test(unquoted)) {
const n = Number(unquoted);
if (Number.isFinite(n)) return n;
}
return unquoted;
},
/**
* Normalize array of enum values, deduplicate
*/
normalizeEnumList(list) {
if (!Array.isArray(list)) return [];
const out = [];
const seen = new Set();
for (const item of list) {
const v = this.normalizeEnumValue(item);
if (v === null) continue;
const key =
typeof v === 'string' ? `s:${v}` : typeof v === 'number' ? `n:${v}` : `b:${v ? 1 : 0}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(v);
if (out.length >= this.MAX_ENUM_VALUES) break;
}
return out;
},
/**
* Extract enum values from React docgen prop info
* (e.g., from Storybook's __docgenInfo)
*/
extractDocgenEnumValues(propInfo) {
if (!isObject(propInfo)) return [];
// Check type.name === 'enum' with type.value array
const t = propInfo.type;
if (isObject(t) && t.name === 'enum' && Array.isArray(t.value)) {
const rawList = t.value.map((item) =>
isObject(item) && 'value' in item ? item.value : item,
);
return this.normalizeEnumList(rawList);
}
// Check tsType for TypeScript enums
const ts = propInfo.tsType;
if (isObject(ts) && ts.name === 'union' && Array.isArray(ts.elements)) {
const rawList = ts.elements.map((el) =>
isObject(el) && 'value' in el ? el.value : el.name,
);
return this.normalizeEnumList(rawList);
}
return [];
},
/**
* Get enum values map for React component
*/
getReactEnumValues(componentFiber) {
try {
const type = componentFiber?.type || componentFiber?.elementType;
if (!type) return {};
const docgen = type.__docgenInfo;
if (!isObject(docgen) || !isObject(docgen.props)) return {};
const result = {};
for (const [key, info] of Object.entries(docgen.props)) {
const values = this.extractDocgenEnumValues(info);
if (values.length > 0) result[key] = values;
}
return result;
} catch {
return {};
}
},
/**
* Extract enum values from Vue prop option
*/
extractVuePropEnumValues(propOption) {
if (!isObject(propOption)) return [];
// Check explicit values array
if (Array.isArray(propOption.values)) {
return this.normalizeEnumList(propOption.values);
}
// Check validator with values/allowedValues
const validator = propOption.validator;
if (validator && Array.isArray(validator.values)) {
return this.normalizeEnumList(validator.values);
}
if (validator && Array.isArray(validator.allowedValues)) {
return this.normalizeEnumList(validator.allowedValues);
}
return [];
},
/**
* Get enum values map for Vue component
*/
getVueEnumValues(instance) {
try {
const propsOptions = instance?.type?.props;
if (!isObject(propsOptions)) return {};
const result = {};
for (const [key, opt] of Object.entries(propsOptions)) {
const values = this.extractVuePropEnumValues(opt);
if (values.length > 0) result[key] = values;
}
return result;
} catch {
return {};
}
},
};
// =============================================================================
// Value Access Helpers
// =============================================================================
function getValueAtPath(root, path) {
let current = root;
for (let i = 0; i < path.length; i++) {
const seg = path[i];
if (!isObject(current) && !Array.isArray(current)) {
return { ok: false, existed: false, value: undefined };
}
const has = Object.prototype.hasOwnProperty.call(current, seg);
current = current[seg];
if (!has && i === path.length - 1) {
return { ok: true, existed: false, value: undefined };
}
}
return { ok: true, existed: true, value: current };
}
// Dangerous keys that could cause prototype pollution or unexpected behavior
const DANGEROUS_KEYS = new Set([
'__proto__',
'constructor',
'prototype',
'__defineGetter__',
'__defineSetter__',
'__lookupGetter__',
'__lookupSetter__',
]);
function isDangerousKey(key) {
return typeof key === 'string' && DANGEROUS_KEYS.has(key);
}
function normalizePropPath(value) {
if (!Array.isArray(value) || value.length === 0 || value.length > 32) return null;
const result = [];
for (const seg of value) {
if (typeof seg === 'string') {
const s = seg.trim();
if (!s) return null;
// Reject dangerous keys to prevent prototype pollution
if (isDangerousKey(s)) return null;
result.push(s);
} else if (typeof seg === 'number' && Number.isInteger(seg) && seg >= 0 && seg <= 1e6) {
result.push(seg);
} else {
return null;
}
}
return result;
}
function decodeIncomingValue(raw) {
// Bridge encodes undefined as { $we: 'undefined' }
if (isObject(raw) && raw.$we === 'undefined') return undefined;
return raw;
}
// =============================================================================
// Capabilities Builder
// =============================================================================
function makeCapabilities(init) {
return {
canRead: Boolean(init?.canRead),
canWrite: Boolean(init?.canWrite),
canWriteHooks: Boolean(init?.canWriteHooks),
};
}
function buildResponseData(init) {
const data = {};
if (init?.hookStatus) data.hookStatus = init.hookStatus;
if (typeof init?.needsRefresh === 'boolean') data.needsRefresh = init.needsRefresh;
if (init?.framework) data.framework = init.framework;
if (init?.frameworkVersion) data.frameworkVersion = init.frameworkVersion;
if (init?.componentName) data.componentName = init.componentName;
if (init?.debugSource) data.debugSource = init.debugSource;
if (init?.props) data.props = init.props;
if (init?.capabilities) data.capabilities = init.capabilities;
if (init?.meta) data.meta = init.meta;
return data;
}
// =============================================================================
// Request Handlers
// =============================================================================
const Handlers = {
resolveTarget(locator) {
if (!locator) return null;
const el = Locator.locate(locator, document);
// Return element if connected to DOM; otherwise return null
return el?.isConnected ? el : null;
},
/**
* Handle 'probe' operation - Detect capabilities without reading props
*/
handleProbe(req) {
// Check initial hook status
const preStatus = ReactAdapter.detectStatus();
const initialHookStatus = preStatus.hookStatus;
// Try to install hook if missing (only helps if React hasn't initialized)
if (initialHookStatus === HOOK_STATUS.HOOK_MISSING) {
ReactAdapter.installMinimalHook();
}
const hookInfo = ReactAdapter.detectStatus();
// Report original status if hook was missing (so UI knows refresh is needed)
const hookStatus =
initialHookStatus === HOOK_STATUS.HOOK_MISSING
? HOOK_STATUS.HOOK_MISSING
: hookInfo.hookStatus;
const target = this.resolveTarget(req.locator);
const fw = target ? FrameworkDetector.detect(target) : { framework: 'unknown', data: null };
let componentName;
let debugSource;
let canRead = false;
let canWrite = false;
let needsRefresh = false;
let frameworkVersion;
if (fw.framework === 'react') {
const fiberInfo = ReactAdapter.resolveFiberWithRenderer(target, hookInfo);
const componentFiber = fiberInfo.fiber
? ReactAdapter.findNearestComponentFiber(fiberInfo.fiber)
: null;
componentName = componentFiber ? ReactAdapter.getComponentName(componentFiber) : undefined;
// Extract debug source from component fiber or raw fiber
const sourceFiber = componentFiber || fiberInfo.fiber;
debugSource = sourceFiber ? ReactAdapter.getDebugSource(sourceFiber) : undefined;
// Pass specific renderer to prioritize its version in multi-renderer scenarios
frameworkVersion = ReactAdapter.getVersion(hookInfo, fiberInfo.renderer);
canRead = Boolean(componentFiber);
canWrite = hookStatus === HOOK_STATUS.READY && Boolean(componentFiber);
needsRefresh = canRead && hookStatus !== HOOK_STATUS.READY;
} else if (fw.framework === 'vue') {
const instance = fw.data;
componentName = VueAdapter.getComponentName(instance);
debugSource = instance ? VueAdapter.getDebugSource(instance, target) : undefined;
frameworkVersion = VueAdapter.getVersion(instance);
canRead = Boolean(instance);
canWrite = Boolean(instance) && VueAdapter.isDevBuild(instance);
needsRefresh = false;
}
const data = buildResponseData({
hookStatus,
framework: fw.framework,
frameworkVersion,
componentName,
debugSource,
capabilities: makeCapabilities({ canRead, canWrite, canWriteHooks: false }),
needsRefresh,
});
return Transport.createResponse(req.requestId, true, data);
},
/**
* Handle 'read' operation - Read component props
*/
handleRead(req) {
const target = this.resolveTarget(req.locator);
if (!target) {
return Transport.createResponse(
req.requestId,
false,
undefined,
'Target element not found',
);
}
const preStatus = ReactAdapter.detectStatus();
if (preStatus.hookStatus === HOOK_STATUS.HOOK_MISSING) {
ReactAdapter.installMinimalHook();
}
const hookInfo = ReactAdapter.detectStatus();
const hookStatus =
preStatus.hookStatus === HOOK_STATUS.HOOK_MISSING
? HOOK_STATUS.HOOK_MISSING
: hookInfo.hookStatus;
const fw = FrameworkDetector.detect(target);
if (fw.framework === 'react') {
const fiberInfo = ReactAdapter.resolveFiberWithRenderer(target, hookInfo);
const componentFiber = fiberInfo.fiber
? ReactAdapter.findNearestComponentFiber(fiberInfo.fiber)
: null;
// Extract debug source even if component fiber not found
const sourceFiber = componentFiber || fiberInfo.fiber;
const debugSource = sourceFiber ? ReactAdapter.getDebugSource(sourceFiber) : undefined;
// Pass specific renderer to prioritize its version in multi-renderer scenarios
const frameworkVersion = ReactAdapter.getVersion(hookInfo, fiberInfo.renderer);
if (!componentFiber) {
const data = buildResponseData({
hookStatus,
framework: 'react',
frameworkVersion,
debugSource,
capabilities: makeCapabilities({ canRead: false, canWrite: false }),
needsRefresh: false,
});
return Transport.createResponse(
req.requestId,
false,
data,
'React component fiber not found',
);
}
const props = componentFiber.memoizedProps;
const enumValuesByKey = EnumIntrospection.getReactEnumValues(componentFiber);
const serialized = Serializer.serializeProps(props, enumValuesByKey);
const componentName = ReactAdapter.getComponentName(componentFiber);
const canWrite = hookStatus === HOOK_STATUS.READY;
const needsRefresh = hookStatus !== HOOK_STATUS.READY;
const data = buildResponseData({
hookStatus,
framework: 'react',
frameworkVersion,
componentName,
debugSource,
props: serialized,
capabilities: makeCapabilities({ canRead: true, canWrite, canWriteHooks: false }),
needsRefresh,
});
return Transport.createResponse(req.requestId, true, data);
}
if (fw.framework === 'vue') {
const instance = fw.data;
const frameworkVersion = VueAdapter.getVersion(instance);
if (!instance) {
const data = buildResponseData({
hookStatus,
framework: 'vue',
frameworkVersion,
capabilities: makeCapabilities({ canRead: false, canWrite: false }),
needsRefresh: false,
});
return Transport.createResponse(
req.requestId,
false,
data,
'Vue component instance not found',
);
}
const componentName = VueAdapter.getComponentName(instance);
const debugSource = VueAdapter.getDebugSource(instance, target);
// Read both props and attrs
let rootProps = null;
let rootAttrs = null;
try {
rootProps = instance.props;
} catch {
rootProps = null;
}
try {
rootAttrs = instance.attrs;
} catch {
rootAttrs = null;
}
// Serialize props with enum introspection
const enumValuesByKey = EnumIntrospection.getVueEnumValues(instance);
const serializedProps = Serializer.serializeProps(rootProps, enumValuesByKey);
const serializedAttrs = Serializer.serializeProps(rootAttrs, null);
// Merge entries with source annotation
const mergedEntries = [];
if (Array.isArray(serializedProps.entries)) {
for (const entry of serializedProps.entries) {
mergedEntries.push({ ...entry, source: 'props' });
}
}
if (Array.isArray(serializedAttrs.entries)) {
for (const entry of serializedAttrs.entries) {
mergedEntries.push({ ...entry, source: 'attrs' });
}
}
const serialized = {
kind: 'props',
entries: mergedEntries,
};
if (serializedProps.truncated || serializedAttrs.truncated) {
serialized.truncated = true;
}
const canWrite = VueAdapter.isDevBuild(instance);
const data = buildResponseData({
hookStatus,
framework: 'vue',
frameworkVersion,
componentName,
debugSource,
props: serialized,
capabilities: makeCapabilities({ canRead: true, canWrite, canWriteHooks: false }),
needsRefresh: false,
});
return Transport.createResponse(req.requestId, true, data);
}
// Unknown framework
const data = buildResponseData({
hookStatus,
framework: 'unknown',
capabilities: makeCapabilities({ canRead: false, canWrite: false }),
needsRefresh: false,
});
return Transport.createResponse(req.requestId, false, data, 'Not a React/Vue component');
},
/**
* Handle 'write' operation - Modify component props
*/
handleWrite(req) {
const target = this.resolveTarget(req.locator);
if (!target) {
return Transport.createResponse(
req.requestId,
false,
undefined,
'Target element not found',
);
}
const path = normalizePropPath(req.payload?.propPath);
if (!path) {
return Transport.createResponse(req.requestId, false, undefined, 'Invalid propPath');
}
const rawValue = req.payload?.propValue;
const value = decodeIncomingValue(rawValue);
if (!Serializer.isEditablePrimitive(value)) {
return Transport.createResponse(
req.requestId,
false,
undefined,
'Only primitive prop values are supported',
);
}
const preStatus = ReactAdapter.detectStatus();
if (preStatus.hookStatus === HOOK_STATUS.HOOK_MISSING) {
ReactAdapter.installMinimalHook();
}
const hookInfo = ReactAdapter.detectStatus();
const hookStatus =
preStatus.hookStatus === HOOK_STATUS.HOOK_MISSING
? HOOK_STATUS.HOOK_MISSING
: hookInfo.hookStatus;
const fw = FrameworkDetector.detect(target);
if (fw.framework === 'react') {
const fiberInfo = ReactAdapter.resolveFiberWithRenderer(target, hookInfo);
const componentFiber = fiberInfo.fiber
? ReactAdapter.findNearestComponentFiber(fiberInfo.fiber)
: null;
const componentName = componentFiber
? ReactAdapter.getComponentName(componentFiber)
: undefined;
const canRead = Boolean(componentFiber);
const canWrite = hookStatus === HOOK_STATUS.READY && Boolean(componentFiber);
const needsRefresh = canRead && hookStatus !== HOOK_STATUS.READY;
const base = buildResponseData({
hookStatus,
framework: 'react',
componentName,
capabilities: makeCapabilities({ canRead, canWrite, canWriteHooks: false }),
needsRefresh,
});
if (!componentFiber) {
return Transport.createResponse(
req.requestId,
false,
base,
'React component fiber not found',
);
}
if (hookStatus !== HOOK_STATUS.READY) {
return Transport.createResponse(
req.requestId,
false,
base,
'React DevTools editing API unavailable. Use a Development build and refresh the page.',
);
}
// Check current value for editability and record original
const props = componentFiber.memoizedProps;
const read = getValueAtPath(props, path);
if (read.ok && read.existed && !Serializer.isEditablePrimitive(read.value)) {
return Transport.createResponse(
req.requestId,
false,
base,
'Target prop is not a primitive (read-only)',
);
}
// Try renderers with overrideProps
const candidates = (hookInfo.editableRenderers || [])
.map((r) => r.renderer)
.filter(Boolean);
const preferred =
fiberInfo.renderer && typeof fiberInfo.renderer.overrideProps === 'function'
? fiberInfo.renderer
: null;
const ordered = preferred
? [preferred, ...candidates.filter((r) => r !== preferred)]
: candidates;
let usedRenderer = null;
let lastErr = null;
for (const renderer of ordered) {
try {
renderer.overrideProps(componentFiber, path, value);
usedRenderer = renderer;
break;
} catch (err) {
lastErr = err;
}
}
if (!usedRenderer) {
base.meta = { write: { method: 'overrideProps', error: safeString(lastErr) } };
return Transport.createResponse(
req.requestId,
false,
base,
'Failed to write props via overrideProps',
);
}
ReactAdapter.recordOriginal(componentFiber, usedRenderer, path, read.existed, read.value);
base.meta = { write: { method: 'overrideProps' } };
return Transport.createResponse(req.requestId, true, base);
}
if (fw.framework === 'vue') {
const instance = fw.data;
const componentName = VueAdapter.getComponentName(instance);
const canRead = Boolean(instance);
const canWrite = Boolean(instance) && VueAdapter.isDevBuild(instance);
const base = buildResponseData({
hookStatus,
framework: 'vue',
componentName,
capabilities: makeCapabilities({ canRead, canWrite, canWriteHooks: false }),
needsRefresh: false,
});
if (!instance) {
return Transport.createResponse(
req.requestId,
false,
base,
'Vue component instance not found',
);
}
if (!VueAdapter.isDevBuild(instance)) {
return Transport.createResponse(
req.requestId,
false,
base,
'Vue dev metadata missing. Use a Development build.',
);
}
// Vue props keys must be strings at top level
if (typeof path[0] !== 'string') {
return Transport.createResponse(
req.requestId,
false,
base,
'Vue propPath must start with a string key',
);
}
const propName = path[0];
const subPath = path.slice(1);
// Infer target kind based on whether key is declared prop
const targetKind = VueAdapter.isDeclaredProp(instance, propName) ? 'props' : 'attrs';
// Check current value from logical root
const readRoot = VueAdapter.getReadRoot(instance, targetKind) || {};
const read = getValueAtPath(readRoot, path);
if (read.ok && read.existed && !Serializer.isEditablePrimitive(read.value)) {
return Transport.createResponse(
req.requestId,
false,
base,
'Target prop is not a primitive (read-only)',
);
}
// Build next vnode props (the correct way to update Vue3 props)
const currentRawProps = VueAdapter.getVNodeProps(instance) || {};
const nextRawProps = { ...currentRawProps };
try {
if (subPath.length === 0) {
nextRawProps[propName] = value;
} else {
const prev = nextRawProps[propName];
nextRawProps[propName] = VueAdapter.copyWithSet(prev, subPath, value);
}
} catch (err) {
base.meta = {
write: { method: 'vueNextVNode', target: targetKind, error: safeString(err) },
};
return Transport.createResponse(
req.requestId,
false,
base,
'Failed to build Vue props patch',
);
}
// Apply via instance.next + update() to trigger Vue's internal updateProps pipeline
if (!VueAdapter.applyNextProps(instance, nextRawProps)) {
base.meta = {
write: { method: 'vueNextVNode', target: targetKind, error: 'No update method' },
};
return Transport.createResponse(
req.requestId,
false,
base,
'Vue update method not available',
);
}
// Record original for reset only after successful write (include targetKind for accurate reset)
VueAdapter.recordOriginal(instance, path, read.existed, read.value, targetKind);
base.meta = { write: { method: 'vueNextVNode', target: targetKind } };
return Transport.createResponse(req.requestId, true, base);
}
return Transport.createResponse(req.requestId, false, undefined, 'Not a React/Vue component');
},
/**
* Handle 'reset' operation - Restore original props values
*/
handleReset(req) {
const target = this.resolveTarget(req.locator);
if (!target) {
return Transport.createResponse(
req.requestId,
false,
undefined,
'Target element not found',
);
}
const preStatus = ReactAdapter.detectStatus();
if (preStatus.hookStatus === HOOK_STATUS.HOOK_MISSING) {
ReactAdapter.installMinimalHook();
}
const hookInfo = ReactAdapter.detectStatus();
const hookStatus =
preStatus.hookStatus === HOOK_STATUS.HOOK_MISSING
? HOOK_STATUS.HOOK_MISSING
: hookInfo.hookStatus;
const fw = FrameworkDetector.detect(target);
if (fw.framework === 'react') {
const fiberInfo = ReactAdapter.resolveFiberWithRenderer(target, hookInfo);
const componentFiber = fiberInfo.fiber
? ReactAdapter.findNearestComponentFiber(fiberInfo.fiber)
: null;
const componentName = componentFiber
? ReactAdapter.getComponentName(componentFiber)
: undefined;
const canRead = Boolean(componentFiber);
const canWrite = hookStatus === HOOK_STATUS.READY && Boolean(componentFiber);
const needsRefresh = canRead && hookStatus !== HOOK_STATUS.READY;
const base = buildResponseData({
hookStatus,
framework: 'react',
componentName,
capabilities: makeCapabilities({ canRead, canWrite, canWriteHooks: false }),
needsRefresh,
});
if (!componentFiber) {
return Transport.createResponse(
req.requestId,
false,
base,
'React component fiber not found',
);
}
const store = ReactAdapter.getOriginals(componentFiber);
if (!store?.originals?.size) {
base.meta = { reset: { method: 'refresh', reason: 'noOverrides' } };
base.needsRefresh = true;
return Transport.createResponse(req.requestId, true, base);
}
if (hookStatus !== HOOK_STATUS.READY) {
base.meta = { reset: { method: 'refresh', reason: 'hookNotReady' } };
base.needsRefresh = true;
return Transport.createResponse(req.requestId, true, base);
}
const renderer = store.renderer;
if (!renderer || typeof renderer.overrideProps !== 'function') {
base.meta = { reset: { method: 'refresh', reason: 'missingRenderer' } };
base.needsRefresh = true;
return Transport.createResponse(req.requestId, true, base);
}
let reverted = 0;
for (const entry of store.originals.values()) {
try {
renderer.overrideProps(componentFiber, entry.path, entry.value);
reverted++;
} catch {
// Continue reverting others
}
}
ReactAdapter.clearOriginals(componentFiber);
base.meta = { reset: { method: 'overrideProps', reverted } };
return Transport.createResponse(req.requestId, true, base);
}
if (fw.framework === 'vue') {
const instance = fw.data;
const componentName = VueAdapter.getComponentName(instance);
const canRead = Boolean(instance);
const canWrite = Boolean(instance) && VueAdapter.isDevBuild(instance);
const base = buildResponseData({
hookStatus,
framework: 'vue',
componentName,
capabilities: makeCapabilities({ canRead, canWrite, canWriteHooks: false }),
needsRefresh: false,
});
if (!instance) {
return Transport.createResponse(
req.requestId,
false,
base,
'Vue component instance not found',
);
}
const store = VueAdapter.getOriginals(instance);
if (!store?.size) {
base.meta = { reset: { method: 'refresh', reason: 'noOverrides' } };
base.needsRefresh = true;
return Transport.createResponse(req.requestId, true, base);
}
// Build next vnode props with all originals restored
const currentRawProps = VueAdapter.getVNodeProps(instance) || {};
const nextRawProps = { ...currentRawProps };
let reverted = 0;
for (const entry of store.values()) {
const path = entry.path;
if (!Array.isArray(path) || typeof path[0] !== 'string') continue;
const propName = path[0];
const subPath = path.slice(1);
try {
if (subPath.length === 0) {
if (entry.existed) {
nextRawProps[propName] = entry.value;
} else {
delete nextRawProps[propName];
}
} else {
const prev = nextRawProps[propName];
nextRawProps[propName] = VueAdapter.copyWithSet(prev, subPath, entry.value);
}
reverted++;
} catch {
// Continue with other entries
}
}
// Apply via instance.next + update() to trigger Vue's internal updateProps pipeline
if (!VueAdapter.applyNextProps(instance, nextRawProps)) {
base.meta = { reset: { method: 'refresh', reason: 'noUpdate' } };
base.needsRefresh = true;
return Transport.createResponse(req.requestId, true, base);
}
VueAdapter.clearOriginals(instance);
base.meta = { reset: { method: 'vueNextVNode', reverted } };
return Transport.createResponse(req.requestId, true, base);
}
return Transport.createResponse(req.requestId, false, undefined, 'Not a React/Vue component');
},
/**
* Handle 'cleanup' operation - Dispose agent
*/
handleCleanup(req) {
const resp = Transport.createResponse(req.requestId, true, {
meta: { cleanup: { ok: true } },
});
Lifecycle.dispose('request');
return resp;
},
/**
* Route request to appropriate handler
*/
handle(req) {
switch (req.op) {
case 'probe':
return this.handleProbe(req);
case 'read':
return this.handleRead(req);
case 'write':
return this.handleWrite(req);
case 'reset':
return this.handleReset(req);
case 'cleanup':
return this.handleCleanup(req);
default:
return Transport.createResponse(
req.requestId,
false,
undefined,
`Unsupported op: ${safeString(req.op)}`,
);
}
},
};
// =============================================================================
// Lifecycle Management
// =============================================================================
const Lifecycle = {
disposed: false,
onRequestEvent(event) {
try {
if (Lifecycle.disposed) return;
const detail = event?.detail;
const req = Transport.normalizeRequest(detail);
if (!req) return;
const resp = Handlers.handle(req);
Transport.dispatchResponse(resp);
} catch (err) {
try {
const requestId = event?.detail?.requestId;
if (typeof requestId === 'string' && requestId) {
Transport.dispatchResponse(
Transport.createResponse(requestId, false, undefined, safeString(err)),
);
}
} catch {
// ignore
}
}
},
onCleanupEvent() {
Lifecycle.dispose('external-event');
},
dispose(reason) {
if (this.disposed) return;
this.disposed = true;
try {
window.removeEventListener(EVENT_NAME.REQUEST, this.onRequestEvent, true);
window.removeEventListener(EVENT_NAME.CLEANUP, this.onCleanupEvent, true);
} catch {
// ignore
}
try {
delete window[GLOBAL_KEY];
} catch {
// ignore
}
if (reason) {
logWarn('Disposed:', reason);
}
},
init() {
// Use capture phase to avoid page stopPropagation interfering
window.addEventListener(EVENT_NAME.REQUEST, this.onRequestEvent, true);
window.addEventListener(EVENT_NAME.CLEANUP, this.onCleanupEvent, true);
window[GLOBAL_KEY] = {
version: PROTOCOL_VERSION,
dispose: () => this.dispose('manual'),
};
// Early injection: install minimal hook before React loads (document_start)
// This is critical for capturing React renderers that initialize early
if (document.readyState === 'loading') {
try {
const status = ReactAdapter.detectStatus();
if (status.hookStatus === HOOK_STATUS.HOOK_MISSING) {
ReactAdapter.installMinimalHook();
logWarn('Installed minimal hook during early injection');
}
} catch (err) {
// Best-effort: early injection may fail in some environments
logWarn('Early hook injection failed:', err);
}
}
},
};
// Initialize
Lifecycle.init();
})();