@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 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
 
@@ -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 { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
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
- return readFileSync(file, "utf-8").split("\n").filter(Boolean).map((line) => JSON.parse(line));
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
- let buf = this.buffers.get(projectId);
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(projectId, buf);
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(projectId);
154
+ const existing = this.timers.get(bk);
54
155
  if (existing) clearTimeout(existing);
55
- this.timers.set(projectId, setTimeout(() => this.flush(projectId), FLUSH_DELAY_MS));
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.flushProject(projectId);
59
- else for (const id of this.buffers.keys()) this.flushProject(id);
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
- flushProject(projectId) {
62
- const buf = this.buffers.get(projectId);
169
+ flushBuffer(bk) {
170
+ const buf = this.buffers.get(bk);
63
171
  if (!buf || buf.length === 0) return;
64
- const lines = buf.map((s) => JSON.stringify(s)).join("\n");
65
- appendFileSync(this.projectFile(projectId), `${lines}\n`);
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(projectId);
186
+ const timer = this.timers.get(bk);
68
187
  if (timer) {
69
188
  clearTimeout(timer);
70
- this.timers.delete(projectId);
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 readJsonl(this.projectFile(projectId)).map(toResult);
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.buffers.delete(projectId);
84
- const timer = this.timers.get(projectId);
85
- if (timer) {
86
- clearTimeout(timer);
87
- this.timers.delete(projectId);
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 file = this.projectFile(projectId);
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 [id, timer] of this.timers) clearTimeout(timer);
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 firstLine = readFileSync(join(this.dir, f), "utf-8").split("\n")[0];
103
- if (!firstLine) continue;
104
- const stored = JSON.parse(firstLine);
105
- projects.add(stored.projectId);
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
- const renders = this.getAllRenders(projectId);
111
- return [...new Set(renders.map((r) => r.commitId).filter((id) => id != null))];
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
- return this.getAllRenders(projectId).filter((r) => r.commitId === commitId);
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
- projectFile(projectId) {
127
- return join(this.dir, `${sanitizeProjectId(projectId)}.jsonl`);
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.6",
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": {