@0x1f320.sh/why-did-you-render-mcp 1.0.0-dev.6 → 1.0.0-dev.8
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 +1 -0
- package/dist/server/index.js +211 -33
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -126,6 +126,7 @@ Browser (project-b) ──┤
|
|
|
126
126
|
- **Multiple MCP instances** can run simultaneously. Only the first instance starts the WebSocket server; others gracefully skip. All instances share the same JSONL data directory.
|
|
127
127
|
- **Multi-project support** — Each project is identified by `location.origin`. Render data is stored in per-project JSONL files.
|
|
128
128
|
- **No daemon required** — Each MCP instance is independent. The WebSocket server is opportunistically claimed by whichever instance starts first.
|
|
129
|
+
- **Value dictionary deduplication** — Render reports often repeat the same `prevValue`/`nextValue` objects across thousands of entries. Each JSONL file stores a content-addressed dictionary on its first line, mapping xxhash-wasm hashes to unique values. Render lines reference them via `@@ref:<hash>` sentinels instead of inlining the full object, dramatically reducing file size. Reads hydrate refs transparently.
|
|
129
130
|
|
|
130
131
|
## Configuration
|
|
131
132
|
|
package/dist/server/index.js
CHANGED
|
@@ -2,14 +2,107 @@
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import {
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
+
import xxhash from "xxhash-wasm";
|
|
8
9
|
import { WebSocketServer } from "ws";
|
|
10
|
+
//#region src/server/store/utils/value-dict.ts
|
|
11
|
+
const DICT_KEY = "@@dict";
|
|
12
|
+
const REF_PREFIX = "@@ref:";
|
|
13
|
+
let h64ToString;
|
|
14
|
+
const ready = xxhash().then((api) => {
|
|
15
|
+
h64ToString = api.h64ToString;
|
|
16
|
+
});
|
|
17
|
+
function ensureReady() {
|
|
18
|
+
return ready;
|
|
19
|
+
}
|
|
20
|
+
function hashValue(value) {
|
|
21
|
+
return h64ToString(JSON.stringify(value));
|
|
22
|
+
}
|
|
23
|
+
function shouldDehydrate(value) {
|
|
24
|
+
return typeof value === "object" && value !== null;
|
|
25
|
+
}
|
|
26
|
+
function dehydrateDiffs(diffs, dict) {
|
|
27
|
+
if (!diffs) return false;
|
|
28
|
+
return diffs.map((d) => {
|
|
29
|
+
let { prevValue, nextValue } = d;
|
|
30
|
+
if (shouldDehydrate(prevValue)) {
|
|
31
|
+
const hash = hashValue(prevValue);
|
|
32
|
+
dict[hash] ??= prevValue;
|
|
33
|
+
prevValue = `${REF_PREFIX}${hash}`;
|
|
34
|
+
}
|
|
35
|
+
if (shouldDehydrate(nextValue)) {
|
|
36
|
+
const hash = hashValue(nextValue);
|
|
37
|
+
dict[hash] ??= nextValue;
|
|
38
|
+
nextValue = `${REF_PREFIX}${hash}`;
|
|
39
|
+
}
|
|
40
|
+
return prevValue === d.prevValue && nextValue === d.nextValue ? d : {
|
|
41
|
+
...d,
|
|
42
|
+
prevValue,
|
|
43
|
+
nextValue
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function dehydrate(render, dict) {
|
|
48
|
+
const { propsDifferences, stateDifferences, hookDifferences } = render.reason;
|
|
49
|
+
const newProps = dehydrateDiffs(propsDifferences, dict);
|
|
50
|
+
const newState = dehydrateDiffs(stateDifferences, dict);
|
|
51
|
+
const newHooks = dehydrateDiffs(hookDifferences, dict);
|
|
52
|
+
if (newProps === propsDifferences && newState === stateDifferences && newHooks === hookDifferences) return render;
|
|
53
|
+
return {
|
|
54
|
+
...render,
|
|
55
|
+
reason: {
|
|
56
|
+
propsDifferences: newProps,
|
|
57
|
+
stateDifferences: newState,
|
|
58
|
+
hookDifferences: newHooks
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function hydrateDiffs(diffs, dict) {
|
|
63
|
+
if (!diffs) return false;
|
|
64
|
+
return diffs.map((d) => {
|
|
65
|
+
let { prevValue, nextValue } = d;
|
|
66
|
+
if (typeof prevValue === "string" && prevValue.startsWith(REF_PREFIX)) prevValue = dict[prevValue.slice(6)] ?? prevValue;
|
|
67
|
+
if (typeof nextValue === "string" && nextValue.startsWith(REF_PREFIX)) nextValue = dict[nextValue.slice(6)] ?? nextValue;
|
|
68
|
+
return prevValue === d.prevValue && nextValue === d.nextValue ? d : {
|
|
69
|
+
...d,
|
|
70
|
+
prevValue,
|
|
71
|
+
nextValue
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
function hydrate(render, dict) {
|
|
76
|
+
const { propsDifferences, stateDifferences, hookDifferences } = render.reason;
|
|
77
|
+
const newProps = hydrateDiffs(propsDifferences, dict);
|
|
78
|
+
const newState = hydrateDiffs(stateDifferences, dict);
|
|
79
|
+
const newHooks = hydrateDiffs(hookDifferences, dict);
|
|
80
|
+
if (newProps === propsDifferences && newState === stateDifferences && newHooks === hookDifferences) return render;
|
|
81
|
+
return {
|
|
82
|
+
...render,
|
|
83
|
+
reason: {
|
|
84
|
+
propsDifferences: newProps,
|
|
85
|
+
stateDifferences: newState,
|
|
86
|
+
hookDifferences: newHooks
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
//#endregion
|
|
9
91
|
//#region src/server/store/utils/read-jsonl.ts
|
|
10
92
|
function readJsonl(file) {
|
|
11
93
|
if (!existsSync(file)) return [];
|
|
12
|
-
|
|
94
|
+
const lines = readFileSync(file, "utf-8").split("\n").filter(Boolean);
|
|
95
|
+
if (lines.length === 0) return [];
|
|
96
|
+
let dict;
|
|
97
|
+
let startIdx = 0;
|
|
98
|
+
const first = JSON.parse(lines[0]);
|
|
99
|
+
if ("@@dict" in first) {
|
|
100
|
+
dict = first[DICT_KEY];
|
|
101
|
+
startIdx = 1;
|
|
102
|
+
}
|
|
103
|
+
const renders = lines.slice(startIdx).map((l) => JSON.parse(l));
|
|
104
|
+
if (!dict) return renders;
|
|
105
|
+
return renders.map((r) => hydrate(r, dict));
|
|
13
106
|
}
|
|
14
107
|
//#endregion
|
|
15
108
|
//#region src/server/store/utils/sanitize-project-id.ts
|
|
@@ -30,10 +123,13 @@ function toResult(stored) {
|
|
|
30
123
|
//#endregion
|
|
31
124
|
//#region src/server/store/render-store.ts
|
|
32
125
|
const FLUSH_DELAY_MS = 200;
|
|
126
|
+
const NOCOMMIT = "nocommit";
|
|
33
127
|
var RenderStore = class {
|
|
34
128
|
dir;
|
|
35
129
|
buffers = /* @__PURE__ */ new Map();
|
|
36
130
|
timers = /* @__PURE__ */ new Map();
|
|
131
|
+
dicts = /* @__PURE__ */ new Map();
|
|
132
|
+
bufferMeta = /* @__PURE__ */ new Map();
|
|
37
133
|
constructor(dir) {
|
|
38
134
|
this.dir = dir ?? join(homedir(), ".wdyr-mcp", "renders");
|
|
39
135
|
mkdirSync(this.dir, { recursive: true });
|
|
@@ -44,35 +140,66 @@ var RenderStore = class {
|
|
|
44
140
|
projectId,
|
|
45
141
|
...commitId != null && { commitId }
|
|
46
142
|
};
|
|
47
|
-
|
|
143
|
+
const bk = this.bufferKey(projectId, commitId);
|
|
144
|
+
let buf = this.buffers.get(bk);
|
|
48
145
|
if (!buf) {
|
|
49
146
|
buf = [];
|
|
50
|
-
this.buffers.set(
|
|
147
|
+
this.buffers.set(bk, buf);
|
|
148
|
+
this.bufferMeta.set(bk, {
|
|
149
|
+
projectId,
|
|
150
|
+
commitId
|
|
151
|
+
});
|
|
51
152
|
}
|
|
52
153
|
buf.push(stored);
|
|
53
|
-
const existing = this.timers.get(
|
|
154
|
+
const existing = this.timers.get(bk);
|
|
54
155
|
if (existing) clearTimeout(existing);
|
|
55
|
-
this.timers.set(
|
|
156
|
+
this.timers.set(bk, setTimeout(() => {
|
|
157
|
+
this.flushAsync(projectId, commitId).catch((err) => console.error(`[wdyr-mcp] flush error for ${bk}:`, err));
|
|
158
|
+
}, FLUSH_DELAY_MS));
|
|
159
|
+
}
|
|
160
|
+
async flushAsync(projectId, commitId) {
|
|
161
|
+
await ensureReady();
|
|
162
|
+
this.flush(projectId, commitId);
|
|
56
163
|
}
|
|
57
|
-
flush(projectId) {
|
|
58
|
-
if (projectId) this.
|
|
59
|
-
else for (const
|
|
164
|
+
flush(projectId, commitId) {
|
|
165
|
+
if (projectId != null && commitId !== void 0) this.flushBuffer(this.bufferKey(projectId, commitId));
|
|
166
|
+
else if (projectId != null) for (const bk of this.bufferKeysForProject(projectId)) this.flushBuffer(bk);
|
|
167
|
+
else for (const bk of [...this.buffers.keys()]) this.flushBuffer(bk);
|
|
60
168
|
}
|
|
61
|
-
|
|
62
|
-
const buf = this.buffers.get(
|
|
169
|
+
flushBuffer(bk) {
|
|
170
|
+
const buf = this.buffers.get(bk);
|
|
63
171
|
if (!buf || buf.length === 0) return;
|
|
64
|
-
const
|
|
65
|
-
|
|
172
|
+
const meta = this.bufferMeta.get(bk);
|
|
173
|
+
if (!meta) return;
|
|
174
|
+
let dict = this.dicts.get(bk);
|
|
175
|
+
if (!dict) {
|
|
176
|
+
dict = {};
|
|
177
|
+
this.dicts.set(bk, dict);
|
|
178
|
+
}
|
|
179
|
+
const dehydrated = buf.map((r) => dehydrate(r, dict));
|
|
180
|
+
const file = this.commitFile(meta.projectId, meta.commitId);
|
|
181
|
+
const existingLines = this.readDataLines(file);
|
|
182
|
+
const newLines = dehydrated.map((r) => JSON.stringify(r));
|
|
183
|
+
const allDataLines = [...existingLines, ...newLines];
|
|
184
|
+
writeFileSync(file, `${(Object.keys(dict).length > 0 ? [JSON.stringify({ [DICT_KEY]: dict }), ...allDataLines] : allDataLines).join("\n")}\n`);
|
|
66
185
|
buf.length = 0;
|
|
67
|
-
const timer = this.timers.get(
|
|
186
|
+
const timer = this.timers.get(bk);
|
|
68
187
|
if (timer) {
|
|
69
188
|
clearTimeout(timer);
|
|
70
|
-
this.timers.delete(
|
|
189
|
+
this.timers.delete(bk);
|
|
71
190
|
}
|
|
72
191
|
}
|
|
192
|
+
readDataLines(file) {
|
|
193
|
+
if (!existsSync(file)) return [];
|
|
194
|
+
return readFileSync(file, "utf-8").split("\n").filter((line) => {
|
|
195
|
+
if (!line) return false;
|
|
196
|
+
if (line.startsWith(`{"@@dict"`)) return false;
|
|
197
|
+
return true;
|
|
198
|
+
});
|
|
199
|
+
}
|
|
73
200
|
getAllRenders(projectId) {
|
|
74
201
|
this.flush(projectId);
|
|
75
|
-
if (projectId) return
|
|
202
|
+
if (projectId) return this.projectFiles(projectId).flatMap((f) => readJsonl(join(this.dir, f)).map(toResult));
|
|
76
203
|
return this.jsonlFiles().flatMap((f) => readJsonl(join(this.dir, f)).map(toResult));
|
|
77
204
|
}
|
|
78
205
|
getRendersByComponent(componentName, projectId) {
|
|
@@ -80,38 +207,64 @@ var RenderStore = class {
|
|
|
80
207
|
}
|
|
81
208
|
clearRenders(projectId) {
|
|
82
209
|
if (projectId) {
|
|
83
|
-
this.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
this.timers.
|
|
210
|
+
for (const bk of this.bufferKeysForProject(projectId)) {
|
|
211
|
+
this.buffers.delete(bk);
|
|
212
|
+
this.dicts.delete(bk);
|
|
213
|
+
this.bufferMeta.delete(bk);
|
|
214
|
+
const timer = this.timers.get(bk);
|
|
215
|
+
if (timer) {
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
this.timers.delete(bk);
|
|
218
|
+
}
|
|
88
219
|
}
|
|
89
|
-
const
|
|
90
|
-
if (existsSync(file)) unlinkSync(file);
|
|
220
|
+
for (const f of this.projectFiles(projectId)) unlinkSync(join(this.dir, f));
|
|
91
221
|
} else {
|
|
92
|
-
for (const [
|
|
222
|
+
for (const [, timer] of this.timers) clearTimeout(timer);
|
|
93
223
|
this.buffers.clear();
|
|
94
224
|
this.timers.clear();
|
|
225
|
+
this.dicts.clear();
|
|
226
|
+
this.bufferMeta.clear();
|
|
95
227
|
for (const f of this.jsonlFiles()) unlinkSync(join(this.dir, f));
|
|
96
228
|
}
|
|
97
229
|
}
|
|
98
230
|
getProjects() {
|
|
99
231
|
this.flush();
|
|
100
232
|
const projects = /* @__PURE__ */ new Set();
|
|
233
|
+
const seen = /* @__PURE__ */ new Set();
|
|
101
234
|
for (const f of this.jsonlFiles()) {
|
|
102
|
-
const
|
|
103
|
-
if (!
|
|
104
|
-
|
|
105
|
-
|
|
235
|
+
const parsed = this.parseFilename(f);
|
|
236
|
+
if (!parsed) continue;
|
|
237
|
+
if (seen.has(parsed.projectSanitized)) continue;
|
|
238
|
+
seen.add(parsed.projectSanitized);
|
|
239
|
+
const lines = readFileSync(join(this.dir, f), "utf-8").split("\n");
|
|
240
|
+
for (const line of lines) {
|
|
241
|
+
if (!line) continue;
|
|
242
|
+
const obj = JSON.parse(line);
|
|
243
|
+
if ("@@dict" in obj) continue;
|
|
244
|
+
projects.add(obj.projectId);
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
106
247
|
}
|
|
107
248
|
return [...projects];
|
|
108
249
|
}
|
|
109
250
|
getCommitIds(projectId) {
|
|
110
|
-
|
|
111
|
-
|
|
251
|
+
this.flush(projectId);
|
|
252
|
+
const files = projectId ? this.projectFiles(projectId) : this.jsonlFiles();
|
|
253
|
+
const ids = /* @__PURE__ */ new Set();
|
|
254
|
+
for (const f of files) {
|
|
255
|
+
const parsed = this.parseFilename(f);
|
|
256
|
+
if (parsed?.commitId != null) ids.add(parsed.commitId);
|
|
257
|
+
}
|
|
258
|
+
return [...ids].sort((a, b) => a - b);
|
|
112
259
|
}
|
|
113
260
|
getRendersByCommit(commitId, projectId) {
|
|
114
|
-
|
|
261
|
+
if (projectId) {
|
|
262
|
+
this.flush(projectId, commitId);
|
|
263
|
+
return readJsonl(this.commitFile(projectId, commitId)).map(toResult);
|
|
264
|
+
}
|
|
265
|
+
this.flush();
|
|
266
|
+
const suffix = `_commit_${commitId}.jsonl`;
|
|
267
|
+
return this.jsonlFiles().filter((f) => f.endsWith(suffix)).flatMap((f) => readJsonl(join(this.dir, f)).map(toResult));
|
|
115
268
|
}
|
|
116
269
|
getSummary(projectId) {
|
|
117
270
|
const renders = this.getAllRenders(projectId);
|
|
@@ -123,8 +276,33 @@ var RenderStore = class {
|
|
|
123
276
|
}
|
|
124
277
|
return summary;
|
|
125
278
|
}
|
|
126
|
-
|
|
127
|
-
return
|
|
279
|
+
bufferKey(projectId, commitId) {
|
|
280
|
+
return `${projectId}\0${commitId ?? NOCOMMIT}`;
|
|
281
|
+
}
|
|
282
|
+
bufferKeysForProject(projectId) {
|
|
283
|
+
const prefix = `${projectId}\0`;
|
|
284
|
+
return [...this.buffers.keys()].filter((bk) => bk.startsWith(prefix));
|
|
285
|
+
}
|
|
286
|
+
commitFile(projectId, commitId) {
|
|
287
|
+
const sanitized = sanitizeProjectId(projectId);
|
|
288
|
+
const suffix = commitId != null ? `_commit_${commitId}` : `_${NOCOMMIT}`;
|
|
289
|
+
return join(this.dir, `${sanitized}${suffix}.jsonl`);
|
|
290
|
+
}
|
|
291
|
+
projectFiles(projectId) {
|
|
292
|
+
const prefix = sanitizeProjectId(projectId);
|
|
293
|
+
return readdirSync(this.dir).filter((f) => f.startsWith(prefix) && f.endsWith(".jsonl"));
|
|
294
|
+
}
|
|
295
|
+
parseFilename(filename) {
|
|
296
|
+
if (!filename.endsWith(".jsonl")) return null;
|
|
297
|
+
const base = filename.slice(0, -6);
|
|
298
|
+
const commitMatch = base.match(/^(.+)_commit_(\d+)$/);
|
|
299
|
+
if (commitMatch) return {
|
|
300
|
+
projectSanitized: commitMatch[1],
|
|
301
|
+
commitId: Number(commitMatch[2])
|
|
302
|
+
};
|
|
303
|
+
const nocommitMatch = base.match(/^(.+)_nocommit$/);
|
|
304
|
+
if (nocommitMatch) return { projectSanitized: nocommitMatch[1] };
|
|
305
|
+
return { projectSanitized: base };
|
|
128
306
|
}
|
|
129
307
|
jsonlFiles() {
|
|
130
308
|
return readdirSync(this.dir).filter((f) => f.endsWith(".jsonl"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@0x1f320.sh/why-did-you-render-mcp",
|
|
3
|
-
"version": "1.0.0-dev.
|
|
3
|
+
"version": "1.0.0-dev.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "MCP server that collects why-did-you-render data from browser and exposes it to coding agents",
|
|
6
6
|
"license": "MIT",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
52
52
|
"ws": "^8.18.0",
|
|
53
|
+
"xxhash-wasm": "^1.1.0",
|
|
53
54
|
"zod": "^3.24.4"
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|