hangwin--mcp-chrome
2394 行
72 KiB
JavaScript
2394 行
72 KiB
JavaScript
/* 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();
|
|
})();
|