@yusufffararatt/dombridge-mcp 2.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +559 -0
- package/bin/cli.js +88 -0
- package/package.json +54 -0
- package/src/bridge/http-server.js +290 -0
- package/src/bridge/middleware.js +56 -0
- package/src/bridge/routes.js +1003 -0
- package/src/bridge-daemon.js +172 -0
- package/src/cli/auto-config.js +120 -0
- package/src/constants.js +13 -0
- package/src/index.js +279 -0
- package/src/mcp-bridge.js +136 -0
- package/src/metrics/error-codes.js +44 -0
- package/src/metrics/index.js +3 -0
- package/src/metrics/metrics-db.js +269 -0
- package/src/metrics/metrics-recorder.js +240 -0
- package/src/metrics/metrics-report.js +146 -0
- package/src/profiles/profile-db.js +159 -0
- package/src/profiles/profile-enricher.js +333 -0
- package/src/profiles/profile-manager.js +563 -0
- package/src/profiles/profile-repo.js +183 -0
- package/src/state/bridge-client.js +272 -0
- package/src/state/bridge-persistence.js +205 -0
- package/src/state/cache.js +38 -0
- package/src/state/extension-state.js +321 -0
- package/src/tools/action_tools.js +218 -0
- package/src/tools/analyze-page.js +247 -0
- package/src/tools/debug-mcp-state.js +172 -0
- package/src/tools/discover-apis.js +186 -0
- package/src/tools/execute-js.js +284 -0
- package/src/tools/export-session.js +171 -0
- package/src/tools/extract-data.js +395 -0
- package/src/tools/get-element.js +281 -0
- package/src/tools/get-network-trace.js +471 -0
- package/src/tools/index.js +110 -0
- package/src/tools/manage-site-profile.js +153 -0
- package/src/tools/paginate.js +444 -0
- package/src/tools/quick-scan.js +418 -0
- package/src/tools/screenshot_tools.js +117 -0
- package/src/utils/circuit-breaker.js +112 -0
- package/src/utils/extract-density.js +21 -0
- package/src/utils/logger.js +31 -0
- package/src/utils/paginate-detector.js +24 -0
- package/src/utils/rate-limiter.js +244 -0
- package/src/utils/run-script.js +37 -0
- package/src/utils/selector-validator.js +95 -0
- package/src/utils/state-validator.js +354 -0
- package/src/utils/tab-resolver.js +70 -0
- package/src/utils/workflow-helper.js +292 -0
- package/src/utils/workflow-state.js +177 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProfileRepo — domain operations on top of ProfileDB.
|
|
3
|
+
* Handles BUG #1 fix (hostname filter) and BUG #2 fix (real endpoint persistence).
|
|
4
|
+
*/
|
|
5
|
+
import { ProfileDB } from './profile-db.js';
|
|
6
|
+
import { logger } from '../utils/logger.js';
|
|
7
|
+
|
|
8
|
+
function hostnameOf(url) {
|
|
9
|
+
try {
|
|
10
|
+
return new URL(url).hostname.toLowerCase();
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isAllowedHostname(host, domain) {
|
|
17
|
+
if (!host || !domain) return false;
|
|
18
|
+
const d = domain.toLowerCase();
|
|
19
|
+
// Exact match OR subdomain match
|
|
20
|
+
if (host === d || host.endsWith('.' + d)) return true;
|
|
21
|
+
// Bug #6 fix: also accept sibling subdomains that share the same root
|
|
22
|
+
// (e.g. profile='www.trendyol.com' accepts 'apigw.trendyol.com' since both share 'trendyol.com' root)
|
|
23
|
+
const getRootDomain = (h) => {
|
|
24
|
+
const parts = h.split('.');
|
|
25
|
+
if (parts.length < 2) return h;
|
|
26
|
+
// last 2 parts = root (handles .com, .co.uk via simple heuristic)
|
|
27
|
+
return parts.slice(-2).join('.');
|
|
28
|
+
};
|
|
29
|
+
return getRootDomain(host) === getRootDomain(d);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class ProfileRepo {
|
|
33
|
+
constructor(dbPath) {
|
|
34
|
+
this.db = new ProfileDB(dbPath);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
close() {
|
|
38
|
+
this.db.close();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── save (with BUG #1 hostname filter) ───────────────────────────
|
|
42
|
+
save(domain, { endpoints = [], notes, framework, pageType } = {}) {
|
|
43
|
+
const dropped = [];
|
|
44
|
+
let inserted = 0;
|
|
45
|
+
|
|
46
|
+
// BUG #2 fix: actually persist endpoints, not just notes
|
|
47
|
+
this.db.upsertProfile({ domain, framework, pageType, notes });
|
|
48
|
+
|
|
49
|
+
for (const ep of endpoints) {
|
|
50
|
+
if (!ep || !ep.url) continue;
|
|
51
|
+
const host = hostnameOf(ep.url);
|
|
52
|
+
if (!isAllowedHostname(host, domain)) {
|
|
53
|
+
dropped.push({ url: ep.url, reason: `host=${host} != domain=${domain}` });
|
|
54
|
+
logger.warn(`[profile] dropped ${ep.url} (host=${host} != domain=${domain})`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
this.db.upsertEndpoint(domain, ep);
|
|
58
|
+
inserted++;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { inserted, dropped };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── load ─────────────────────────────────────────────────────────
|
|
65
|
+
load(domain) {
|
|
66
|
+
const profile = this.db.getProfile(domain);
|
|
67
|
+
if (!profile) return null;
|
|
68
|
+
|
|
69
|
+
const endpoints = this.db.db.prepare(`
|
|
70
|
+
SELECT method, url, status, content_type AS contentType,
|
|
71
|
+
first_seen_at AS firstSeenAt, last_seen_at AS lastSeenAt, hit_count AS hitCount
|
|
72
|
+
FROM endpoints WHERE domain = ? ORDER BY last_seen_at DESC
|
|
73
|
+
`).all(domain);
|
|
74
|
+
|
|
75
|
+
const paths = this.db.db.prepare(`
|
|
76
|
+
SELECT path, source_key AS sourceKey, example_value AS exampleValue
|
|
77
|
+
FROM paths WHERE domain = ?
|
|
78
|
+
`).all(domain);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
domain: profile.domain,
|
|
82
|
+
framework: profile.framework,
|
|
83
|
+
pageType: profile.page_type,
|
|
84
|
+
notes: profile.notes,
|
|
85
|
+
version: profile.version,
|
|
86
|
+
firstSeenAt: profile.first_seen_at,
|
|
87
|
+
lastSeenAt: profile.last_seen_at,
|
|
88
|
+
endpoints,
|
|
89
|
+
paths
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── list ─────────────────────────────────────────────────────────
|
|
94
|
+
list({ limit = 20 } = {}) {
|
|
95
|
+
const rows = this.db.db.prepare(`
|
|
96
|
+
SELECT p.domain, p.framework, p.last_seen_at AS lastSeenAt,
|
|
97
|
+
(SELECT COUNT(*) FROM endpoints e WHERE e.domain = p.domain) AS endpointCount,
|
|
98
|
+
(SELECT COUNT(*) FROM paths pa WHERE pa.domain = p.domain) AS pathCount
|
|
99
|
+
FROM site_profiles p
|
|
100
|
+
ORDER BY p.last_seen_at DESC
|
|
101
|
+
LIMIT ?
|
|
102
|
+
`).all(limit);
|
|
103
|
+
return rows;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── delete ───────────────────────────────────────────────────────
|
|
107
|
+
delete(domain) {
|
|
108
|
+
const existing = this.db.getProfile(domain);
|
|
109
|
+
if (!existing) return { deleted: false };
|
|
110
|
+
this.db.deleteProfile(domain);
|
|
111
|
+
return { deleted: true };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── update (partial patch) ───────────────────────────────────────
|
|
115
|
+
update(domain, patch) {
|
|
116
|
+
const existing = this.db.getProfile(domain);
|
|
117
|
+
if (!existing) return { updated: false };
|
|
118
|
+
|
|
119
|
+
const sets = [];
|
|
120
|
+
const args = [];
|
|
121
|
+
if (patch.notes !== undefined) { sets.push('notes = ?'); args.push(patch.notes); }
|
|
122
|
+
if (patch.framework !== undefined) { sets.push('framework = ?'); args.push(patch.framework); }
|
|
123
|
+
if (patch.pageType !== undefined) { sets.push('page_type = ?'); args.push(patch.pageType); }
|
|
124
|
+
if (patch.stableSelectorsJson !== undefined) { sets.push('stable_selectors_json = ?'); args.push(patch.stableSelectorsJson); }
|
|
125
|
+
if (patch.authInfoJson !== undefined) { sets.push('auth_info_json = ?'); args.push(patch.authInfoJson); }
|
|
126
|
+
sets.push('last_seen_at = ?'); args.push(Date.now());
|
|
127
|
+
sets.push('version = version + 1');
|
|
128
|
+
args.push(domain);
|
|
129
|
+
|
|
130
|
+
this.db.db.prepare(
|
|
131
|
+
`UPDATE site_profiles SET ${sets.join(', ')} WHERE domain = ?`
|
|
132
|
+
).run(...args);
|
|
133
|
+
|
|
134
|
+
// Bug #7 fix: support incremental endpoint upsert via update() call
|
|
135
|
+
let endpointsInserted = 0;
|
|
136
|
+
if (Array.isArray(patch.endpoints) && patch.endpoints.length > 0) {
|
|
137
|
+
for (const ep of patch.endpoints) {
|
|
138
|
+
if (!ep || !ep.url) continue;
|
|
139
|
+
this.db.upsertEndpoint(domain, ep);
|
|
140
|
+
endpointsInserted++;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { updated: true, endpointsInserted };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── check (drift detection) ──────────────────────────────────────
|
|
148
|
+
check(domain, liveCapture = []) {
|
|
149
|
+
const loaded = this.load(domain);
|
|
150
|
+
if (!loaded) return { error: 'profile_not_found', domain };
|
|
151
|
+
|
|
152
|
+
const knownKeys = new Set(loaded.endpoints.map(e => `${e.method}:${hostnameOf(e.url)}${new URL(e.url).pathname}`));
|
|
153
|
+
const liveKeys = new Set();
|
|
154
|
+
|
|
155
|
+
const newEndpoints = [];
|
|
156
|
+
for (const ep of liveCapture) {
|
|
157
|
+
if (!ep?.url) continue;
|
|
158
|
+
const key = `${(ep.method || 'GET').toUpperCase()}:${hostnameOf(ep.url)}${(new URL(ep.url).pathname)}`;
|
|
159
|
+
liveKeys.add(key);
|
|
160
|
+
if (!knownKeys.has(key)) {
|
|
161
|
+
newEndpoints.push({ url: ep.url, method: ep.method, status: ep.status });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const missing = loaded.endpoints
|
|
166
|
+
.filter(e => !liveKeys.has(`${e.method}:${hostnameOf(e.url)}${new URL(e.url).pathname}`))
|
|
167
|
+
.map(e => ({ url: e.url, method: e.method, status: e.status, lastSeenAt: e.lastSeenAt }));
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
domain,
|
|
171
|
+
knownEndpointCount: loaded.endpoints.length,
|
|
172
|
+
newCount: newEndpoints.length,
|
|
173
|
+
missingCount: missing.length,
|
|
174
|
+
new: newEndpoints,
|
|
175
|
+
missing
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── upsertPath (exposed for extract-data flow) ───────────────────
|
|
180
|
+
upsertPath(domain, { path, sourceKey, exampleValue }) {
|
|
181
|
+
this.db.upsertPath(domain, { path, sourceKey, exampleValue });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge Client — HTTP client for bridge daemon communication
|
|
3
|
+
*
|
|
4
|
+
* Phase 2.3: MCP thin client uses this instead of direct extensionData access.
|
|
5
|
+
* All state reads go through HTTP GET endpoints.
|
|
6
|
+
* All request/result operations go through HTTP POST/GET endpoints.
|
|
7
|
+
*
|
|
8
|
+
* Architecture:
|
|
9
|
+
* MCP Client (index.js) ←→ BridgeClient ←→ Bridge Daemon (bridge-daemon.js)
|
|
10
|
+
* ↕
|
|
11
|
+
* Extension (Chrome)
|
|
12
|
+
*
|
|
13
|
+
* Property getters mirror extensionData fields for drop-in replacement in tool handlers.
|
|
14
|
+
* State is cached from /api/state and refreshed before each tool call.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const DEFAULT_PORT = 3101;
|
|
18
|
+
const DEFAULT_POLL_INTERVAL = 300;
|
|
19
|
+
const DEFAULT_REQUEST_TIMEOUT = 10000;
|
|
20
|
+
const FETCH_TIMEOUT = 5000;
|
|
21
|
+
|
|
22
|
+
export class BridgeClient {
|
|
23
|
+
constructor(port = DEFAULT_PORT) {
|
|
24
|
+
this.port = port;
|
|
25
|
+
this.baseUrl = `http://localhost:${port}`;
|
|
26
|
+
this._state = {};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// State refresh — fetch all state from bridge daemon
|
|
31
|
+
// ============================================================================
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Refresh cached state from bridge daemon.
|
|
35
|
+
* Called before each tool invocation so that tool handlers
|
|
36
|
+
* can access fresh state via synchronous property getters.
|
|
37
|
+
*/
|
|
38
|
+
async refreshState() {
|
|
39
|
+
try {
|
|
40
|
+
const res = await this._fetch('/api/state');
|
|
41
|
+
this._state = await res.json();
|
|
42
|
+
} catch (err) {
|
|
43
|
+
throw new Error(`Bridge not available at ${this.baseUrl}: ${err.message}`);
|
|
44
|
+
}
|
|
45
|
+
return this._state;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Synchronous property access (from cached state)
|
|
50
|
+
// These mirror extensionData properties for drop-in replacement.
|
|
51
|
+
// ============================================================================
|
|
52
|
+
|
|
53
|
+
// Connection & status
|
|
54
|
+
get isConnected() { return this._state.isConnected || false; }
|
|
55
|
+
get activeTabUrl() { return this._state.activeTabUrl || ''; }
|
|
56
|
+
get pendingNavigation() { return this._state.pendingNavigation || false; }
|
|
57
|
+
get lastUpdateTime() { return this._state.lastUpdateTime || null; }
|
|
58
|
+
get currentSessionId() { return this._state.currentSessionId || null; }
|
|
59
|
+
get sessionStartedAt() { return this._state.sessionStartedAt || null; }
|
|
60
|
+
get _connectionHealth() { return this._state._connectionHealth || null; }
|
|
61
|
+
|
|
62
|
+
// Data fields
|
|
63
|
+
get selectedElement() { return this._state.selectedElement || null; }
|
|
64
|
+
get pageAnalysis() { return this._state.pageAnalysis || null; }
|
|
65
|
+
get networkTrace() { return this._state.networkTrace || null; }
|
|
66
|
+
get websocketTrace() { return this._state.websocketTrace || null; }
|
|
67
|
+
get websocketConnections() { return this._state.websocketConnections || null; }
|
|
68
|
+
get savedSelections() { return this._state.savedSelections || []; }
|
|
69
|
+
|
|
70
|
+
// Request fields (for validation — e.g. validateNoPendingExecution)
|
|
71
|
+
get jsExecutionRequest() { return this._state.jsExecutionRequest || null; }
|
|
72
|
+
get jsExecutionResult() { return this._state.jsExecutionResult || null; }
|
|
73
|
+
get actionExecutionRequest() { return this._state.actionExecutionRequest || null; }
|
|
74
|
+
get actionExecutionResult() { return this._state.actionExecutionResult || null; }
|
|
75
|
+
get selectElementRequest() { return this._state.selectElementRequest || null; }
|
|
76
|
+
get selectElementResult() { return this._state.selectElementResult || null; }
|
|
77
|
+
get captureScreenshotRequest() { return this._state.captureScreenshotRequest || null; }
|
|
78
|
+
get captureScreenshotResult() { return this._state.captureScreenshotResult || null; }
|
|
79
|
+
get exportSessionRequest() { return this._state.exportSessionRequest || null; }
|
|
80
|
+
get exportSessionResult() { return this._state.exportSessionResult || null; }
|
|
81
|
+
get tabsRequest() { return this._state.tabsRequest || null; }
|
|
82
|
+
get tabsResult() { return this._state.tabsResult || null; }
|
|
83
|
+
get analyzePageRequests() { return this._state.analyzePageRequests || []; }
|
|
84
|
+
get analyzePageResults() { return this._state.analyzePageResults || {}; }
|
|
85
|
+
get rawNetworkRequests() { return this._state.rawNetworkRequests || []; }
|
|
86
|
+
get rawNetworkResults() { return this._state.rawNetworkResults || {}; }
|
|
87
|
+
|
|
88
|
+
// Profile & insight tracking
|
|
89
|
+
get insightOpportunities() { return this._state.insightOpportunities || {}; }
|
|
90
|
+
get profileSaves() { return this._state.profileSaves || {}; }
|
|
91
|
+
|
|
92
|
+
// Captured API endpoints from discover_apis (NEW — for manage_site_profile save flow)
|
|
93
|
+
get apiEndpoints() { return this._state.apiEndpoints || []; }
|
|
94
|
+
getCapturedEndpoints(domain) {
|
|
95
|
+
if (!domain) return this._state.apiEndpoints || [];
|
|
96
|
+
return (this._state.apiEndpoints || []).filter((e) => e.domain === domain);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Push a captured endpoint to the bridge daemon's in-memory state.
|
|
101
|
+
* Bridge dedupes by (domain, method, url). Fire-and-forget; failure
|
|
102
|
+
* does not break the calling tool.
|
|
103
|
+
*
|
|
104
|
+
* Returns the inserted entry so callers can update their local cache.
|
|
105
|
+
*/
|
|
106
|
+
async addCapturedEndpoint(domain, endpoint) {
|
|
107
|
+
if (!domain || !endpoint || !endpoint.url) return null;
|
|
108
|
+
const res = await this.post('captured-endpoint', { domain, ...endpoint });
|
|
109
|
+
// Optimistically reflect the new entry in our local cache so a
|
|
110
|
+
// subsequent getCapturedEndpoints() within the same process call
|
|
111
|
+
// (e.g. manage_site_profile after discover_apis) sees it without
|
|
112
|
+
// requiring a full refreshState() round-trip.
|
|
113
|
+
if (res && res.entry) {
|
|
114
|
+
const list = this._state.apiEndpoints || (this._state.apiEndpoints = []);
|
|
115
|
+
const key = `${domain}::${(endpoint.method || 'GET').toUpperCase()}::${endpoint.url}`;
|
|
116
|
+
const idx = list.findIndex(
|
|
117
|
+
(e) => `${e.domain}::${(e.method || 'GET').toUpperCase()}::${e.url}` === key
|
|
118
|
+
);
|
|
119
|
+
if (idx >= 0) list[idx] = res.entry;
|
|
120
|
+
else list.push(res.entry);
|
|
121
|
+
}
|
|
122
|
+
return res;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Restart signal — MCP server checks this to decide whether to exit
|
|
126
|
+
get restartRequestedAt() { return this._state.restartRequestedAt || null; }
|
|
127
|
+
|
|
128
|
+
// ============================================================================
|
|
129
|
+
// Write operations — forward to bridge daemon via HTTP POST
|
|
130
|
+
// ============================================================================
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Update insight opportunities (increments counter for domain).
|
|
134
|
+
* Replaces: extensionData.insightOpportunities[domain]++
|
|
135
|
+
*/
|
|
136
|
+
async incrementInsight(domain) {
|
|
137
|
+
// Read current value, increment, write back
|
|
138
|
+
const current = this._state.insightOpportunities?.[domain] || 0;
|
|
139
|
+
this._state.insightOpportunities = {
|
|
140
|
+
...(this._state.insightOpportunities || {}),
|
|
141
|
+
[domain]: current + 1
|
|
142
|
+
};
|
|
143
|
+
// Persist to bridge daemon via heartbeat or sync
|
|
144
|
+
// Note: insight tracking is best-effort; no dedicated endpoint needed
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ============================================================================
|
|
148
|
+
// Async state access methods (fresh data per call)
|
|
149
|
+
// ============================================================================
|
|
150
|
+
|
|
151
|
+
async getConnectionStatus() {
|
|
152
|
+
const res = await this._fetch('/api/connection-status');
|
|
153
|
+
return res.json();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async getSelectedElement() {
|
|
157
|
+
const res = await this._fetch('/api/selected-element');
|
|
158
|
+
return res.json();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async getPageAnalysis() {
|
|
162
|
+
const res = await this._fetch('/api/page-analysis');
|
|
163
|
+
return res.json();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async getNetworkTrace() {
|
|
167
|
+
const res = await this._fetch('/api/network-trace');
|
|
168
|
+
return res.json();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async getWebSocketTrace() {
|
|
172
|
+
const res = await this._fetch('/api/websocket-trace');
|
|
173
|
+
return res.json();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ============================================================================
|
|
177
|
+
// Request queuing (POST to existing bridge routes)
|
|
178
|
+
// ============================================================================
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Queue a request to the bridge daemon via HTTP POST.
|
|
182
|
+
* @param {string} endpoint - Route name (e.g. 'execute-js', 'select-element')
|
|
183
|
+
* @param {object} data - Request payload
|
|
184
|
+
* @returns {Promise<object>} Response from bridge
|
|
185
|
+
*/
|
|
186
|
+
async queueRequest(endpoint, data) {
|
|
187
|
+
const res = await this._fetch(`/api/${endpoint}`, {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: { 'Content-Type': 'application/json' },
|
|
190
|
+
body: JSON.stringify(data)
|
|
191
|
+
});
|
|
192
|
+
return res.json();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ============================================================================
|
|
196
|
+
// Result polling (GET /api/result/:type)
|
|
197
|
+
// ============================================================================
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Poll for a result from the bridge daemon.
|
|
201
|
+
* Uses spin-wait with configurable interval and timeout.
|
|
202
|
+
* The result is consumed (cleared) on the bridge daemon side on read.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} type - Result type (e.g. 'js-execution', 'select-element')
|
|
205
|
+
* @param {string} requestId - Request ID to match
|
|
206
|
+
* @param {number} timeoutMs - Maximum wait time in ms
|
|
207
|
+
* @param {number} intervalMs - Polling interval in ms
|
|
208
|
+
* @returns {Promise<object|null>} Result data or null on timeout
|
|
209
|
+
*/
|
|
210
|
+
async waitForResult(type, requestId, timeoutMs = DEFAULT_REQUEST_TIMEOUT, intervalMs = DEFAULT_POLL_INTERVAL) {
|
|
211
|
+
const startTime = Date.now();
|
|
212
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
213
|
+
try {
|
|
214
|
+
const res = await this._fetch(`/api/result/${type}?requestId=${encodeURIComponent(requestId)}`);
|
|
215
|
+
const data = await res.json();
|
|
216
|
+
if (data.found) {
|
|
217
|
+
return data.result;
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
// Bridge might be temporarily unavailable, retry
|
|
221
|
+
}
|
|
222
|
+
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
|
223
|
+
}
|
|
224
|
+
return null; // Timeout
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ============================================================================
|
|
228
|
+
// Health check
|
|
229
|
+
// ============================================================================
|
|
230
|
+
|
|
231
|
+
async health() {
|
|
232
|
+
const res = await this._fetch('/health');
|
|
233
|
+
return res.json();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ============================================================================
|
|
237
|
+
// Control signals — simple POST to bridge daemon
|
|
238
|
+
// ============================================================================
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Send a simple POST to a bridge daemon endpoint.
|
|
242
|
+
* Used for control signals (e.g. clearing restart flag).
|
|
243
|
+
* @param {string} endpoint - Route path (e.g. 'clear-restart-signal')
|
|
244
|
+
* @returns {Promise<object>} Response from bridge
|
|
245
|
+
*/
|
|
246
|
+
async post(endpoint, data = {}) {
|
|
247
|
+
const res = await this._fetch(`/api/${endpoint}`, {
|
|
248
|
+
method: 'POST',
|
|
249
|
+
headers: { 'Content-Type': 'application/json' },
|
|
250
|
+
body: JSON.stringify(data)
|
|
251
|
+
});
|
|
252
|
+
return res.json();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ============================================================================
|
|
256
|
+
// Internal helper
|
|
257
|
+
// ============================================================================
|
|
258
|
+
|
|
259
|
+
async _fetch(path, options = {}) {
|
|
260
|
+
const url = `${this.baseUrl}${path}`;
|
|
261
|
+
const controller = new AbortController();
|
|
262
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
|
263
|
+
try {
|
|
264
|
+
const response = await fetch(url, { ...options, signal: controller.signal });
|
|
265
|
+
clearTimeout(timeout);
|
|
266
|
+
return response;
|
|
267
|
+
} catch (err) {
|
|
268
|
+
clearTimeout(timeout);
|
|
269
|
+
throw err;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge State Persistence
|
|
3
|
+
*
|
|
4
|
+
* Persists extensionData to disk as JSON with atomic writes.
|
|
5
|
+
* - Debounced writes (2s interval) to avoid excessive disk I/O
|
|
6
|
+
* - Atomic write: tmp file → rename (no partial/corrupt states)
|
|
7
|
+
* - Immediate writes for graceful shutdown
|
|
8
|
+
* - Startup load to restore state after process restart
|
|
9
|
+
*
|
|
10
|
+
* Phase 1.1 of Persistent Bridge Architecture.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { writeFileSync, readFileSync, renameSync, unlinkSync, existsSync, mkdirSync } from 'fs';
|
|
14
|
+
import { join, dirname } from 'path';
|
|
15
|
+
import { fileURLToPath } from 'url';
|
|
16
|
+
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
// Go up from src/state/ → project root → mcp-server/state/
|
|
19
|
+
const STATE_DIR = join(__dirname, '..', '..', 'state');
|
|
20
|
+
const STATE_FILE = join(STATE_DIR, 'bridge-state.json');
|
|
21
|
+
const TMP_FILE = join(STATE_DIR, 'bridge-state.tmp.json');
|
|
22
|
+
|
|
23
|
+
// Debounce state
|
|
24
|
+
let persistTimer = null;
|
|
25
|
+
const DEBOUNCE_MS = 2000;
|
|
26
|
+
|
|
27
|
+
// Fields to exclude from persistence (request queues are transient)
|
|
28
|
+
const EXCLUDED_FIELDS = new Set([
|
|
29
|
+
'jsExecutionRequest',
|
|
30
|
+
'jsExecutionResult',
|
|
31
|
+
'jsExecutionResults',
|
|
32
|
+
'actionExecutionRequest',
|
|
33
|
+
'actionExecutionResult',
|
|
34
|
+
'captureScreenshotRequest',
|
|
35
|
+
'captureScreenshotResult',
|
|
36
|
+
'rawNetworkRequests',
|
|
37
|
+
'rawNetworkResults',
|
|
38
|
+
'analyzePageRequests',
|
|
39
|
+
'analyzePageResults',
|
|
40
|
+
'selectElementRequest',
|
|
41
|
+
'selectElementResult',
|
|
42
|
+
'exportSessionRequest',
|
|
43
|
+
'exportSessionResult',
|
|
44
|
+
'tabsRequest',
|
|
45
|
+
'tabsResult',
|
|
46
|
+
'pendingNavigation',
|
|
47
|
+
'isConnected',
|
|
48
|
+
'lastUpdateTime',
|
|
49
|
+
'_connectionHealth',
|
|
50
|
+
'insightOpportunities',
|
|
51
|
+
'profileSaves',
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Ensure state directory exists
|
|
56
|
+
*/
|
|
57
|
+
function ensureStateDir() {
|
|
58
|
+
if (!existsSync(STATE_DIR)) {
|
|
59
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Serialize extensionData, excluding transient fields
|
|
65
|
+
* @param {object} extensionData - The shared state object
|
|
66
|
+
* @returns {object} Serializable subset
|
|
67
|
+
*/
|
|
68
|
+
function serializeState(extensionData) {
|
|
69
|
+
const snapshot = {};
|
|
70
|
+
for (const key of Object.keys(extensionData)) {
|
|
71
|
+
if (EXCLUDED_FIELDS.has(key)) continue;
|
|
72
|
+
const value = extensionData[key];
|
|
73
|
+
// Skip null/undefined — they'll be defaults on load
|
|
74
|
+
if (value === null || value === undefined) continue;
|
|
75
|
+
// Skip empty arrays/objects — no value in persisting
|
|
76
|
+
if (Array.isArray(value) && value.length === 0) continue;
|
|
77
|
+
if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0) continue;
|
|
78
|
+
snapshot[key] = value;
|
|
79
|
+
}
|
|
80
|
+
return snapshot;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Atomic write: write to tmp file, then rename over target.
|
|
85
|
+
* Prevents corrupt state from partial writes.
|
|
86
|
+
* @param {object} data - The data to write
|
|
87
|
+
*/
|
|
88
|
+
function atomicWrite(data) {
|
|
89
|
+
ensureStateDir();
|
|
90
|
+
const json = JSON.stringify(data, null, 2);
|
|
91
|
+
try {
|
|
92
|
+
writeFileSync(TMP_FILE, json, 'utf8');
|
|
93
|
+
renameSync(TMP_FILE, STATE_FILE);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
// Clean up tmp file if rename fails
|
|
96
|
+
try { unlinkSync(TMP_FILE); } catch { /* ignore */ }
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Persist extensionData to disk (debounced).
|
|
103
|
+
* Called after every state mutation. Will write at most once every DEBOUNCE_MS.
|
|
104
|
+
* @param {object} extensionData - The shared state object
|
|
105
|
+
*/
|
|
106
|
+
export function schedulePersist(extensionData) {
|
|
107
|
+
// Cancel any pending write
|
|
108
|
+
if (persistTimer) {
|
|
109
|
+
clearTimeout(persistTimer);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
persistTimer = setTimeout(() => {
|
|
113
|
+
try {
|
|
114
|
+
const data = serializeState(extensionData);
|
|
115
|
+
atomicWrite(data);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error('[Bridge Persistence] Debounced write failed:', err.message);
|
|
118
|
+
}
|
|
119
|
+
persistTimer = null;
|
|
120
|
+
}, DEBOUNCE_MS);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Persist extensionData to disk IMMEDIATELY (no debounce).
|
|
125
|
+
* Used for graceful shutdown and /api/die handler.
|
|
126
|
+
* @param {object} extensionData - The shared state object
|
|
127
|
+
*/
|
|
128
|
+
export function persistStateNow(extensionData) {
|
|
129
|
+
// Cancel any pending debounced write
|
|
130
|
+
if (persistTimer) {
|
|
131
|
+
clearTimeout(persistTimer);
|
|
132
|
+
persistTimer = null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const data = serializeState(extensionData);
|
|
137
|
+
atomicWrite(data);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.error('[Bridge Persistence] Immediate write failed:', err.message);
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Load persisted state from disk.
|
|
146
|
+
* Merges into the provided extensionData object, preserving defaults
|
|
147
|
+
* for any missing fields.
|
|
148
|
+
* @param {object} extensionData - The shared state object to merge into
|
|
149
|
+
* @returns {boolean} True if state was loaded, false if no file or error
|
|
150
|
+
*/
|
|
151
|
+
export function loadPersistedState(extensionData) {
|
|
152
|
+
if (!existsSync(STATE_FILE)) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const raw = readFileSync(STATE_FILE, 'utf8');
|
|
158
|
+
const persisted = JSON.parse(raw);
|
|
159
|
+
|
|
160
|
+
// Merge persisted data into extensionData (deep merge for nested objects)
|
|
161
|
+
for (const [key, value] of Object.entries(persisted)) {
|
|
162
|
+
if (EXCLUDED_FIELDS.has(key)) continue; // Skip transient fields
|
|
163
|
+
if (!(key in extensionData)) continue; // Skip unknown fields
|
|
164
|
+
|
|
165
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
166
|
+
// Deep merge for objects (networkTrace, websocketTrace, pageAnalysis, etc.)
|
|
167
|
+
if (typeof extensionData[key] === 'object' && extensionData[key] !== null && !Array.isArray(extensionData[key])) {
|
|
168
|
+
Object.assign(extensionData[key], value);
|
|
169
|
+
} else {
|
|
170
|
+
extensionData[key] = value;
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
extensionData[key] = value;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.error(`[Bridge Persistence] Loaded state from disk (${Object.keys(persisted).length} fields)`);
|
|
178
|
+
return true;
|
|
179
|
+
} catch (err) {
|
|
180
|
+
console.error('[Bridge Persistence] Failed to load state:', err.message);
|
|
181
|
+
// Don't throw — start with empty state rather than crash
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Clear persisted state file.
|
|
188
|
+
* Used for testing or when state is intentionally reset.
|
|
189
|
+
*/
|
|
190
|
+
export function clearPersistedState() {
|
|
191
|
+
try {
|
|
192
|
+
if (existsSync(STATE_FILE)) unlinkSync(STATE_FILE);
|
|
193
|
+
if (existsSync(TMP_FILE)) unlinkSync(TMP_FILE);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
console.error('[Bridge Persistence] Failed to clear state:', err.message);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Get the state file path (for external checks)
|
|
201
|
+
* @returns {string}
|
|
202
|
+
*/
|
|
203
|
+
export function getStateFilePath() {
|
|
204
|
+
return STATE_FILE;
|
|
205
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Temporary Data Caching
|
|
3
|
+
* Geçici veri önbellekleme (gelecekte genişletilebilir)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const cache = new Map();
|
|
7
|
+
|
|
8
|
+
export const setCache = (key, value, ttlMs = 60000) => {
|
|
9
|
+
const expiresAt = Date.now() + ttlMs;
|
|
10
|
+
cache.set(key, { value, expiresAt });
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const getCache = (key) => {
|
|
14
|
+
const item = cache.get(key);
|
|
15
|
+
|
|
16
|
+
if (!item) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (Date.now() > item.expiresAt) {
|
|
21
|
+
cache.delete(key);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return item.value;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const deleteCache = (key) => {
|
|
29
|
+
cache.delete(key);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const clearCache = () => {
|
|
33
|
+
cache.clear();
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const getCacheSize = () => {
|
|
37
|
+
return cache.size;
|
|
38
|
+
};
|