@0x1f320.sh/why-did-you-render-mcp 1.0.0-dev.1 → 1.0.0-dev.10
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 +87 -7
- package/dist/client/index.cjs +174 -17
- package/dist/client/index.d.cts +1 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +174 -17
- package/dist/server/index.js +278 -43
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# why-did-you-render-mcp
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@0x1f320.sh/why-did-you-render-mcp)
|
|
4
|
+
[](https://github.com/0x1f320/why-did-you-render-mcp/actions/workflows/ci.yml)
|
|
5
|
+
[](https://www.npmjs.com/package/@0x1f320.sh/why-did-you-render-mcp)
|
|
6
|
+
|
|
3
7
|
An [MCP](https://modelcontextprotocol.io/) server that bridges [why-did-you-render](https://github.com/welldone-software/why-did-you-render) data from the browser to coding agents. It captures unnecessary React re-render reports in real time and exposes them as MCP tools, so agents can diagnose and fix performance issues without manual browser inspection.
|
|
4
8
|
|
|
5
9
|
## How It Works
|
|
@@ -39,10 +43,8 @@ import whyDidYouRender from "@welldone-software/why-did-you-render";
|
|
|
39
43
|
import { buildOptions } from "@0x1f320.sh/why-did-you-render-mcp/client";
|
|
40
44
|
|
|
41
45
|
if (process.env.NODE_ENV === "development") {
|
|
42
|
-
const { notifier } = buildOptions();
|
|
43
|
-
|
|
44
46
|
whyDidYouRender(React, {
|
|
45
|
-
|
|
47
|
+
...buildOptions(),
|
|
46
48
|
trackAllPureComponents: true,
|
|
47
49
|
});
|
|
48
50
|
}
|
|
@@ -59,31 +61,99 @@ const { notifier } = buildOptions({
|
|
|
59
61
|
|
|
60
62
|
### 2. Add the MCP server to your agent
|
|
61
63
|
|
|
62
|
-
|
|
64
|
+
<details>
|
|
65
|
+
<summary>Claude Code</summary>
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
claude mcp add why-did-you-render -- npx -y @0x1f320.sh/why-did-you-render-mcp
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
</details>
|
|
72
|
+
|
|
73
|
+
<details>
|
|
74
|
+
<summary>Claude Desktop</summary>
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
claude mcp add-json why-did-you-render '{"command":"npx","args":["-y","@0x1f320.sh/why-did-you-render-mcp"]}' -s user
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Or manually edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) / `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
|
|
63
81
|
|
|
64
82
|
```json
|
|
65
83
|
{
|
|
66
84
|
"mcpServers": {
|
|
67
85
|
"why-did-you-render": {
|
|
68
86
|
"command": "npx",
|
|
69
|
-
"args": ["@0x1f320.sh/why-did-you-render-mcp"]
|
|
87
|
+
"args": ["-y", "@0x1f320.sh/why-did-you-render-mcp"]
|
|
70
88
|
}
|
|
71
89
|
}
|
|
72
90
|
}
|
|
73
91
|
```
|
|
74
92
|
|
|
75
|
-
|
|
93
|
+
</details>
|
|
94
|
+
|
|
95
|
+
<details>
|
|
96
|
+
<summary>Cursor</summary>
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
cursor --add-mcp '{"name":"why-did-you-render","command":"npx","args":["-y","@0x1f320.sh/why-did-you-render-mcp"]}'
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Or add to `.cursor/mcp.json` in your project:
|
|
76
103
|
|
|
77
104
|
```json
|
|
78
105
|
{
|
|
79
106
|
"mcpServers": {
|
|
80
107
|
"why-did-you-render": {
|
|
81
|
-
"command": "
|
|
108
|
+
"command": "npx",
|
|
109
|
+
"args": ["-y", "@0x1f320.sh/why-did-you-render-mcp"]
|
|
82
110
|
}
|
|
83
111
|
}
|
|
84
112
|
}
|
|
85
113
|
```
|
|
86
114
|
|
|
115
|
+
</details>
|
|
116
|
+
|
|
117
|
+
<details>
|
|
118
|
+
<summary>Windsurf</summary>
|
|
119
|
+
|
|
120
|
+
Add to `~/.codeium/windsurf/mcp_config.json`:
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"mcpServers": {
|
|
125
|
+
"why-did-you-render": {
|
|
126
|
+
"command": "npx",
|
|
127
|
+
"args": ["-y", "@0x1f320.sh/why-did-you-render-mcp"]
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
</details>
|
|
134
|
+
|
|
135
|
+
<details>
|
|
136
|
+
<summary>VS Code (GitHub Copilot)</summary>
|
|
137
|
+
|
|
138
|
+
```sh
|
|
139
|
+
code --add-mcp '{"name":"why-did-you-render","command":"npx","args":["-y","@0x1f320.sh/why-did-you-render-mcp"]}'
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Or add to `.vscode/mcp.json` in your project:
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{
|
|
146
|
+
"servers": {
|
|
147
|
+
"why-did-you-render": {
|
|
148
|
+
"command": "npx",
|
|
149
|
+
"args": ["-y", "@0x1f320.sh/why-did-you-render-mcp"]
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
</details>
|
|
156
|
+
|
|
87
157
|
### 3. Start your dev server and interact with the app
|
|
88
158
|
|
|
89
159
|
Once both the MCP server and your React dev server are running, interact with your app in the browser. The agent can now query re-render data using the MCP tools below.
|
|
@@ -94,11 +164,20 @@ Once both the MCP server and your React dev server are running, interact with yo
|
|
|
94
164
|
| --- | --- |
|
|
95
165
|
| `get_unnecessary_renders` | Returns all captured unnecessary re-renders. Optionally filter by `component` name. |
|
|
96
166
|
| `get_render_summary` | Returns a summary of re-renders grouped by component with counts. |
|
|
167
|
+
| `get_commits` | Lists React commit IDs that have recorded render data. Use these IDs with `get_renders_by_commit`. |
|
|
168
|
+
| `get_renders_by_commit` | Returns all unnecessary re-renders for a specific React commit ID. |
|
|
97
169
|
| `get_projects` | Lists all active projects (identified by their origin URL). |
|
|
98
170
|
| `clear_renders` | Clears all stored render data. Optionally scope to a specific project. |
|
|
99
171
|
|
|
100
172
|
When multiple projects are active, tools accept an optional `project` parameter (the browser's origin URL, e.g. `http://localhost:3000`). If omitted and only one project exists, it is auto-selected.
|
|
101
173
|
|
|
174
|
+
### Commit-level grouping
|
|
175
|
+
|
|
176
|
+
Each render report is tagged with a React **commit ID**, allowing agents to inspect which components re-rendered together in the same commit. The client tracks commits by hooking into `__REACT_DEVTOOLS_GLOBAL_HOOK__.onCommitFiberRoot`, which React calls synchronously once per commit. A typical workflow:
|
|
177
|
+
|
|
178
|
+
1. Call `get_commits` to list available commit IDs
|
|
179
|
+
2. Call `get_renders_by_commit` with a specific ID to see all renders in that commit
|
|
180
|
+
|
|
102
181
|
## Architecture
|
|
103
182
|
|
|
104
183
|
```
|
|
@@ -117,6 +196,7 @@ Browser (project-b) ──┤
|
|
|
117
196
|
- **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.
|
|
118
197
|
- **Multi-project support** — Each project is identified by `location.origin`. Render data is stored in per-project JSONL files.
|
|
119
198
|
- **No daemon required** — Each MCP instance is independent. The WebSocket server is opportunistically claimed by whichever instance starts first.
|
|
199
|
+
- **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.
|
|
120
200
|
|
|
121
201
|
## Configuration
|
|
122
202
|
|
package/dist/client/index.cjs
CHANGED
|
@@ -1,14 +1,116 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
//#region src/client/utils/describe-value.ts
|
|
3
|
+
const MAX_DEPTH = 8;
|
|
4
|
+
const REACT_ELEMENT_SYMBOL = Symbol.for("react.element");
|
|
5
|
+
const REACT_TRANSITIONAL_ELEMENT_SYMBOL = Symbol.for("react.transitional.element");
|
|
6
|
+
const REACT_MEMO_TYPE = Symbol.for("react.memo");
|
|
7
|
+
const REACT_FORWARD_REF_TYPE = Symbol.for("react.forward_ref");
|
|
8
|
+
function isReactElement(value) {
|
|
9
|
+
if (typeof value !== "object" || value === null) return false;
|
|
10
|
+
const v = value;
|
|
11
|
+
return v.$$typeof === REACT_ELEMENT_SYMBOL || v.$$typeof === REACT_TRANSITIONAL_ELEMENT_SYMBOL || v.$$typeof === 60103;
|
|
12
|
+
}
|
|
13
|
+
function resolveComponentInfo(type) {
|
|
14
|
+
let memo = false;
|
|
15
|
+
let forwardRef = false;
|
|
16
|
+
let current = type;
|
|
17
|
+
for (let i = 0; i < 5; i++) {
|
|
18
|
+
if (typeof current !== "object" || current === null) break;
|
|
19
|
+
const wrapper = current;
|
|
20
|
+
if (wrapper.$$typeof === REACT_MEMO_TYPE) {
|
|
21
|
+
memo = true;
|
|
22
|
+
current = wrapper.type;
|
|
23
|
+
} else if (wrapper.$$typeof === REACT_FORWARD_REF_TYPE) {
|
|
24
|
+
forwardRef = true;
|
|
25
|
+
current = wrapper.render;
|
|
26
|
+
} else break;
|
|
27
|
+
}
|
|
28
|
+
let name = "Unknown";
|
|
29
|
+
if (typeof current === "string") name = current;
|
|
30
|
+
else if (typeof current === "function") name = current.displayName || current.name || "Anonymous";
|
|
31
|
+
return {
|
|
32
|
+
name,
|
|
33
|
+
memo,
|
|
34
|
+
forwardRef
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function serializeReactElement(el, seen, depth) {
|
|
38
|
+
const component = resolveComponentInfo(el.type);
|
|
39
|
+
const props = {};
|
|
40
|
+
if (el.props && typeof el.props === "object") for (const key of Object.keys(el.props)) {
|
|
41
|
+
if (key === "children") continue;
|
|
42
|
+
props[key] = serialize(el.props[key], seen, depth + 1);
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
type: "react-node",
|
|
46
|
+
component,
|
|
47
|
+
props
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function serialize(value, seen, depth) {
|
|
51
|
+
if (value === null) return null;
|
|
52
|
+
if (value === void 0) return null;
|
|
53
|
+
if (typeof value === "function") return {
|
|
54
|
+
type: "function",
|
|
55
|
+
name: value.name || "anonymous"
|
|
56
|
+
};
|
|
57
|
+
if (typeof value === "boolean") return value;
|
|
58
|
+
if (typeof value === "number") {
|
|
59
|
+
if (Number.isNaN(value)) return "NaN";
|
|
60
|
+
if (!Number.isFinite(value)) return value > 0 ? "Infinity" : "-Infinity";
|
|
61
|
+
if (Object.is(value, -0)) return "-0";
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
if (typeof value === "string") return value;
|
|
65
|
+
if (typeof value === "bigint") return value.toString();
|
|
66
|
+
if (typeof value === "symbol") return value.toString();
|
|
67
|
+
if (seen.has(value)) return "[Circular]";
|
|
68
|
+
if (depth >= MAX_DEPTH) return "[MaxDepth]";
|
|
69
|
+
seen.add(value);
|
|
70
|
+
if (isReactElement(value)) return serializeReactElement(value, seen, depth);
|
|
71
|
+
if (Array.isArray(value)) return value.map((item) => serialize(item, seen, depth + 1));
|
|
72
|
+
const ctorName = Object.getPrototypeOf(value)?.constructor?.name;
|
|
73
|
+
if (ctorName && ctorName !== "Object") {
|
|
74
|
+
if (value instanceof Date) return value.toISOString();
|
|
75
|
+
if (value instanceof RegExp) return String(value);
|
|
76
|
+
if (value instanceof Map) {
|
|
77
|
+
const entries = {};
|
|
78
|
+
for (const [k, v] of value.entries()) entries[String(k)] = serialize(v, seen, depth + 1);
|
|
79
|
+
return {
|
|
80
|
+
type: "Map",
|
|
81
|
+
entries
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (value instanceof Set) return {
|
|
85
|
+
type: "Set",
|
|
86
|
+
values: [...value].map((v) => serialize(v, seen, depth + 1))
|
|
87
|
+
};
|
|
88
|
+
if (value instanceof Promise) return "Promise";
|
|
89
|
+
if (value instanceof Error) return {
|
|
90
|
+
type: "Error",
|
|
91
|
+
name: value.name,
|
|
92
|
+
message: value.message
|
|
93
|
+
};
|
|
94
|
+
if (typeof Node !== "undefined" && value instanceof Node && value instanceof Element) {
|
|
95
|
+
const attrs = {};
|
|
96
|
+
for (const attr of value.attributes) attrs[attr.name] = attr.value;
|
|
97
|
+
return {
|
|
98
|
+
type: "dom",
|
|
99
|
+
tagName: value.tagName.toLowerCase(),
|
|
100
|
+
attrs
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
type: "class",
|
|
105
|
+
name: ctorName
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const result = {};
|
|
109
|
+
for (const key of Object.keys(value)) result[key] = serialize(value[key], seen, depth + 1);
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
3
112
|
function describeValue(value) {
|
|
4
|
-
|
|
5
|
-
if (value === void 0) return "undefined";
|
|
6
|
-
if (typeof value === "function") return `function ${value.name || "anonymous"}`;
|
|
7
|
-
if (typeof value !== "object") return String(value);
|
|
8
|
-
if (Array.isArray(value)) return `Array(${value.length})`;
|
|
9
|
-
const name = Object.getPrototypeOf(value)?.constructor?.name;
|
|
10
|
-
if (name && name !== "Object") return name;
|
|
11
|
-
return "Object";
|
|
113
|
+
return serialize(value, /* @__PURE__ */ new WeakSet(), 0);
|
|
12
114
|
}
|
|
13
115
|
//#endregion
|
|
14
116
|
//#region src/client/utils/sanitize-differences.ts
|
|
@@ -33,40 +135,95 @@ function sanitizeReason(reason) {
|
|
|
33
135
|
//#endregion
|
|
34
136
|
//#region src/client/index.ts
|
|
35
137
|
const DEFAULT_WS_URL = "ws://localhost:4649";
|
|
138
|
+
const PREFIX_STYLE = "color: #38bdf8; font-weight: bold";
|
|
139
|
+
const RESET_STYLE = "color: inherit; font-weight: normal";
|
|
140
|
+
function log(message) {
|
|
141
|
+
console.log(`%c[WDYR MCP]%c ${message}`, PREFIX_STYLE, RESET_STYLE);
|
|
142
|
+
}
|
|
143
|
+
function patchDevToolsHook(onCommit) {
|
|
144
|
+
if (!globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__) globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
|
|
145
|
+
supportsFiber: true,
|
|
146
|
+
inject() {},
|
|
147
|
+
onCommitFiberRoot() {},
|
|
148
|
+
onCommitFiberUnmount() {}
|
|
149
|
+
};
|
|
150
|
+
const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
151
|
+
const original = hook.onCommitFiberRoot.bind(hook);
|
|
152
|
+
hook.onCommitFiberRoot = (...args) => {
|
|
153
|
+
onCommit();
|
|
154
|
+
return original(...args);
|
|
155
|
+
};
|
|
156
|
+
}
|
|
36
157
|
function buildOptions(opts) {
|
|
37
158
|
const wsUrl = opts?.wsUrl ?? DEFAULT_WS_URL;
|
|
38
159
|
const projectId = opts?.projectId ?? globalThis.location?.origin ?? "default";
|
|
160
|
+
const MAX_QUEUE_SIZE = 1e3;
|
|
161
|
+
const BASE_DELAY = 1e3;
|
|
162
|
+
const MAX_DELAY = 3e4;
|
|
39
163
|
let ws = null;
|
|
40
164
|
let queue = [];
|
|
165
|
+
let commitId = 0;
|
|
166
|
+
let retryDelay = BASE_DELAY;
|
|
167
|
+
patchDevToolsHook(() => {
|
|
168
|
+
commitId++;
|
|
169
|
+
});
|
|
41
170
|
function connect() {
|
|
42
171
|
ws = new WebSocket(wsUrl);
|
|
43
172
|
ws.addEventListener("open", () => {
|
|
173
|
+
log(`Connected to ${wsUrl}`);
|
|
174
|
+
retryDelay = BASE_DELAY;
|
|
44
175
|
for (const msg of queue) ws?.send(JSON.stringify(msg));
|
|
45
176
|
queue = [];
|
|
46
177
|
});
|
|
47
178
|
ws.addEventListener("close", () => {
|
|
48
179
|
ws = null;
|
|
49
|
-
setTimeout(connect,
|
|
180
|
+
setTimeout(connect, retryDelay);
|
|
181
|
+
retryDelay = Math.min(retryDelay * 2, MAX_DELAY);
|
|
50
182
|
});
|
|
51
183
|
ws.addEventListener("error", () => {
|
|
184
|
+
log(`Connection failed (${wsUrl}). Retrying in ${retryDelay / 1e3}s...`);
|
|
52
185
|
ws?.close();
|
|
53
186
|
});
|
|
54
187
|
}
|
|
55
188
|
connect();
|
|
56
189
|
function send(msg) {
|
|
57
190
|
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
|
|
58
|
-
else
|
|
191
|
+
else {
|
|
192
|
+
if (queue.length >= MAX_QUEUE_SIZE) queue.shift();
|
|
193
|
+
queue.push(msg);
|
|
194
|
+
}
|
|
59
195
|
}
|
|
60
|
-
|
|
196
|
+
let pendingBatch = null;
|
|
197
|
+
let flushScheduled = false;
|
|
198
|
+
function flushBatch() {
|
|
199
|
+
flushScheduled = false;
|
|
200
|
+
if (!pendingBatch || pendingBatch.reports.length === 0) return;
|
|
61
201
|
send({
|
|
62
|
-
type: "render",
|
|
202
|
+
type: "render-batch",
|
|
63
203
|
projectId,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
reason: sanitizeReason(info.reason),
|
|
67
|
-
hookName: info.hookName
|
|
68
|
-
}
|
|
204
|
+
commitId: pendingBatch.commitId,
|
|
205
|
+
payload: pendingBatch.reports
|
|
69
206
|
});
|
|
207
|
+
pendingBatch = null;
|
|
208
|
+
}
|
|
209
|
+
return { notifier(info) {
|
|
210
|
+
const report = {
|
|
211
|
+
displayName: info.displayName,
|
|
212
|
+
reason: sanitizeReason(info.reason),
|
|
213
|
+
hookName: info.hookName
|
|
214
|
+
};
|
|
215
|
+
if (pendingBatch && pendingBatch.commitId === commitId) pendingBatch.reports.push(report);
|
|
216
|
+
else {
|
|
217
|
+
if (pendingBatch) flushBatch();
|
|
218
|
+
pendingBatch = {
|
|
219
|
+
commitId,
|
|
220
|
+
reports: [report]
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
if (!flushScheduled) {
|
|
224
|
+
flushScheduled = true;
|
|
225
|
+
queueMicrotask(flushBatch);
|
|
226
|
+
}
|
|
70
227
|
} };
|
|
71
228
|
}
|
|
72
229
|
//#endregion
|
package/dist/client/index.d.cts
CHANGED
package/dist/client/index.d.ts
CHANGED
package/dist/client/index.js
CHANGED
|
@@ -1,13 +1,115 @@
|
|
|
1
1
|
//#region src/client/utils/describe-value.ts
|
|
2
|
+
const MAX_DEPTH = 8;
|
|
3
|
+
const REACT_ELEMENT_SYMBOL = Symbol.for("react.element");
|
|
4
|
+
const REACT_TRANSITIONAL_ELEMENT_SYMBOL = Symbol.for("react.transitional.element");
|
|
5
|
+
const REACT_MEMO_TYPE = Symbol.for("react.memo");
|
|
6
|
+
const REACT_FORWARD_REF_TYPE = Symbol.for("react.forward_ref");
|
|
7
|
+
function isReactElement(value) {
|
|
8
|
+
if (typeof value !== "object" || value === null) return false;
|
|
9
|
+
const v = value;
|
|
10
|
+
return v.$$typeof === REACT_ELEMENT_SYMBOL || v.$$typeof === REACT_TRANSITIONAL_ELEMENT_SYMBOL || v.$$typeof === 60103;
|
|
11
|
+
}
|
|
12
|
+
function resolveComponentInfo(type) {
|
|
13
|
+
let memo = false;
|
|
14
|
+
let forwardRef = false;
|
|
15
|
+
let current = type;
|
|
16
|
+
for (let i = 0; i < 5; i++) {
|
|
17
|
+
if (typeof current !== "object" || current === null) break;
|
|
18
|
+
const wrapper = current;
|
|
19
|
+
if (wrapper.$$typeof === REACT_MEMO_TYPE) {
|
|
20
|
+
memo = true;
|
|
21
|
+
current = wrapper.type;
|
|
22
|
+
} else if (wrapper.$$typeof === REACT_FORWARD_REF_TYPE) {
|
|
23
|
+
forwardRef = true;
|
|
24
|
+
current = wrapper.render;
|
|
25
|
+
} else break;
|
|
26
|
+
}
|
|
27
|
+
let name = "Unknown";
|
|
28
|
+
if (typeof current === "string") name = current;
|
|
29
|
+
else if (typeof current === "function") name = current.displayName || current.name || "Anonymous";
|
|
30
|
+
return {
|
|
31
|
+
name,
|
|
32
|
+
memo,
|
|
33
|
+
forwardRef
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function serializeReactElement(el, seen, depth) {
|
|
37
|
+
const component = resolveComponentInfo(el.type);
|
|
38
|
+
const props = {};
|
|
39
|
+
if (el.props && typeof el.props === "object") for (const key of Object.keys(el.props)) {
|
|
40
|
+
if (key === "children") continue;
|
|
41
|
+
props[key] = serialize(el.props[key], seen, depth + 1);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
type: "react-node",
|
|
45
|
+
component,
|
|
46
|
+
props
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function serialize(value, seen, depth) {
|
|
50
|
+
if (value === null) return null;
|
|
51
|
+
if (value === void 0) return null;
|
|
52
|
+
if (typeof value === "function") return {
|
|
53
|
+
type: "function",
|
|
54
|
+
name: value.name || "anonymous"
|
|
55
|
+
};
|
|
56
|
+
if (typeof value === "boolean") return value;
|
|
57
|
+
if (typeof value === "number") {
|
|
58
|
+
if (Number.isNaN(value)) return "NaN";
|
|
59
|
+
if (!Number.isFinite(value)) return value > 0 ? "Infinity" : "-Infinity";
|
|
60
|
+
if (Object.is(value, -0)) return "-0";
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
if (typeof value === "string") return value;
|
|
64
|
+
if (typeof value === "bigint") return value.toString();
|
|
65
|
+
if (typeof value === "symbol") return value.toString();
|
|
66
|
+
if (seen.has(value)) return "[Circular]";
|
|
67
|
+
if (depth >= MAX_DEPTH) return "[MaxDepth]";
|
|
68
|
+
seen.add(value);
|
|
69
|
+
if (isReactElement(value)) return serializeReactElement(value, seen, depth);
|
|
70
|
+
if (Array.isArray(value)) return value.map((item) => serialize(item, seen, depth + 1));
|
|
71
|
+
const ctorName = Object.getPrototypeOf(value)?.constructor?.name;
|
|
72
|
+
if (ctorName && ctorName !== "Object") {
|
|
73
|
+
if (value instanceof Date) return value.toISOString();
|
|
74
|
+
if (value instanceof RegExp) return String(value);
|
|
75
|
+
if (value instanceof Map) {
|
|
76
|
+
const entries = {};
|
|
77
|
+
for (const [k, v] of value.entries()) entries[String(k)] = serialize(v, seen, depth + 1);
|
|
78
|
+
return {
|
|
79
|
+
type: "Map",
|
|
80
|
+
entries
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (value instanceof Set) return {
|
|
84
|
+
type: "Set",
|
|
85
|
+
values: [...value].map((v) => serialize(v, seen, depth + 1))
|
|
86
|
+
};
|
|
87
|
+
if (value instanceof Promise) return "Promise";
|
|
88
|
+
if (value instanceof Error) return {
|
|
89
|
+
type: "Error",
|
|
90
|
+
name: value.name,
|
|
91
|
+
message: value.message
|
|
92
|
+
};
|
|
93
|
+
if (typeof Node !== "undefined" && value instanceof Node && value instanceof Element) {
|
|
94
|
+
const attrs = {};
|
|
95
|
+
for (const attr of value.attributes) attrs[attr.name] = attr.value;
|
|
96
|
+
return {
|
|
97
|
+
type: "dom",
|
|
98
|
+
tagName: value.tagName.toLowerCase(),
|
|
99
|
+
attrs
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
type: "class",
|
|
104
|
+
name: ctorName
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const result = {};
|
|
108
|
+
for (const key of Object.keys(value)) result[key] = serialize(value[key], seen, depth + 1);
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
2
111
|
function describeValue(value) {
|
|
3
|
-
|
|
4
|
-
if (value === void 0) return "undefined";
|
|
5
|
-
if (typeof value === "function") return `function ${value.name || "anonymous"}`;
|
|
6
|
-
if (typeof value !== "object") return String(value);
|
|
7
|
-
if (Array.isArray(value)) return `Array(${value.length})`;
|
|
8
|
-
const name = Object.getPrototypeOf(value)?.constructor?.name;
|
|
9
|
-
if (name && name !== "Object") return name;
|
|
10
|
-
return "Object";
|
|
112
|
+
return serialize(value, /* @__PURE__ */ new WeakSet(), 0);
|
|
11
113
|
}
|
|
12
114
|
//#endregion
|
|
13
115
|
//#region src/client/utils/sanitize-differences.ts
|
|
@@ -32,40 +134,95 @@ function sanitizeReason(reason) {
|
|
|
32
134
|
//#endregion
|
|
33
135
|
//#region src/client/index.ts
|
|
34
136
|
const DEFAULT_WS_URL = "ws://localhost:4649";
|
|
137
|
+
const PREFIX_STYLE = "color: #38bdf8; font-weight: bold";
|
|
138
|
+
const RESET_STYLE = "color: inherit; font-weight: normal";
|
|
139
|
+
function log(message) {
|
|
140
|
+
console.log(`%c[WDYR MCP]%c ${message}`, PREFIX_STYLE, RESET_STYLE);
|
|
141
|
+
}
|
|
142
|
+
function patchDevToolsHook(onCommit) {
|
|
143
|
+
if (!globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__) globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
|
|
144
|
+
supportsFiber: true,
|
|
145
|
+
inject() {},
|
|
146
|
+
onCommitFiberRoot() {},
|
|
147
|
+
onCommitFiberUnmount() {}
|
|
148
|
+
};
|
|
149
|
+
const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
150
|
+
const original = hook.onCommitFiberRoot.bind(hook);
|
|
151
|
+
hook.onCommitFiberRoot = (...args) => {
|
|
152
|
+
onCommit();
|
|
153
|
+
return original(...args);
|
|
154
|
+
};
|
|
155
|
+
}
|
|
35
156
|
function buildOptions(opts) {
|
|
36
157
|
const wsUrl = opts?.wsUrl ?? DEFAULT_WS_URL;
|
|
37
158
|
const projectId = opts?.projectId ?? globalThis.location?.origin ?? "default";
|
|
159
|
+
const MAX_QUEUE_SIZE = 1e3;
|
|
160
|
+
const BASE_DELAY = 1e3;
|
|
161
|
+
const MAX_DELAY = 3e4;
|
|
38
162
|
let ws = null;
|
|
39
163
|
let queue = [];
|
|
164
|
+
let commitId = 0;
|
|
165
|
+
let retryDelay = BASE_DELAY;
|
|
166
|
+
patchDevToolsHook(() => {
|
|
167
|
+
commitId++;
|
|
168
|
+
});
|
|
40
169
|
function connect() {
|
|
41
170
|
ws = new WebSocket(wsUrl);
|
|
42
171
|
ws.addEventListener("open", () => {
|
|
172
|
+
log(`Connected to ${wsUrl}`);
|
|
173
|
+
retryDelay = BASE_DELAY;
|
|
43
174
|
for (const msg of queue) ws?.send(JSON.stringify(msg));
|
|
44
175
|
queue = [];
|
|
45
176
|
});
|
|
46
177
|
ws.addEventListener("close", () => {
|
|
47
178
|
ws = null;
|
|
48
|
-
setTimeout(connect,
|
|
179
|
+
setTimeout(connect, retryDelay);
|
|
180
|
+
retryDelay = Math.min(retryDelay * 2, MAX_DELAY);
|
|
49
181
|
});
|
|
50
182
|
ws.addEventListener("error", () => {
|
|
183
|
+
log(`Connection failed (${wsUrl}). Retrying in ${retryDelay / 1e3}s...`);
|
|
51
184
|
ws?.close();
|
|
52
185
|
});
|
|
53
186
|
}
|
|
54
187
|
connect();
|
|
55
188
|
function send(msg) {
|
|
56
189
|
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
|
|
57
|
-
else
|
|
190
|
+
else {
|
|
191
|
+
if (queue.length >= MAX_QUEUE_SIZE) queue.shift();
|
|
192
|
+
queue.push(msg);
|
|
193
|
+
}
|
|
58
194
|
}
|
|
59
|
-
|
|
195
|
+
let pendingBatch = null;
|
|
196
|
+
let flushScheduled = false;
|
|
197
|
+
function flushBatch() {
|
|
198
|
+
flushScheduled = false;
|
|
199
|
+
if (!pendingBatch || pendingBatch.reports.length === 0) return;
|
|
60
200
|
send({
|
|
61
|
-
type: "render",
|
|
201
|
+
type: "render-batch",
|
|
62
202
|
projectId,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
reason: sanitizeReason(info.reason),
|
|
66
|
-
hookName: info.hookName
|
|
67
|
-
}
|
|
203
|
+
commitId: pendingBatch.commitId,
|
|
204
|
+
payload: pendingBatch.reports
|
|
68
205
|
});
|
|
206
|
+
pendingBatch = null;
|
|
207
|
+
}
|
|
208
|
+
return { notifier(info) {
|
|
209
|
+
const report = {
|
|
210
|
+
displayName: info.displayName,
|
|
211
|
+
reason: sanitizeReason(info.reason),
|
|
212
|
+
hookName: info.hookName
|
|
213
|
+
};
|
|
214
|
+
if (pendingBatch && pendingBatch.commitId === commitId) pendingBatch.reports.push(report);
|
|
215
|
+
else {
|
|
216
|
+
if (pendingBatch) flushBatch();
|
|
217
|
+
pendingBatch = {
|
|
218
|
+
commitId,
|
|
219
|
+
reports: [report]
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (!flushScheduled) {
|
|
223
|
+
flushScheduled = true;
|
|
224
|
+
queueMicrotask(flushBatch);
|
|
225
|
+
}
|
|
69
226
|
} };
|
|
70
227
|
}
|
|
71
228
|
//#endregion
|
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
|
|
@@ -23,54 +116,90 @@ function toResult(stored) {
|
|
|
23
116
|
project: stored.projectId,
|
|
24
117
|
displayName: stored.displayName,
|
|
25
118
|
reason: stored.reason,
|
|
26
|
-
...stored.hookName != null && { hookName: stored.hookName }
|
|
119
|
+
...stored.hookName != null && { hookName: stored.hookName },
|
|
120
|
+
...stored.commitId != null && { commitId: stored.commitId }
|
|
27
121
|
};
|
|
28
122
|
}
|
|
29
123
|
//#endregion
|
|
30
124
|
//#region src/server/store/render-store.ts
|
|
31
125
|
const FLUSH_DELAY_MS = 200;
|
|
126
|
+
const NOCOMMIT = "nocommit";
|
|
32
127
|
var RenderStore = class {
|
|
33
128
|
dir;
|
|
34
129
|
buffers = /* @__PURE__ */ new Map();
|
|
35
130
|
timers = /* @__PURE__ */ new Map();
|
|
131
|
+
dicts = /* @__PURE__ */ new Map();
|
|
132
|
+
bufferMeta = /* @__PURE__ */ new Map();
|
|
36
133
|
constructor(dir) {
|
|
37
134
|
this.dir = dir ?? join(homedir(), ".wdyr-mcp", "renders");
|
|
38
135
|
mkdirSync(this.dir, { recursive: true });
|
|
39
136
|
}
|
|
40
|
-
addRender(report, projectId) {
|
|
137
|
+
addRender(report, projectId, commitId) {
|
|
41
138
|
const stored = {
|
|
42
139
|
...report,
|
|
43
|
-
projectId
|
|
140
|
+
projectId,
|
|
141
|
+
...commitId != null && { commitId }
|
|
44
142
|
};
|
|
45
|
-
|
|
143
|
+
const bk = this.bufferKey(projectId, commitId);
|
|
144
|
+
let buf = this.buffers.get(bk);
|
|
46
145
|
if (!buf) {
|
|
47
146
|
buf = [];
|
|
48
|
-
this.buffers.set(
|
|
147
|
+
this.buffers.set(bk, buf);
|
|
148
|
+
this.bufferMeta.set(bk, {
|
|
149
|
+
projectId,
|
|
150
|
+
commitId
|
|
151
|
+
});
|
|
49
152
|
}
|
|
50
153
|
buf.push(stored);
|
|
51
|
-
const existing = this.timers.get(
|
|
154
|
+
const existing = this.timers.get(bk);
|
|
52
155
|
if (existing) clearTimeout(existing);
|
|
53
|
-
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));
|
|
54
159
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
160
|
+
async flushAsync(projectId, commitId) {
|
|
161
|
+
await ensureReady();
|
|
162
|
+
this.flush(projectId, commitId);
|
|
58
163
|
}
|
|
59
|
-
|
|
60
|
-
|
|
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);
|
|
168
|
+
}
|
|
169
|
+
flushBuffer(bk) {
|
|
170
|
+
const buf = this.buffers.get(bk);
|
|
61
171
|
if (!buf || buf.length === 0) return;
|
|
62
|
-
const
|
|
63
|
-
|
|
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`);
|
|
64
185
|
buf.length = 0;
|
|
65
|
-
const timer = this.timers.get(
|
|
186
|
+
const timer = this.timers.get(bk);
|
|
66
187
|
if (timer) {
|
|
67
188
|
clearTimeout(timer);
|
|
68
|
-
this.timers.delete(
|
|
189
|
+
this.timers.delete(bk);
|
|
69
190
|
}
|
|
70
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
|
+
}
|
|
71
200
|
getAllRenders(projectId) {
|
|
72
201
|
this.flush(projectId);
|
|
73
|
-
if (projectId) return
|
|
202
|
+
if (projectId) return this.projectFiles(projectId).flatMap((f) => readJsonl(join(this.dir, f)).map(toResult));
|
|
74
203
|
return this.jsonlFiles().flatMap((f) => readJsonl(join(this.dir, f)).map(toResult));
|
|
75
204
|
}
|
|
76
205
|
getRendersByComponent(componentName, projectId) {
|
|
@@ -78,32 +207,65 @@ var RenderStore = class {
|
|
|
78
207
|
}
|
|
79
208
|
clearRenders(projectId) {
|
|
80
209
|
if (projectId) {
|
|
81
|
-
this.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
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
|
+
}
|
|
86
219
|
}
|
|
87
|
-
const
|
|
88
|
-
if (existsSync(file)) unlinkSync(file);
|
|
220
|
+
for (const f of this.projectFiles(projectId)) unlinkSync(join(this.dir, f));
|
|
89
221
|
} else {
|
|
90
|
-
for (const [
|
|
222
|
+
for (const [, timer] of this.timers) clearTimeout(timer);
|
|
91
223
|
this.buffers.clear();
|
|
92
224
|
this.timers.clear();
|
|
225
|
+
this.dicts.clear();
|
|
226
|
+
this.bufferMeta.clear();
|
|
93
227
|
for (const f of this.jsonlFiles()) unlinkSync(join(this.dir, f));
|
|
94
228
|
}
|
|
95
229
|
}
|
|
96
230
|
getProjects() {
|
|
97
231
|
this.flush();
|
|
98
232
|
const projects = /* @__PURE__ */ new Set();
|
|
233
|
+
const seen = /* @__PURE__ */ new Set();
|
|
99
234
|
for (const f of this.jsonlFiles()) {
|
|
100
|
-
const
|
|
101
|
-
if (!
|
|
102
|
-
|
|
103
|
-
|
|
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
|
+
}
|
|
104
247
|
}
|
|
105
248
|
return [...projects];
|
|
106
249
|
}
|
|
250
|
+
getCommitIds(projectId) {
|
|
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);
|
|
259
|
+
}
|
|
260
|
+
getRendersByCommit(commitId, projectId) {
|
|
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));
|
|
268
|
+
}
|
|
107
269
|
getSummary(projectId) {
|
|
108
270
|
const renders = this.getAllRenders(projectId);
|
|
109
271
|
const summary = {};
|
|
@@ -114,8 +276,33 @@ var RenderStore = class {
|
|
|
114
276
|
}
|
|
115
277
|
return summary;
|
|
116
278
|
}
|
|
117
|
-
|
|
118
|
-
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 };
|
|
119
306
|
}
|
|
120
307
|
jsonlFiles() {
|
|
121
308
|
return readdirSync(this.dir).filter((f) => f.endsWith(".jsonl"));
|
|
@@ -156,7 +343,7 @@ function textResult(text) {
|
|
|
156
343
|
}
|
|
157
344
|
//#endregion
|
|
158
345
|
//#region src/server/tools/clear-renders.ts
|
|
159
|
-
function register$
|
|
346
|
+
function register$5(server) {
|
|
160
347
|
server.registerTool("clear_renders", {
|
|
161
348
|
title: "Clear Renders",
|
|
162
349
|
description: "Clears collected render data. If multiple projects are active and no project is specified, the tool will ask you to disambiguate.",
|
|
@@ -169,8 +356,23 @@ function register$3(server) {
|
|
|
169
356
|
});
|
|
170
357
|
}
|
|
171
358
|
//#endregion
|
|
359
|
+
//#region src/server/tools/get-commits.ts
|
|
360
|
+
function register$4(server) {
|
|
361
|
+
server.registerTool("get_commits", {
|
|
362
|
+
title: "Get Commits",
|
|
363
|
+
description: "Returns a list of React commit IDs that have recorded render data for a project. Use these IDs with get_renders_by_commit to inspect individual commits.",
|
|
364
|
+
inputSchema: { project: z.string().optional().describe("Project identifier (the browser's origin URL, e.g. http://localhost:3000). Omit to auto-detect.") }
|
|
365
|
+
}, async ({ project }) => {
|
|
366
|
+
const resolved = resolveProject(project);
|
|
367
|
+
if (resolved.error) return textResult(resolved.error);
|
|
368
|
+
const commitIds = store.getCommitIds(resolved.projectId);
|
|
369
|
+
if (commitIds.length === 0) return textResult("No commits recorded yet. Make sure the browser is connected and triggering re-renders.");
|
|
370
|
+
return textResult(JSON.stringify(commitIds));
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
//#endregion
|
|
172
374
|
//#region src/server/tools/get-projects.ts
|
|
173
|
-
function register$
|
|
375
|
+
function register$3(server) {
|
|
174
376
|
server.registerTool("get_projects", {
|
|
175
377
|
title: "Get Projects",
|
|
176
378
|
description: "Returns a list of project identifiers (browser origin URLs) that have recorded render data.",
|
|
@@ -183,7 +385,7 @@ function register$2(server) {
|
|
|
183
385
|
}
|
|
184
386
|
//#endregion
|
|
185
387
|
//#region src/server/tools/get-render-summary.ts
|
|
186
|
-
function register$
|
|
388
|
+
function register$2(server) {
|
|
187
389
|
server.registerTool("get_render_summary", {
|
|
188
390
|
title: "Get Render Summary",
|
|
189
391
|
description: "Returns a summary of unnecessary re-renders grouped by component name with counts. If multiple projects are active and no project is specified, the tool will ask you to disambiguate.",
|
|
@@ -202,6 +404,26 @@ function register$1(server) {
|
|
|
202
404
|
});
|
|
203
405
|
}
|
|
204
406
|
//#endregion
|
|
407
|
+
//#region src/server/tools/get-renders-by-commit.ts
|
|
408
|
+
function register$1(server) {
|
|
409
|
+
server.registerTool("get_renders_by_commit", {
|
|
410
|
+
title: "Get Renders by Commit",
|
|
411
|
+
description: "Returns all unnecessary re-renders for a specific React commit ID. Use get_commits first to discover available commit IDs.",
|
|
412
|
+
inputSchema: {
|
|
413
|
+
commitId: z.number().describe("The React commit ID to filter by."),
|
|
414
|
+
component: z.string().optional().describe("Filter by component name. Omit to get all renders."),
|
|
415
|
+
project: z.string().optional().describe("Project identifier (the browser's origin URL, e.g. http://localhost:3000). Omit to auto-detect.")
|
|
416
|
+
}
|
|
417
|
+
}, async ({ commitId, component, project }) => {
|
|
418
|
+
const resolved = resolveProject(project);
|
|
419
|
+
if (resolved.error) return textResult(resolved.error);
|
|
420
|
+
let renders = store.getRendersByCommit(commitId, resolved.projectId);
|
|
421
|
+
if (component) renders = renders.filter((r) => r.displayName === component);
|
|
422
|
+
if (renders.length === 0) return textResult(component ? `No renders recorded for component "${component}" in commit ${commitId}.` : `No renders recorded for commit ${commitId}.`);
|
|
423
|
+
return textResult(JSON.stringify(renders, null, 2));
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
//#endregion
|
|
205
427
|
//#region src/server/tools/get-unnecessary-renders.ts
|
|
206
428
|
function register(server) {
|
|
207
429
|
server.registerTool("get_unnecessary_renders", {
|
|
@@ -223,9 +445,11 @@ function register(server) {
|
|
|
223
445
|
//#region src/server/tools/index.ts
|
|
224
446
|
function registerTools(server) {
|
|
225
447
|
register(server);
|
|
226
|
-
register$1(server);
|
|
227
448
|
register$2(server);
|
|
449
|
+
register$4(server);
|
|
450
|
+
register$1(server);
|
|
228
451
|
register$3(server);
|
|
452
|
+
register$5(server);
|
|
229
453
|
}
|
|
230
454
|
//#endregion
|
|
231
455
|
//#region src/server/liveness.ts
|
|
@@ -300,11 +524,10 @@ function createWsServer(port) {
|
|
|
300
524
|
ws.on("message", (raw) => {
|
|
301
525
|
try {
|
|
302
526
|
const msg = JSON.parse(String(raw));
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
527
|
+
const projectId = msg.projectId ?? "default";
|
|
528
|
+
heartbeat.setProjectId(ws, projectId);
|
|
529
|
+
if (msg.type === "render") store.addRender(msg.payload, projectId, msg.commitId);
|
|
530
|
+
else if (msg.type === "render-batch") for (const report of msg.payload) store.addRender(report, projectId, msg.commitId);
|
|
308
531
|
} catch {
|
|
309
532
|
console.error("[wdyr-mcp] invalid message received");
|
|
310
533
|
}
|
|
@@ -327,10 +550,22 @@ const server = new McpServer({
|
|
|
327
550
|
});
|
|
328
551
|
registerTools(server);
|
|
329
552
|
async function main() {
|
|
330
|
-
createWsServer(Number(process.env.WDYR_WS_PORT) || DEFAULT_WS_PORT);
|
|
553
|
+
const wss = createWsServer(Number(process.env.WDYR_WS_PORT) || DEFAULT_WS_PORT);
|
|
331
554
|
const transport = new StdioServerTransport();
|
|
332
555
|
await server.connect(transport);
|
|
333
556
|
console.error("[wdyr-mcp] MCP server running on stdio");
|
|
557
|
+
let shuttingDown = false;
|
|
558
|
+
async function shutdown() {
|
|
559
|
+
if (shuttingDown) return;
|
|
560
|
+
shuttingDown = true;
|
|
561
|
+
console.error("[wdyr-mcp] Shutting down…");
|
|
562
|
+
wss?.close();
|
|
563
|
+
await server.close();
|
|
564
|
+
process.exit(0);
|
|
565
|
+
}
|
|
566
|
+
process.stdin.on("end", shutdown);
|
|
567
|
+
process.on("SIGTERM", shutdown);
|
|
568
|
+
process.on("SIGINT", shutdown);
|
|
334
569
|
}
|
|
335
570
|
main().catch((error) => {
|
|
336
571
|
console.error("[wdyr-mcp] Fatal error:", error);
|
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.10",
|
|
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,10 +50,13 @@
|
|
|
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": {
|
|
56
57
|
"@biomejs/biome": "^1.9.4",
|
|
58
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
59
|
+
"@semantic-release/git": "^10.0.1",
|
|
57
60
|
"@types/node": "^22.14.1",
|
|
58
61
|
"@types/ws": "^8.18.0",
|
|
59
62
|
"@vitest/coverage-v8": "^4.1.2",
|