@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.
Files changed (49) hide show
  1. package/README.md +559 -0
  2. package/bin/cli.js +88 -0
  3. package/package.json +54 -0
  4. package/src/bridge/http-server.js +290 -0
  5. package/src/bridge/middleware.js +56 -0
  6. package/src/bridge/routes.js +1003 -0
  7. package/src/bridge-daemon.js +172 -0
  8. package/src/cli/auto-config.js +120 -0
  9. package/src/constants.js +13 -0
  10. package/src/index.js +279 -0
  11. package/src/mcp-bridge.js +136 -0
  12. package/src/metrics/error-codes.js +44 -0
  13. package/src/metrics/index.js +3 -0
  14. package/src/metrics/metrics-db.js +269 -0
  15. package/src/metrics/metrics-recorder.js +240 -0
  16. package/src/metrics/metrics-report.js +146 -0
  17. package/src/profiles/profile-db.js +159 -0
  18. package/src/profiles/profile-enricher.js +333 -0
  19. package/src/profiles/profile-manager.js +563 -0
  20. package/src/profiles/profile-repo.js +183 -0
  21. package/src/state/bridge-client.js +272 -0
  22. package/src/state/bridge-persistence.js +205 -0
  23. package/src/state/cache.js +38 -0
  24. package/src/state/extension-state.js +321 -0
  25. package/src/tools/action_tools.js +218 -0
  26. package/src/tools/analyze-page.js +247 -0
  27. package/src/tools/debug-mcp-state.js +172 -0
  28. package/src/tools/discover-apis.js +186 -0
  29. package/src/tools/execute-js.js +284 -0
  30. package/src/tools/export-session.js +171 -0
  31. package/src/tools/extract-data.js +395 -0
  32. package/src/tools/get-element.js +281 -0
  33. package/src/tools/get-network-trace.js +471 -0
  34. package/src/tools/index.js +110 -0
  35. package/src/tools/manage-site-profile.js +153 -0
  36. package/src/tools/paginate.js +444 -0
  37. package/src/tools/quick-scan.js +418 -0
  38. package/src/tools/screenshot_tools.js +117 -0
  39. package/src/utils/circuit-breaker.js +112 -0
  40. package/src/utils/extract-density.js +21 -0
  41. package/src/utils/logger.js +31 -0
  42. package/src/utils/paginate-detector.js +24 -0
  43. package/src/utils/rate-limiter.js +244 -0
  44. package/src/utils/run-script.js +37 -0
  45. package/src/utils/selector-validator.js +95 -0
  46. package/src/utils/state-validator.js +354 -0
  47. package/src/utils/tab-resolver.js +70 -0
  48. package/src/utils/workflow-helper.js +292 -0
  49. 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
+ };