state-surgeon 1.0.0
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/LICENSE +21 -0
- package/README.md +296 -0
- package/dist/dashboard/index.js +1192 -0
- package/dist/dashboard/index.js.map +1 -0
- package/dist/dashboard/index.mjs +1181 -0
- package/dist/dashboard/index.mjs.map +1 -0
- package/dist/index.js +1828 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1798 -0
- package/dist/index.mjs.map +1 -0
- package/dist/instrument/index.js +828 -0
- package/dist/instrument/index.js.map +1 -0
- package/dist/instrument/index.mjs +819 -0
- package/dist/instrument/index.mjs.map +1 -0
- package/dist/recorder/index.js +882 -0
- package/dist/recorder/index.js.map +1 -0
- package/dist/recorder/index.mjs +873 -0
- package/dist/recorder/index.mjs.map +1 -0
- package/package.json +94 -0
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
import { v4 } from 'uuid';
|
|
2
|
+
|
|
3
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
4
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
5
|
+
}) : x)(function(x) {
|
|
6
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
7
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// src/core/diff.ts
|
|
11
|
+
function deepClone(obj) {
|
|
12
|
+
if (obj === null || typeof obj !== "object") {
|
|
13
|
+
return obj;
|
|
14
|
+
}
|
|
15
|
+
if (Array.isArray(obj)) {
|
|
16
|
+
return obj.map((item) => deepClone(item));
|
|
17
|
+
}
|
|
18
|
+
if (obj instanceof Date) {
|
|
19
|
+
return new Date(obj.getTime());
|
|
20
|
+
}
|
|
21
|
+
if (obj instanceof Map) {
|
|
22
|
+
const clonedMap = /* @__PURE__ */ new Map();
|
|
23
|
+
obj.forEach((value, key) => {
|
|
24
|
+
clonedMap.set(deepClone(key), deepClone(value));
|
|
25
|
+
});
|
|
26
|
+
return clonedMap;
|
|
27
|
+
}
|
|
28
|
+
if (obj instanceof Set) {
|
|
29
|
+
const clonedSet = /* @__PURE__ */ new Set();
|
|
30
|
+
obj.forEach((value) => {
|
|
31
|
+
clonedSet.add(deepClone(value));
|
|
32
|
+
});
|
|
33
|
+
return clonedSet;
|
|
34
|
+
}
|
|
35
|
+
const cloned = {};
|
|
36
|
+
for (const key in obj) {
|
|
37
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
38
|
+
cloned[key] = deepClone(obj[key]);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return cloned;
|
|
42
|
+
}
|
|
43
|
+
function calculateDiff(before, after, path = "") {
|
|
44
|
+
const diffs = [];
|
|
45
|
+
if (before === after) {
|
|
46
|
+
return diffs;
|
|
47
|
+
}
|
|
48
|
+
if (before === null || before === void 0 || typeof before !== "object") {
|
|
49
|
+
if (after === null || after === void 0 || typeof after !== "object") {
|
|
50
|
+
if (before !== after) {
|
|
51
|
+
if (before === void 0) {
|
|
52
|
+
diffs.push({ path: path || "root", operation: "ADD", newValue: after });
|
|
53
|
+
} else if (after === void 0) {
|
|
54
|
+
diffs.push({ path: path || "root", operation: "REMOVE", oldValue: before });
|
|
55
|
+
} else {
|
|
56
|
+
diffs.push({ path: path || "root", operation: "UPDATE", oldValue: before, newValue: after });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return diffs;
|
|
60
|
+
}
|
|
61
|
+
diffs.push({ path: path || "root", operation: "UPDATE", oldValue: before, newValue: after });
|
|
62
|
+
return diffs;
|
|
63
|
+
}
|
|
64
|
+
if (after === null || after === void 0 || typeof after !== "object") {
|
|
65
|
+
diffs.push({ path: path || "root", operation: "UPDATE", oldValue: before, newValue: after });
|
|
66
|
+
return diffs;
|
|
67
|
+
}
|
|
68
|
+
if (Array.isArray(before) || Array.isArray(after)) {
|
|
69
|
+
if (!Array.isArray(before) || !Array.isArray(after)) {
|
|
70
|
+
diffs.push({ path: path || "root", operation: "UPDATE", oldValue: before, newValue: after });
|
|
71
|
+
return diffs;
|
|
72
|
+
}
|
|
73
|
+
const maxLength = Math.max(before.length, after.length);
|
|
74
|
+
for (let i = 0; i < maxLength; i++) {
|
|
75
|
+
const itemPath = path ? `${path}[${i}]` : `[${i}]`;
|
|
76
|
+
if (i >= before.length) {
|
|
77
|
+
diffs.push({ path: itemPath, operation: "ADD", newValue: after[i] });
|
|
78
|
+
} else if (i >= after.length) {
|
|
79
|
+
diffs.push({ path: itemPath, operation: "REMOVE", oldValue: before[i] });
|
|
80
|
+
} else {
|
|
81
|
+
diffs.push(...calculateDiff(before[i], after[i], itemPath));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return diffs;
|
|
85
|
+
}
|
|
86
|
+
const beforeObj = before;
|
|
87
|
+
const afterObj = after;
|
|
88
|
+
const allKeys = /* @__PURE__ */ new Set([...Object.keys(beforeObj), ...Object.keys(afterObj)]);
|
|
89
|
+
for (const key of allKeys) {
|
|
90
|
+
const keyPath = path ? `${path}.${key}` : key;
|
|
91
|
+
const beforeValue = beforeObj[key];
|
|
92
|
+
const afterValue = afterObj[key];
|
|
93
|
+
if (!(key in beforeObj)) {
|
|
94
|
+
diffs.push({ path: keyPath, operation: "ADD", newValue: afterValue });
|
|
95
|
+
} else if (!(key in afterObj)) {
|
|
96
|
+
diffs.push({ path: keyPath, operation: "REMOVE", oldValue: beforeValue });
|
|
97
|
+
} else {
|
|
98
|
+
diffs.push(...calculateDiff(beforeValue, afterValue, keyPath));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return diffs;
|
|
102
|
+
}
|
|
103
|
+
var logicalClock = 0;
|
|
104
|
+
function createMutation(options) {
|
|
105
|
+
const {
|
|
106
|
+
source,
|
|
107
|
+
sessionId,
|
|
108
|
+
previousState,
|
|
109
|
+
nextState,
|
|
110
|
+
actionType = "CUSTOM",
|
|
111
|
+
actionPayload,
|
|
112
|
+
component,
|
|
113
|
+
function: funcName,
|
|
114
|
+
captureStack = true,
|
|
115
|
+
metadata
|
|
116
|
+
} = options;
|
|
117
|
+
logicalClock++;
|
|
118
|
+
const mutation = {
|
|
119
|
+
id: `mut_${Date.now()}_${v4().slice(0, 8)}`,
|
|
120
|
+
timestamp: typeof performance !== "undefined" ? performance.now() : Date.now(),
|
|
121
|
+
logicalClock,
|
|
122
|
+
sessionId,
|
|
123
|
+
source,
|
|
124
|
+
component,
|
|
125
|
+
function: funcName,
|
|
126
|
+
actionType,
|
|
127
|
+
actionPayload,
|
|
128
|
+
previousState,
|
|
129
|
+
nextState,
|
|
130
|
+
metadata
|
|
131
|
+
};
|
|
132
|
+
if (captureStack) {
|
|
133
|
+
mutation.callStack = parseCallStack(new Error().stack);
|
|
134
|
+
}
|
|
135
|
+
return mutation;
|
|
136
|
+
}
|
|
137
|
+
function parseCallStack(stack) {
|
|
138
|
+
if (!stack) return [];
|
|
139
|
+
return stack.split("\n").slice(2).map((line) => line.trim()).filter((line) => line.startsWith("at ")).map((line) => {
|
|
140
|
+
const match = line.match(/at\s+(.+?)\s+\((.+):(\d+):(\d+)\)/);
|
|
141
|
+
if (match) {
|
|
142
|
+
return `${match[1]} (${match[2].split("/").pop()}:${match[3]})`;
|
|
143
|
+
}
|
|
144
|
+
const simpleMatch = line.match(/at\s+(.+):(\d+):(\d+)/);
|
|
145
|
+
if (simpleMatch) {
|
|
146
|
+
return `anonymous (${simpleMatch[1].split("/").pop()}:${simpleMatch[2]})`;
|
|
147
|
+
}
|
|
148
|
+
return line.replace("at ", "");
|
|
149
|
+
}).slice(0, 10);
|
|
150
|
+
}
|
|
151
|
+
function generateSessionId() {
|
|
152
|
+
return `session_${Date.now()}_${v4().slice(0, 8)}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// src/instrument/transport.ts
|
|
156
|
+
var MutationTransport = class {
|
|
157
|
+
constructor(url, options = {}) {
|
|
158
|
+
this.ws = null;
|
|
159
|
+
this.buffer = [];
|
|
160
|
+
this.flushTimer = null;
|
|
161
|
+
this.reconnectAttempts = 0;
|
|
162
|
+
this.isConnecting = false;
|
|
163
|
+
this.shouldReconnect = true;
|
|
164
|
+
this.url = url;
|
|
165
|
+
this.options = {
|
|
166
|
+
batchSize: options.batchSize ?? 50,
|
|
167
|
+
flushInterval: options.flushInterval ?? 100,
|
|
168
|
+
reconnectDelay: options.reconnectDelay ?? 1e3,
|
|
169
|
+
maxReconnectAttempts: options.maxReconnectAttempts ?? 10,
|
|
170
|
+
onConnect: options.onConnect ?? (() => {
|
|
171
|
+
}),
|
|
172
|
+
onDisconnect: options.onDisconnect ?? (() => {
|
|
173
|
+
}),
|
|
174
|
+
onError: options.onError ?? (() => {
|
|
175
|
+
}),
|
|
176
|
+
onMessage: options.onMessage ?? (() => {
|
|
177
|
+
})
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Establishes WebSocket connection
|
|
182
|
+
*/
|
|
183
|
+
connect() {
|
|
184
|
+
if (this.ws || this.isConnecting) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
this.isConnecting = true;
|
|
188
|
+
this.shouldReconnect = true;
|
|
189
|
+
try {
|
|
190
|
+
const WebSocketImpl = typeof WebSocket !== "undefined" ? WebSocket : __require("ws");
|
|
191
|
+
this.ws = new WebSocketImpl(this.url);
|
|
192
|
+
this.ws.onopen = () => {
|
|
193
|
+
this.isConnecting = false;
|
|
194
|
+
this.reconnectAttempts = 0;
|
|
195
|
+
this.startFlushTimer();
|
|
196
|
+
this.options.onConnect();
|
|
197
|
+
};
|
|
198
|
+
this.ws.onclose = () => {
|
|
199
|
+
this.isConnecting = false;
|
|
200
|
+
this.stopFlushTimer();
|
|
201
|
+
this.ws = null;
|
|
202
|
+
this.options.onDisconnect();
|
|
203
|
+
this.attemptReconnect();
|
|
204
|
+
};
|
|
205
|
+
this.ws.onerror = (event) => {
|
|
206
|
+
this.isConnecting = false;
|
|
207
|
+
const error = new Error("WebSocket error");
|
|
208
|
+
this.options.onError(error);
|
|
209
|
+
};
|
|
210
|
+
this.ws.onmessage = (event) => {
|
|
211
|
+
try {
|
|
212
|
+
const message = JSON.parse(event.data);
|
|
213
|
+
this.options.onMessage(message);
|
|
214
|
+
} catch (e) {
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
} catch (error) {
|
|
218
|
+
this.isConnecting = false;
|
|
219
|
+
this.options.onError(error instanceof Error ? error : new Error(String(error)));
|
|
220
|
+
this.attemptReconnect();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Closes the WebSocket connection
|
|
225
|
+
*/
|
|
226
|
+
disconnect() {
|
|
227
|
+
this.shouldReconnect = false;
|
|
228
|
+
this.stopFlushTimer();
|
|
229
|
+
if (this.ws) {
|
|
230
|
+
this.ws.close();
|
|
231
|
+
this.ws = null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Sends a message (adds to buffer)
|
|
236
|
+
*/
|
|
237
|
+
send(message) {
|
|
238
|
+
this.buffer.push(message);
|
|
239
|
+
if (this.buffer.length >= this.options.batchSize) {
|
|
240
|
+
this.flush();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Flushes the buffer immediately
|
|
245
|
+
*/
|
|
246
|
+
flush() {
|
|
247
|
+
if (this.buffer.length === 0) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (this.ws && this.ws.readyState === 1) {
|
|
251
|
+
try {
|
|
252
|
+
const batch = {
|
|
253
|
+
type: "MUTATION_BATCH",
|
|
254
|
+
payload: {
|
|
255
|
+
mutations: this.buffer.map((m) => m.payload),
|
|
256
|
+
timestamp: Date.now()
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
this.ws.send(JSON.stringify(batch));
|
|
260
|
+
this.buffer = [];
|
|
261
|
+
} catch (error) {
|
|
262
|
+
this.options.onError(
|
|
263
|
+
error instanceof Error ? error : new Error(String(error))
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Checks if transport is connected
|
|
270
|
+
*/
|
|
271
|
+
isConnected() {
|
|
272
|
+
return this.ws !== null && this.ws.readyState === 1;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Gets the current buffer size
|
|
276
|
+
*/
|
|
277
|
+
getBufferSize() {
|
|
278
|
+
return this.buffer.length;
|
|
279
|
+
}
|
|
280
|
+
startFlushTimer() {
|
|
281
|
+
if (this.flushTimer) {
|
|
282
|
+
clearInterval(this.flushTimer);
|
|
283
|
+
}
|
|
284
|
+
this.flushTimer = setInterval(() => {
|
|
285
|
+
this.flush();
|
|
286
|
+
}, this.options.flushInterval);
|
|
287
|
+
}
|
|
288
|
+
stopFlushTimer() {
|
|
289
|
+
if (this.flushTimer) {
|
|
290
|
+
clearInterval(this.flushTimer);
|
|
291
|
+
this.flushTimer = null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
attemptReconnect() {
|
|
295
|
+
if (!this.shouldReconnect) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
|
|
299
|
+
this.options.onError(new Error("Max reconnection attempts reached"));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
this.reconnectAttempts++;
|
|
303
|
+
const delay = this.options.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1);
|
|
304
|
+
setTimeout(() => {
|
|
305
|
+
if (this.shouldReconnect) {
|
|
306
|
+
this.connect();
|
|
307
|
+
}
|
|
308
|
+
}, Math.min(delay, 3e4));
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// src/instrument/client.ts
|
|
313
|
+
var StateSurgeonClient = class {
|
|
314
|
+
constructor(options = {}) {
|
|
315
|
+
this.transport = null;
|
|
316
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
317
|
+
this.isConnected = false;
|
|
318
|
+
// Track mutations locally for offline support
|
|
319
|
+
this.localMutations = [];
|
|
320
|
+
this.maxLocalMutations = 1e4;
|
|
321
|
+
this.sessionId = options.sessionId || generateSessionId();
|
|
322
|
+
this.appId = options.appId || "default";
|
|
323
|
+
this.debug = options.debug || false;
|
|
324
|
+
this.serverUrl = options.serverUrl || "ws://localhost:8081";
|
|
325
|
+
this.transportOptions = {
|
|
326
|
+
batchSize: options.batchSize || 50,
|
|
327
|
+
flushInterval: options.flushInterval || 100,
|
|
328
|
+
...options.transport
|
|
329
|
+
};
|
|
330
|
+
if (options.autoConnect !== false) {
|
|
331
|
+
this.connect();
|
|
332
|
+
}
|
|
333
|
+
this.log("State Surgeon Client initialized", { sessionId: this.sessionId, appId: this.appId });
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Connects to the recorder server
|
|
337
|
+
*/
|
|
338
|
+
connect() {
|
|
339
|
+
if (this.transport) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
this.transport = new MutationTransport(this.serverUrl, {
|
|
343
|
+
...this.transportOptions,
|
|
344
|
+
onConnect: () => {
|
|
345
|
+
this.isConnected = true;
|
|
346
|
+
this.log("Connected to State Surgeon recorder");
|
|
347
|
+
this.sendHandshake();
|
|
348
|
+
},
|
|
349
|
+
onDisconnect: () => {
|
|
350
|
+
this.isConnected = false;
|
|
351
|
+
this.log("Disconnected from State Surgeon recorder");
|
|
352
|
+
},
|
|
353
|
+
onError: (error) => {
|
|
354
|
+
this.log("Transport error:", error);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
this.transport.connect();
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Disconnects from the recorder server
|
|
361
|
+
*/
|
|
362
|
+
disconnect() {
|
|
363
|
+
if (this.transport) {
|
|
364
|
+
this.transport.disconnect();
|
|
365
|
+
this.transport = null;
|
|
366
|
+
}
|
|
367
|
+
this.isConnected = false;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Sends the initial handshake to register the session
|
|
371
|
+
*/
|
|
372
|
+
sendHandshake() {
|
|
373
|
+
if (this.transport) {
|
|
374
|
+
this.transport.send({
|
|
375
|
+
type: "REGISTER_SESSION",
|
|
376
|
+
payload: {
|
|
377
|
+
appId: this.appId,
|
|
378
|
+
sessionId: this.sessionId,
|
|
379
|
+
timestamp: Date.now(),
|
|
380
|
+
userAgent: typeof navigator !== "undefined" ? navigator.userAgent : "node",
|
|
381
|
+
url: typeof window !== "undefined" ? window.location.href : void 0
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Records a mutation
|
|
388
|
+
*/
|
|
389
|
+
recordMutation(mutation) {
|
|
390
|
+
this.localMutations.push(mutation);
|
|
391
|
+
if (this.localMutations.length > this.maxLocalMutations) {
|
|
392
|
+
this.localMutations.shift();
|
|
393
|
+
}
|
|
394
|
+
for (const listener of this.listeners) {
|
|
395
|
+
try {
|
|
396
|
+
listener(mutation);
|
|
397
|
+
} catch (error) {
|
|
398
|
+
this.log("Listener error:", error);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (this.transport && this.isConnected) {
|
|
402
|
+
this.transport.send({
|
|
403
|
+
type: "MUTATION_RECORDED",
|
|
404
|
+
payload: mutation
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
this.log("Mutation recorded:", mutation.id, mutation.source);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Creates and records a mutation from state change
|
|
411
|
+
*/
|
|
412
|
+
captureStateChange(source, previousState, nextState, options = {}) {
|
|
413
|
+
const mutation = createMutation({
|
|
414
|
+
source,
|
|
415
|
+
sessionId: this.sessionId,
|
|
416
|
+
previousState: deepClone(previousState),
|
|
417
|
+
nextState: deepClone(nextState),
|
|
418
|
+
actionType: options.actionType || "CUSTOM",
|
|
419
|
+
actionPayload: options.actionPayload,
|
|
420
|
+
component: options.component,
|
|
421
|
+
function: options.function,
|
|
422
|
+
captureStack: true
|
|
423
|
+
});
|
|
424
|
+
mutation.diff = calculateDiff(mutation.previousState, mutation.nextState);
|
|
425
|
+
this.recordMutation(mutation);
|
|
426
|
+
return mutation;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Adds a mutation listener
|
|
430
|
+
*/
|
|
431
|
+
addListener(listener) {
|
|
432
|
+
this.listeners.add(listener);
|
|
433
|
+
return () => this.listeners.delete(listener);
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Gets the current session ID
|
|
437
|
+
*/
|
|
438
|
+
getSessionId() {
|
|
439
|
+
return this.sessionId;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Gets all local mutations
|
|
443
|
+
*/
|
|
444
|
+
getMutations() {
|
|
445
|
+
return [...this.localMutations];
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Clears local mutations
|
|
449
|
+
*/
|
|
450
|
+
clearMutations() {
|
|
451
|
+
this.localMutations = [];
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Checks if connected to server
|
|
455
|
+
*/
|
|
456
|
+
isServerConnected() {
|
|
457
|
+
return this.isConnected;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Flushes any pending mutations
|
|
461
|
+
*/
|
|
462
|
+
flush() {
|
|
463
|
+
if (this.transport) {
|
|
464
|
+
this.transport.flush();
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
log(...args) {
|
|
468
|
+
if (this.debug) {
|
|
469
|
+
console.log("[State Surgeon]", ...args);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
var globalClient = null;
|
|
474
|
+
function getClient(options) {
|
|
475
|
+
if (!globalClient) {
|
|
476
|
+
globalClient = new StateSurgeonClient(options);
|
|
477
|
+
}
|
|
478
|
+
return globalClient;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// src/instrument/api.ts
|
|
482
|
+
var originalFetch = null;
|
|
483
|
+
var fetchInstrumented = false;
|
|
484
|
+
var originalXHROpen = null;
|
|
485
|
+
var originalXHRSend = null;
|
|
486
|
+
var xhrInstrumented = false;
|
|
487
|
+
function instrumentFetch(options = {}) {
|
|
488
|
+
if (fetchInstrumented) {
|
|
489
|
+
console.warn("[State Surgeon] Fetch already instrumented");
|
|
490
|
+
return () => {
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
if (typeof fetch === "undefined") {
|
|
494
|
+
console.warn("[State Surgeon] Fetch not available in this environment");
|
|
495
|
+
return () => {
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
const client = options.client || getClient();
|
|
499
|
+
const ignoreUrls = options.ignoreUrls || [];
|
|
500
|
+
const captureRequestBody = options.captureRequestBody !== false;
|
|
501
|
+
const captureResponseBody = options.captureResponseBody !== false;
|
|
502
|
+
const maxBodySize = options.maxBodySize || 1e4;
|
|
503
|
+
originalFetch = fetch;
|
|
504
|
+
globalThis.fetch = async function instrumentedFetch(input, init) {
|
|
505
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
506
|
+
if (ignoreUrls.some((ignore) => url.includes(ignore))) {
|
|
507
|
+
return originalFetch(input, init);
|
|
508
|
+
}
|
|
509
|
+
const startTime = Date.now();
|
|
510
|
+
const requestId = `req_${startTime}_${Math.random().toString(36).slice(2, 8)}`;
|
|
511
|
+
const requestInfo = {
|
|
512
|
+
url,
|
|
513
|
+
method: init?.method || "GET",
|
|
514
|
+
headers: init?.headers
|
|
515
|
+
};
|
|
516
|
+
if (captureRequestBody && init?.body) {
|
|
517
|
+
requestInfo.body = truncateBody(init.body, maxBodySize);
|
|
518
|
+
}
|
|
519
|
+
try {
|
|
520
|
+
const response = await originalFetch(input, init);
|
|
521
|
+
const duration = Date.now() - startTime;
|
|
522
|
+
const clonedResponse = response.clone();
|
|
523
|
+
const responseInfo = {
|
|
524
|
+
status: response.status,
|
|
525
|
+
statusText: response.statusText,
|
|
526
|
+
headers: Object.fromEntries(response.headers.entries())
|
|
527
|
+
};
|
|
528
|
+
if (captureResponseBody) {
|
|
529
|
+
try {
|
|
530
|
+
const text = await clonedResponse.text();
|
|
531
|
+
responseInfo.body = truncateBody(text, maxBodySize);
|
|
532
|
+
} catch {
|
|
533
|
+
responseInfo.body = "[Unable to read response body]";
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
client.captureStateChange("api", requestInfo, responseInfo, {
|
|
537
|
+
actionType: "API_RESPONSE",
|
|
538
|
+
actionPayload: {
|
|
539
|
+
requestId,
|
|
540
|
+
url,
|
|
541
|
+
method: requestInfo.method,
|
|
542
|
+
status: response.status,
|
|
543
|
+
duration
|
|
544
|
+
},
|
|
545
|
+
function: "fetch"
|
|
546
|
+
});
|
|
547
|
+
return response;
|
|
548
|
+
} catch (error) {
|
|
549
|
+
const duration = Date.now() - startTime;
|
|
550
|
+
client.captureStateChange("api", requestInfo, {
|
|
551
|
+
error: error instanceof Error ? error.message : String(error),
|
|
552
|
+
stack: error instanceof Error ? error.stack : void 0
|
|
553
|
+
}, {
|
|
554
|
+
actionType: "API_RESPONSE",
|
|
555
|
+
actionPayload: {
|
|
556
|
+
requestId,
|
|
557
|
+
url,
|
|
558
|
+
method: requestInfo.method,
|
|
559
|
+
status: 0,
|
|
560
|
+
error: true,
|
|
561
|
+
duration
|
|
562
|
+
},
|
|
563
|
+
function: "fetch"
|
|
564
|
+
});
|
|
565
|
+
throw error;
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
fetchInstrumented = true;
|
|
569
|
+
return () => {
|
|
570
|
+
if (originalFetch) {
|
|
571
|
+
globalThis.fetch = originalFetch;
|
|
572
|
+
originalFetch = null;
|
|
573
|
+
}
|
|
574
|
+
fetchInstrumented = false;
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
function instrumentXHR(options = {}) {
|
|
578
|
+
if (xhrInstrumented) {
|
|
579
|
+
console.warn("[State Surgeon] XHR already instrumented");
|
|
580
|
+
return () => {
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
if (typeof XMLHttpRequest === "undefined") {
|
|
584
|
+
console.warn("[State Surgeon] XMLHttpRequest not available in this environment");
|
|
585
|
+
return () => {
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
const client = options.client || getClient();
|
|
589
|
+
const ignoreUrls = options.ignoreUrls || [];
|
|
590
|
+
const captureResponseBody = options.captureResponseBody !== false;
|
|
591
|
+
const maxBodySize = options.maxBodySize || 1e4;
|
|
592
|
+
originalXHROpen = XMLHttpRequest.prototype.open;
|
|
593
|
+
originalXHRSend = XMLHttpRequest.prototype.send;
|
|
594
|
+
XMLHttpRequest.prototype.open = function(method, url, async = true, username, password) {
|
|
595
|
+
this._stateSurgeon = {
|
|
596
|
+
method,
|
|
597
|
+
url: url.toString(),
|
|
598
|
+
startTime: 0
|
|
599
|
+
};
|
|
600
|
+
return originalXHROpen.call(this, method, url, async, username, password);
|
|
601
|
+
};
|
|
602
|
+
XMLHttpRequest.prototype.send = function(body) {
|
|
603
|
+
const info = this._stateSurgeon;
|
|
604
|
+
if (!info || ignoreUrls.some((ignore) => info.url.includes(ignore))) {
|
|
605
|
+
return originalXHRSend.call(this, body);
|
|
606
|
+
}
|
|
607
|
+
info.startTime = Date.now();
|
|
608
|
+
const requestId = `xhr_${info.startTime}_${Math.random().toString(36).slice(2, 8)}`;
|
|
609
|
+
const requestInfo = {
|
|
610
|
+
url: info.url,
|
|
611
|
+
method: info.method
|
|
612
|
+
};
|
|
613
|
+
this.addEventListener("loadend", () => {
|
|
614
|
+
const duration = Date.now() - info.startTime;
|
|
615
|
+
const responseInfo = {
|
|
616
|
+
status: this.status,
|
|
617
|
+
statusText: this.statusText
|
|
618
|
+
};
|
|
619
|
+
if (captureResponseBody && this.responseText) {
|
|
620
|
+
responseInfo.body = truncateBody(this.responseText, maxBodySize);
|
|
621
|
+
}
|
|
622
|
+
client.captureStateChange("api", requestInfo, responseInfo, {
|
|
623
|
+
actionType: "API_RESPONSE",
|
|
624
|
+
actionPayload: {
|
|
625
|
+
requestId,
|
|
626
|
+
url: info.url,
|
|
627
|
+
method: info.method,
|
|
628
|
+
status: this.status,
|
|
629
|
+
duration
|
|
630
|
+
},
|
|
631
|
+
function: "XMLHttpRequest"
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
return originalXHRSend.call(this, body);
|
|
635
|
+
};
|
|
636
|
+
xhrInstrumented = true;
|
|
637
|
+
return () => {
|
|
638
|
+
if (originalXHROpen) {
|
|
639
|
+
XMLHttpRequest.prototype.open = originalXHROpen;
|
|
640
|
+
originalXHROpen = null;
|
|
641
|
+
}
|
|
642
|
+
if (originalXHRSend) {
|
|
643
|
+
XMLHttpRequest.prototype.send = originalXHRSend;
|
|
644
|
+
originalXHRSend = null;
|
|
645
|
+
}
|
|
646
|
+
xhrInstrumented = false;
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
function truncateBody(body, maxSize) {
|
|
650
|
+
let str;
|
|
651
|
+
if (typeof body === "string") {
|
|
652
|
+
str = body;
|
|
653
|
+
} else if (body instanceof FormData) {
|
|
654
|
+
str = "[FormData]";
|
|
655
|
+
} else if (body instanceof Blob) {
|
|
656
|
+
str = `[Blob: ${body.size} bytes]`;
|
|
657
|
+
} else if (body instanceof ArrayBuffer) {
|
|
658
|
+
str = `[ArrayBuffer: ${body.byteLength} bytes]`;
|
|
659
|
+
} else {
|
|
660
|
+
try {
|
|
661
|
+
str = JSON.stringify(body);
|
|
662
|
+
} catch {
|
|
663
|
+
str = String(body);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (str.length > maxSize) {
|
|
667
|
+
return str.slice(0, maxSize) + "... [truncated]";
|
|
668
|
+
}
|
|
669
|
+
return str;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// src/instrument/react.ts
|
|
673
|
+
var originalUseState = null;
|
|
674
|
+
var originalUseReducer = null;
|
|
675
|
+
var isInstrumented = false;
|
|
676
|
+
function instrumentReact(React, options = {}) {
|
|
677
|
+
if (isInstrumented) {
|
|
678
|
+
console.warn("[State Surgeon] React already instrumented");
|
|
679
|
+
return () => {
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
const client = options.client || getClient();
|
|
683
|
+
const captureComponentName = options.captureComponentName !== false;
|
|
684
|
+
originalUseState = React.useState;
|
|
685
|
+
originalUseReducer = React.useReducer;
|
|
686
|
+
React.useState = function(initialState) {
|
|
687
|
+
const [state, originalSetState] = originalUseState(initialState);
|
|
688
|
+
const instrumentedSetState = (newStateOrUpdater) => {
|
|
689
|
+
const previousState = deepClone(state);
|
|
690
|
+
const newState = typeof newStateOrUpdater === "function" ? newStateOrUpdater(state) : newStateOrUpdater;
|
|
691
|
+
client.captureStateChange("react", previousState, newState, {
|
|
692
|
+
actionType: "SET_STATE",
|
|
693
|
+
component: captureComponentName ? getComponentName() : void 0,
|
|
694
|
+
function: "useState"
|
|
695
|
+
});
|
|
696
|
+
return originalSetState(newStateOrUpdater);
|
|
697
|
+
};
|
|
698
|
+
return [state, instrumentedSetState];
|
|
699
|
+
};
|
|
700
|
+
React.useReducer = function(reducer, initialArg, init) {
|
|
701
|
+
const instrumentedReducer = ((state, action) => {
|
|
702
|
+
const previousState = deepClone(state);
|
|
703
|
+
const newState = reducer(state, action);
|
|
704
|
+
client.captureStateChange("react", previousState, newState, {
|
|
705
|
+
actionType: "DISPATCH",
|
|
706
|
+
actionPayload: action,
|
|
707
|
+
component: captureComponentName ? getComponentName() : void 0,
|
|
708
|
+
function: "useReducer"
|
|
709
|
+
});
|
|
710
|
+
return newState;
|
|
711
|
+
});
|
|
712
|
+
return originalUseReducer(instrumentedReducer, initialArg, init);
|
|
713
|
+
};
|
|
714
|
+
isInstrumented = true;
|
|
715
|
+
return () => {
|
|
716
|
+
if (originalUseState) {
|
|
717
|
+
React.useState = originalUseState;
|
|
718
|
+
}
|
|
719
|
+
if (originalUseReducer) {
|
|
720
|
+
React.useReducer = originalUseReducer;
|
|
721
|
+
}
|
|
722
|
+
isInstrumented = false;
|
|
723
|
+
originalUseState = null;
|
|
724
|
+
originalUseReducer = null;
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
function getComponentName() {
|
|
728
|
+
const stack = new Error().stack;
|
|
729
|
+
if (!stack) return void 0;
|
|
730
|
+
const lines = stack.split("\n");
|
|
731
|
+
for (const line of lines) {
|
|
732
|
+
if (line.includes("instrumentedSetState") || line.includes("instrumentedReducer") || line.includes("useState") || line.includes("useReducer") || line.includes("react-dom") || line.includes("react.development")) {
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
const match = line.match(/at\s+([A-Z][A-Za-z0-9_]*)/);
|
|
736
|
+
if (match) {
|
|
737
|
+
return match[1];
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return void 0;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// src/instrument/redux.ts
|
|
744
|
+
function createReduxMiddleware(options = {}) {
|
|
745
|
+
const client = options.client || getClient();
|
|
746
|
+
const ignoreActions = new Set(options.ignoreActions || []);
|
|
747
|
+
const storeName = options.storeName || "redux";
|
|
748
|
+
return (storeAPI) => (next) => (action) => {
|
|
749
|
+
const actionType = action?.type;
|
|
750
|
+
if (actionType && ignoreActions.has(actionType)) {
|
|
751
|
+
return next(action);
|
|
752
|
+
}
|
|
753
|
+
const previousState = deepClone(storeAPI.getState());
|
|
754
|
+
const startTime = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
755
|
+
const result = next(action);
|
|
756
|
+
const nextState = deepClone(storeAPI.getState());
|
|
757
|
+
const duration = (typeof performance !== "undefined" ? performance.now() : Date.now()) - startTime;
|
|
758
|
+
const mutation = client.captureStateChange("redux", previousState, nextState, {
|
|
759
|
+
actionType: "DISPATCH",
|
|
760
|
+
actionPayload: action,
|
|
761
|
+
component: storeName,
|
|
762
|
+
function: actionType || "dispatch"
|
|
763
|
+
});
|
|
764
|
+
mutation.duration = duration;
|
|
765
|
+
return result;
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
function instrumentReduxStore(store, options = {}) {
|
|
769
|
+
const client = options.client || getClient();
|
|
770
|
+
const ignoreActions = new Set(options.ignoreActions || []);
|
|
771
|
+
const storeName = options.storeName || "redux";
|
|
772
|
+
const originalDispatch = store.dispatch;
|
|
773
|
+
store.dispatch = function instrumentedDispatch(action) {
|
|
774
|
+
const actionType = action?.type;
|
|
775
|
+
if (actionType && ignoreActions.has(actionType)) {
|
|
776
|
+
return originalDispatch(action);
|
|
777
|
+
}
|
|
778
|
+
const previousState = deepClone(store.getState());
|
|
779
|
+
const startTime = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
780
|
+
const result = originalDispatch(action);
|
|
781
|
+
const nextState = deepClone(store.getState());
|
|
782
|
+
const duration = (typeof performance !== "undefined" ? performance.now() : Date.now()) - startTime;
|
|
783
|
+
const mutation = client.captureStateChange("redux", previousState, nextState, {
|
|
784
|
+
actionType: "DISPATCH",
|
|
785
|
+
actionPayload: action,
|
|
786
|
+
component: storeName,
|
|
787
|
+
function: actionType || "dispatch"
|
|
788
|
+
});
|
|
789
|
+
mutation.duration = duration;
|
|
790
|
+
return result;
|
|
791
|
+
};
|
|
792
|
+
return () => {
|
|
793
|
+
store.dispatch = originalDispatch;
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// src/instrument/zustand.ts
|
|
798
|
+
function instrumentZustand(stateCreator, options = {}) {
|
|
799
|
+
const client = options.client || getClient();
|
|
800
|
+
const storeName = options.storeName || "zustand";
|
|
801
|
+
return (set, get, api) => {
|
|
802
|
+
const instrumentedSet = (partial, replace) => {
|
|
803
|
+
const previousState = deepClone(get());
|
|
804
|
+
set(partial, replace);
|
|
805
|
+
const nextState = deepClone(get());
|
|
806
|
+
client.captureStateChange("zustand", previousState, nextState, {
|
|
807
|
+
actionType: "SET_STATE",
|
|
808
|
+
actionPayload: typeof partial === "function" ? "updater function" : partial,
|
|
809
|
+
component: storeName,
|
|
810
|
+
function: "set"
|
|
811
|
+
});
|
|
812
|
+
};
|
|
813
|
+
return stateCreator(instrumentedSet, get, api);
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
export { MutationTransport, StateSurgeonClient, createReduxMiddleware, instrumentFetch, instrumentReact, instrumentReduxStore, instrumentXHR, instrumentZustand };
|
|
818
|
+
//# sourceMappingURL=index.mjs.map
|
|
819
|
+
//# sourceMappingURL=index.mjs.map
|