@buoy-gg/external-sync 7.0.17 → 7.0.19

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.
@@ -0,0 +1,209 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.attachDeviceSync = attachDeviceSync;
7
+ var _logger = require("./utils/logger");
8
+ var _emitBudget = require("./emitBudget");
9
+ var _types = require("./types");
10
+ const LOG = "[ExternalSync]";
11
+
12
+ /** Minimal socket surface the device-side bridge needs. */
13
+
14
+ /**
15
+ * "capture" records into the tool's store without pushing snapshots; "stream"
16
+ * also emits on every change. See `DevToolWatchMessage.stream`.
17
+ */
18
+
19
+ /**
20
+ * Device-side sync bridge (no React). A tool's adapter is only subscribed
21
+ * while a dashboard is watching it (`devtool-watch`).
22
+ */
23
+ function attachDeviceSync({
24
+ socket,
25
+ deviceId,
26
+ getTools,
27
+ watched,
28
+ throttleMs = 200,
29
+ enableLogs = false
30
+ }) {
31
+ const unsubs = new Map();
32
+ const lastEmit = {};
33
+ const timers = {};
34
+ const isForThisDevice = targetDeviceId => targetDeviceId === "All" || targetDeviceId === deviceId;
35
+ const safeEmit = (event, message) => {
36
+ try {
37
+ socket.emit(event, message);
38
+ } catch (error) {
39
+ (0, _logger.log)(`${LOG} Failed to emit "${event}": ${error}. Payload must be JSON-serializable.`, enableLogs, "error");
40
+ }
41
+ };
42
+ const warnedOversized = new Set();
43
+ const sendSnapshot = toolId => {
44
+ const tool = getTools()[toolId];
45
+ if (!tool) return;
46
+ lastEmit[toolId] = Date.now();
47
+ const payload = tool.getSnapshot();
48
+ const {
49
+ over,
50
+ approxBytes
51
+ } = (0, _emitBudget.approxSizeOver)(payload, _emitBudget.MAX_SNAPSHOT_EMIT_BYTES);
52
+ if (over) {
53
+ if (!warnedOversized.has(toolId)) {
54
+ warnedOversized.add(toolId);
55
+ console.warn(`${LOG} "${toolId}" snapshot ≈${(approxBytes / 1e6).toFixed(1)}MB exceeds the ${_emitBudget.MAX_SNAPSHOT_EMIT_BYTES / 1e6}MB sync budget — dropped (dashboard will be stale for this tool). The adapter should ship a wire-form snapshot (summaries + on-demand detail).`);
56
+ }
57
+ return;
58
+ }
59
+ const message = {
60
+ toolId,
61
+ persistentDeviceId: deviceId,
62
+ timestamp: lastEmit[toolId],
63
+ version: tool.version,
64
+ kind: "snapshot",
65
+ payload
66
+ };
67
+ safeEmit("devtool-sync", message);
68
+ };
69
+ const scheduleSnapshot = toolId => {
70
+ if (timers[toolId]) return;
71
+ const elapsed = Date.now() - (lastEmit[toolId] ?? 0);
72
+ if (elapsed >= throttleMs) {
73
+ sendSnapshot(toolId);
74
+ return;
75
+ }
76
+ timers[toolId] = setTimeout(() => {
77
+ delete timers[toolId];
78
+ sendSnapshot(toolId);
79
+ }, throttleMs - elapsed);
80
+ };
81
+
82
+ /** The mode each currently-subscribed tool is wired for. */
83
+ const activeModes = new Map();
84
+ const watchTool = (toolId, mode) => {
85
+ const tool = getTools()[toolId];
86
+ if (!tool) return;
87
+ if (unsubs.has(toolId)) {
88
+ if (activeModes.get(toolId) === mode) return;
89
+ // Mode changed (a streaming dashboard left, or arrived) — rewire, but
90
+ // do NOT drop the subscription in between: releasing it would take the
91
+ // store's subscriber count to 0 and stop capture mid-flight.
92
+ const previous = unsubs.get(toolId);
93
+ unsubs.set(toolId, tool.subscribe(mode === "stream" ? () => scheduleSnapshot(toolId) : () => {}));
94
+ activeModes.set(toolId, mode);
95
+ previous?.();
96
+ (0, _logger.log)(`${LOG} "${toolId}" watch mode → ${mode}`, enableLogs);
97
+ return;
98
+ }
99
+ unsubs.set(toolId, tool.subscribe(mode === "stream" ? () => scheduleSnapshot(toolId) : () => {}));
100
+ activeModes.set(toolId, mode);
101
+ (0, _logger.log)(`${LOG} Watching "${toolId}" (${mode})`, enableLogs);
102
+ };
103
+ const unwatchTool = toolId => {
104
+ unsubs.get(toolId)?.();
105
+ unsubs.delete(toolId);
106
+ activeModes.delete(toolId);
107
+ if (timers[toolId]) {
108
+ clearTimeout(timers[toolId]);
109
+ delete timers[toolId];
110
+ }
111
+ (0, _logger.log)(`${LOG} Stopped watching "${toolId}"`, enableLogs);
112
+ };
113
+ const sendCapabilities = () => {
114
+ const message = {
115
+ persistentDeviceId: deviceId,
116
+ protocolVersion: _types.SYNC_PROTOCOL_VERSION,
117
+ tools: Object.entries(getTools()).map(([toolId, tool]) => ({
118
+ toolId,
119
+ version: tool.version,
120
+ actions: Object.keys(tool.actions ?? {})
121
+ }))
122
+ };
123
+ safeEmit("devtool-capabilities", message);
124
+ };
125
+ const handleWatch = message => {
126
+ if (!isForThisDevice(message.targetDeviceId)) return;
127
+ if (message.watching) {
128
+ // `stream` is only trustworthy once a broker has merged every
129
+ // dashboard's request (see DevToolWatchMessage.consolidated). Without
130
+ // that, an unmerged capture request could quiet a dashboard that is
131
+ // actively painting, so default to streaming.
132
+ const mode = message.consolidated === true && message.stream === false ? "capture" : "stream";
133
+ watched.set(message.toolId, mode);
134
+ watchTool(message.toolId, mode);
135
+ // Only a streaming audience gets the priming snapshot. A capture-only
136
+ // client reads with `request-devtool-state` when it actually wants one;
137
+ // pushing here would hand it the very payload it asked not to receive.
138
+ if (mode === "stream") sendSnapshot(message.toolId);
139
+ } else {
140
+ watched.delete(message.toolId);
141
+ unwatchTool(message.toolId);
142
+ }
143
+ };
144
+ const handleRequest = message => {
145
+ if (!isForThisDevice(message.targetDeviceId)) return;
146
+ sendSnapshot(message.toolId);
147
+ };
148
+ const handleAction = async message => {
149
+ if (!isForThisDevice(message.targetDeviceId)) return;
150
+ const {
151
+ requestId,
152
+ toolId,
153
+ action,
154
+ params
155
+ } = message;
156
+ const result = {
157
+ requestId,
158
+ toolId,
159
+ action,
160
+ persistentDeviceId: deviceId,
161
+ timestamp: Date.now(),
162
+ ok: false
163
+ };
164
+ const tool = getTools()[toolId];
165
+ const fn = tool?.actions?.[action];
166
+ if (!fn) {
167
+ result.error = tool ? `Unknown action "${action}" on tool "${toolId}"` : `Unknown tool "${toolId}"`;
168
+ safeEmit("devtool-action-result", result);
169
+ return;
170
+ }
171
+ try {
172
+ result.result = await fn(params);
173
+ result.ok = true;
174
+ } catch (error) {
175
+ result.error = error instanceof Error ? error.message : String(error);
176
+ }
177
+ if (result.ok) {
178
+ const {
179
+ over,
180
+ approxBytes
181
+ } = (0, _emitBudget.approxSizeOver)(result.result, _emitBudget.MAX_ACTION_RESULT_BYTES);
182
+ if (over) {
183
+ result.ok = false;
184
+ result.result = undefined;
185
+ result.error = `Result ≈${(approxBytes / 1e6).toFixed(1)}MB exceeds the ${_emitBudget.MAX_ACTION_RESULT_BYTES / 1e6}MB sync budget. Narrow the request (e.g. includeValues:false, a limit, or a more specific action).`;
186
+ }
187
+ }
188
+ result.timestamp = Date.now();
189
+ safeEmit("devtool-action-result", result);
190
+ };
191
+ const handleConnect = () => {
192
+ sendCapabilities();
193
+ };
194
+ if (socket.connected) sendCapabilities();
195
+ for (const [toolId, mode] of watched) {
196
+ if (getTools()[toolId]) watchTool(toolId, mode);
197
+ }
198
+ socket.on("connect", handleConnect);
199
+ socket.on("devtool-watch", handleWatch);
200
+ socket.on("request-devtool-state", handleRequest);
201
+ socket.on("devtool-action", handleAction);
202
+ return () => {
203
+ socket.off("connect", handleConnect);
204
+ socket.off("devtool-watch", handleWatch);
205
+ socket.off("request-devtool-state", handleRequest);
206
+ socket.off("devtool-action", handleAction);
207
+ for (const toolId of [...unsubs.keys()]) unwatchTool(toolId);
208
+ };
209
+ }
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.MAX_SNAPSHOT_EMIT_BYTES = exports.MAX_ACTION_RESULT_BYTES = void 0;
7
+ exports.approxSizeOver = approxSizeOver;
8
+ /**
9
+ * Emit-layer byte budgets — the choke-point guard.
10
+ *
11
+ * Every adapter's snapshot and every action result funnels through
12
+ * socket.emit, whose serializer walks the ENTIRE payload synchronously on
13
+ * the JS thread (and JSON.stringify does not honor structural sharing).
14
+ * Per-adapter guards get missed — the redux adapter shipping raw state
15
+ * trees froze and native-OOM-crashed enterprise apps whenever a dashboard
16
+ * was connected. One budget here closes every adapter's hole at once:
17
+ * oversized payloads are dropped (snapshots) or replaced with an error
18
+ * (action results) instead of being serialized.
19
+ *
20
+ * The estimator is O(limit) — early-abort walk, O(1) for giant strings —
21
+ * never O(payload). Copied per-package by design (see events/redux copies).
22
+ */
23
+
24
+ /** Max approx bytes for one tool snapshot emit. */
25
+ const MAX_SNAPSHOT_EMIT_BYTES = exports.MAX_SNAPSHOT_EMIT_BYTES = 2 * 1024 * 1024;
26
+ /** Max approx bytes for one action-result emit (explicit requests get more). */
27
+ const MAX_ACTION_RESULT_BYTES = exports.MAX_ACTION_RESULT_BYTES = 8 * 1024 * 1024;
28
+ function approxSizeOver(value, limit) {
29
+ let bytes = 0;
30
+ const stack = [value];
31
+ const seen = new Set();
32
+ while (stack.length > 0) {
33
+ if (bytes > limit) return {
34
+ over: true,
35
+ approxBytes: bytes
36
+ };
37
+ const v = stack.pop();
38
+ if (v === null || v === undefined) bytes += 4;else if (typeof v === "string") bytes += v.length + 2;else if (typeof v === "number") bytes += 8;else if (typeof v === "boolean") bytes += 5;else if (typeof v === "object") {
39
+ if (seen.has(v)) continue;
40
+ seen.add(v);
41
+ if (Array.isArray(v)) {
42
+ bytes += 2 + v.length;
43
+ for (let i = 0; i < v.length; i++) stack.push(v[i]);
44
+ } else {
45
+ bytes += 2;
46
+ for (const k in v) {
47
+ bytes += k.length + 4;
48
+ stack.push(v[k]);
49
+ }
50
+ }
51
+ } else bytes += 4;
52
+ }
53
+ return {
54
+ over: bytes > limit,
55
+ approxBytes: bytes
56
+ };
57
+ }
@@ -61,7 +61,20 @@ const SYNC_PROTOCOL_VERSION = exports.SYNC_PROTOCOL_VERSION = 1;
61
61
  // DASHBOARD -> DEVICE MESSAGES
62
62
  // ============================================================
63
63
 
64
- /** Server-consolidated signal that a tool gained/lost its audience. */
64
+ /**
65
+ * Server-consolidated signal that a tool gained/lost its audience.
66
+ *
67
+ * `watching` arms CAPTURE: the device subscribes to the tool, which is what
68
+ * makes its store start recording. `stream` decides whether the device also
69
+ * PUSHES a snapshot on every change (throttled), which is the expensive half —
70
+ * it serializes the whole payload on the JS thread ~5x/sec.
71
+ *
72
+ * Those are separate because their audiences are: a dashboard renders every
73
+ * snapshot, so it wants both. A request/response client (MCP) reads on demand
74
+ * via `request-devtool-state` and would throw the stream away — but it still
75
+ * needs the device to be RECORDING between its reads, or it only ever sees an
76
+ * empty list and concludes the app made no requests.
77
+ */
65
78
 
66
79
  /** Explicit request for a tool's current state (works even while unwatched). */
67
80
 
@@ -5,61 +5,7 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.useExternalSync = useExternalSync;
7
7
  var _react = require("react");
8
- var _logger = require("./utils/logger");
9
- var _types = require("./types");
10
- const LOG = "[ExternalSync]";
11
-
12
- // ==========================================================================
13
- // Emit-layer byte budgets — the choke-point guard.
14
- //
15
- // Every adapter's snapshot and every action result funnels through
16
- // socket.emit, whose serializer walks the ENTIRE payload synchronously on
17
- // the JS thread (and JSON.stringify does not honor structural sharing).
18
- // Per-adapter guards get missed — the redux adapter shipping raw state
19
- // trees froze and native-OOM-crashed enterprise apps whenever a dashboard
20
- // was connected. One budget here closes every adapter's hole at once:
21
- // oversized payloads are dropped (snapshots) or replaced with an error
22
- // (action results) instead of being serialized.
23
- //
24
- // The estimator is O(limit) — early-abort walk, O(1) for giant strings —
25
- // never O(payload). Copied per-package by design (see events/redux copies).
26
- // ==========================================================================
27
-
28
- /** Max approx bytes for one tool snapshot emit. */
29
- const MAX_SNAPSHOT_EMIT_BYTES = 2 * 1024 * 1024;
30
- /** Max approx bytes for one action-result emit (explicit requests get more). */
31
- const MAX_ACTION_RESULT_BYTES = 8 * 1024 * 1024;
32
- function approxSizeOver(value, limit) {
33
- let bytes = 0;
34
- const stack = [value];
35
- const seen = new Set();
36
- while (stack.length > 0) {
37
- if (bytes > limit) return {
38
- over: true,
39
- approxBytes: bytes
40
- };
41
- const v = stack.pop();
42
- if (v === null || v === undefined) bytes += 4;else if (typeof v === "string") bytes += v.length + 2;else if (typeof v === "number") bytes += 8;else if (typeof v === "boolean") bytes += 5;else if (typeof v === "object") {
43
- if (seen.has(v)) continue;
44
- seen.add(v);
45
- if (Array.isArray(v)) {
46
- bytes += 2 + v.length;
47
- for (let i = 0; i < v.length; i++) stack.push(v[i]);
48
- } else {
49
- bytes += 2;
50
- for (const k in v) {
51
- bytes += k.length + 4;
52
- stack.push(v[k]);
53
- }
54
- }
55
- } else bytes += 4;
56
- }
57
- return {
58
- over: bytes > limit,
59
- approxBytes: bytes
60
- };
61
- }
62
-
8
+ var _attachDeviceSync = require("./attachDeviceSync");
63
9
  /**
64
10
  * Device-side sync bridge: syncs any number of dev tools to the desktop
65
11
  * dashboard and executes actions invoked remotely.
@@ -100,183 +46,21 @@ function useExternalSync({
100
46
 
101
47
  // Watch state persists across effect re-runs (e.g. when tools are
102
48
  // added/removed) so an already-watched tool stays subscribed.
103
- const watchedRef = (0, _react.useRef)(new Set());
49
+ const watchedRef = (0, _react.useRef)(new Map());
104
50
 
105
51
  // Re-run the effect only when tools are added/removed, not on every
106
52
  // object-reference change.
107
53
  const toolIdsKey = Object.keys(tools).sort().join(",");
108
54
  (0, _react.useEffect)(() => {
109
55
  if (!socket || !deviceId || !enabled) return;
110
- const unsubs = new Map();
111
- const lastEmit = {};
112
- const timers = {};
113
- const isForThisDevice = targetDeviceId => targetDeviceId === "All" || targetDeviceId === deviceId;
114
- const safeEmit = (event, message) => {
115
- try {
116
- socket.emit(event, message);
117
- } catch (error) {
118
- (0, _logger.log)(`${LOG} Failed to emit "${event}": ${error}. Payload must be JSON-serializable.`, enableLogs, "error");
119
- }
120
- };
121
-
122
- // One warning per tool per session — the drop repeats every snapshot.
123
- const warnedOversized = new Set();
124
- const sendSnapshot = toolId => {
125
- const tool = toolsRef.current[toolId];
126
- if (!tool) return;
127
- lastEmit[toolId] = Date.now();
128
- const payload = tool.getSnapshot();
129
-
130
- // Emit-layer budget: never hand an unbounded payload to the socket
131
- // serializer. A dropped snapshot means a stale dashboard panel for
132
- // that tool; serializing it would freeze (and can OOM-kill) the app.
133
- const {
134
- over,
135
- approxBytes
136
- } = approxSizeOver(payload, MAX_SNAPSHOT_EMIT_BYTES);
137
- if (over) {
138
- if (!warnedOversized.has(toolId)) {
139
- warnedOversized.add(toolId);
140
- console.warn(`${LOG} "${toolId}" snapshot ≈${(approxBytes / 1e6).toFixed(1)}MB exceeds the ${MAX_SNAPSHOT_EMIT_BYTES / 1e6}MB sync budget — dropped (dashboard will be stale for this tool). The adapter should ship a wire-form snapshot (summaries + on-demand detail).`);
141
- }
142
- return;
143
- }
144
- const message = {
145
- toolId,
146
- persistentDeviceId: deviceId,
147
- timestamp: lastEmit[toolId],
148
- version: tool.version,
149
- kind: "snapshot",
150
- payload
151
- };
152
- safeEmit("devtool-sync", message);
153
- };
154
-
155
- // Leading + trailing throttle: emit immediately when outside the window,
156
- // otherwise schedule a flush for the window's end so the dashboard always
157
- // converges to the latest state.
158
- const scheduleSnapshot = toolId => {
159
- if (timers[toolId]) return;
160
- const elapsed = Date.now() - (lastEmit[toolId] ?? 0);
161
- if (elapsed >= throttleMs) {
162
- sendSnapshot(toolId);
163
- return;
164
- }
165
- timers[toolId] = setTimeout(() => {
166
- delete timers[toolId];
167
- sendSnapshot(toolId);
168
- }, throttleMs - elapsed);
169
- };
170
- const watchTool = toolId => {
171
- const tool = toolsRef.current[toolId];
172
- if (!tool || unsubs.has(toolId)) return;
173
- unsubs.set(toolId, tool.subscribe(() => scheduleSnapshot(toolId)));
174
- (0, _logger.log)(`${LOG} Watching "${toolId}"`, enableLogs);
175
- };
176
- const unwatchTool = toolId => {
177
- unsubs.get(toolId)?.();
178
- unsubs.delete(toolId);
179
- if (timers[toolId]) {
180
- clearTimeout(timers[toolId]);
181
- delete timers[toolId];
182
- }
183
- (0, _logger.log)(`${LOG} Stopped watching "${toolId}"`, enableLogs);
184
- };
185
- const sendCapabilities = () => {
186
- const message = {
187
- persistentDeviceId: deviceId,
188
- protocolVersion: _types.SYNC_PROTOCOL_VERSION,
189
- tools: Object.entries(toolsRef.current).map(([toolId, tool]) => ({
190
- toolId,
191
- version: tool.version,
192
- actions: Object.keys(tool.actions ?? {})
193
- }))
194
- };
195
- safeEmit("devtool-capabilities", message);
196
- };
197
- const handleWatch = message => {
198
- if (!isForThisDevice(message.targetDeviceId)) return;
199
- if (message.watching) {
200
- watchedRef.current.add(message.toolId);
201
- watchTool(message.toolId);
202
- sendSnapshot(message.toolId);
203
- } else {
204
- watchedRef.current.delete(message.toolId);
205
- unwatchTool(message.toolId);
206
- }
207
- };
208
- const handleRequest = message => {
209
- if (!isForThisDevice(message.targetDeviceId)) return;
210
- sendSnapshot(message.toolId);
211
- };
212
- const handleAction = async message => {
213
- if (!isForThisDevice(message.targetDeviceId)) return;
214
- const {
215
- requestId,
216
- toolId,
217
- action,
218
- params
219
- } = message;
220
- const result = {
221
- requestId,
222
- toolId,
223
- action,
224
- persistentDeviceId: deviceId,
225
- timestamp: Date.now(),
226
- ok: false
227
- };
228
- const tool = toolsRef.current[toolId];
229
- const fn = tool?.actions?.[action];
230
- if (!fn) {
231
- result.error = tool ? `Unknown action "${action}" on tool "${toolId}"` : `Unknown tool "${toolId}"`;
232
- safeEmit("devtool-action-result", result);
233
- return;
234
- }
235
- try {
236
- result.result = await fn(params);
237
- result.ok = true;
238
- } catch (error) {
239
- result.error = error instanceof Error ? error.message : String(error);
240
- }
241
-
242
- // Same budget for action results: e.g. redux getState({includeValues:
243
- // true}) on a huge tree would otherwise OOM through this exact emit.
244
- if (result.ok) {
245
- const {
246
- over,
247
- approxBytes
248
- } = approxSizeOver(result.result, MAX_ACTION_RESULT_BYTES);
249
- if (over) {
250
- result.ok = false;
251
- result.result = undefined;
252
- result.error = `Result ≈${(approxBytes / 1e6).toFixed(1)}MB exceeds the ${MAX_ACTION_RESULT_BYTES / 1e6}MB sync budget. Narrow the request (e.g. includeValues:false, a limit, or a more specific action).`;
253
- }
254
- }
255
- result.timestamp = Date.now();
256
- safeEmit("devtool-action-result", result);
257
- };
258
- const handleConnect = () => {
259
- // The server replies with current watch state for this device.
260
- sendCapabilities();
261
- };
262
- if (socket.connected) sendCapabilities();
263
-
264
- // Restore subscriptions for tools that were watched before this effect
265
- // re-ran (or that arrived after the watch message).
266
- for (const toolId of watchedRef.current) {
267
- if (toolsRef.current[toolId]) watchTool(toolId);
268
- }
269
- socket.on("connect", handleConnect);
270
- socket.on("devtool-watch", handleWatch);
271
- socket.on("request-devtool-state", handleRequest);
272
- socket.on("devtool-action", handleAction);
273
- return () => {
274
- socket.off("connect", handleConnect);
275
- socket.off("devtool-watch", handleWatch);
276
- socket.off("request-devtool-state", handleRequest);
277
- socket.off("devtool-action", handleAction);
278
- for (const toolId of [...unsubs.keys()]) unwatchTool(toolId);
279
- };
56
+ return (0, _attachDeviceSync.attachDeviceSync)({
57
+ socket,
58
+ deviceId,
59
+ getTools: () => toolsRef.current,
60
+ watched: watchedRef.current,
61
+ throttleMs,
62
+ enableLogs
63
+ });
280
64
  // toolIdsKey stands in for `tools` (see comment above its definition).
281
65
  // eslint-disable-next-line react-hooks/exhaustive-deps
282
66
  }, [socket, deviceId, enabled, throttleMs, enableLogs, toolIdsKey]);
@@ -0,0 +1,205 @@
1
+ "use strict";
2
+
3
+ import { log } from "./utils/logger";
4
+ import { MAX_ACTION_RESULT_BYTES, MAX_SNAPSHOT_EMIT_BYTES, approxSizeOver } from "./emitBudget";
5
+ import { SYNC_PROTOCOL_VERSION } from "./types";
6
+ const LOG = "[ExternalSync]";
7
+
8
+ /** Minimal socket surface the device-side bridge needs. */
9
+
10
+ /**
11
+ * "capture" records into the tool's store without pushing snapshots; "stream"
12
+ * also emits on every change. See `DevToolWatchMessage.stream`.
13
+ */
14
+
15
+ /**
16
+ * Device-side sync bridge (no React). A tool's adapter is only subscribed
17
+ * while a dashboard is watching it (`devtool-watch`).
18
+ */
19
+ export function attachDeviceSync({
20
+ socket,
21
+ deviceId,
22
+ getTools,
23
+ watched,
24
+ throttleMs = 200,
25
+ enableLogs = false
26
+ }) {
27
+ const unsubs = new Map();
28
+ const lastEmit = {};
29
+ const timers = {};
30
+ const isForThisDevice = targetDeviceId => targetDeviceId === "All" || targetDeviceId === deviceId;
31
+ const safeEmit = (event, message) => {
32
+ try {
33
+ socket.emit(event, message);
34
+ } catch (error) {
35
+ log(`${LOG} Failed to emit "${event}": ${error}. Payload must be JSON-serializable.`, enableLogs, "error");
36
+ }
37
+ };
38
+ const warnedOversized = new Set();
39
+ const sendSnapshot = toolId => {
40
+ const tool = getTools()[toolId];
41
+ if (!tool) return;
42
+ lastEmit[toolId] = Date.now();
43
+ const payload = tool.getSnapshot();
44
+ const {
45
+ over,
46
+ approxBytes
47
+ } = approxSizeOver(payload, MAX_SNAPSHOT_EMIT_BYTES);
48
+ if (over) {
49
+ if (!warnedOversized.has(toolId)) {
50
+ warnedOversized.add(toolId);
51
+ console.warn(`${LOG} "${toolId}" snapshot ≈${(approxBytes / 1e6).toFixed(1)}MB exceeds the ${MAX_SNAPSHOT_EMIT_BYTES / 1e6}MB sync budget — dropped (dashboard will be stale for this tool). The adapter should ship a wire-form snapshot (summaries + on-demand detail).`);
52
+ }
53
+ return;
54
+ }
55
+ const message = {
56
+ toolId,
57
+ persistentDeviceId: deviceId,
58
+ timestamp: lastEmit[toolId],
59
+ version: tool.version,
60
+ kind: "snapshot",
61
+ payload
62
+ };
63
+ safeEmit("devtool-sync", message);
64
+ };
65
+ const scheduleSnapshot = toolId => {
66
+ if (timers[toolId]) return;
67
+ const elapsed = Date.now() - (lastEmit[toolId] ?? 0);
68
+ if (elapsed >= throttleMs) {
69
+ sendSnapshot(toolId);
70
+ return;
71
+ }
72
+ timers[toolId] = setTimeout(() => {
73
+ delete timers[toolId];
74
+ sendSnapshot(toolId);
75
+ }, throttleMs - elapsed);
76
+ };
77
+
78
+ /** The mode each currently-subscribed tool is wired for. */
79
+ const activeModes = new Map();
80
+ const watchTool = (toolId, mode) => {
81
+ const tool = getTools()[toolId];
82
+ if (!tool) return;
83
+ if (unsubs.has(toolId)) {
84
+ if (activeModes.get(toolId) === mode) return;
85
+ // Mode changed (a streaming dashboard left, or arrived) — rewire, but
86
+ // do NOT drop the subscription in between: releasing it would take the
87
+ // store's subscriber count to 0 and stop capture mid-flight.
88
+ const previous = unsubs.get(toolId);
89
+ unsubs.set(toolId, tool.subscribe(mode === "stream" ? () => scheduleSnapshot(toolId) : () => {}));
90
+ activeModes.set(toolId, mode);
91
+ previous?.();
92
+ log(`${LOG} "${toolId}" watch mode → ${mode}`, enableLogs);
93
+ return;
94
+ }
95
+ unsubs.set(toolId, tool.subscribe(mode === "stream" ? () => scheduleSnapshot(toolId) : () => {}));
96
+ activeModes.set(toolId, mode);
97
+ log(`${LOG} Watching "${toolId}" (${mode})`, enableLogs);
98
+ };
99
+ const unwatchTool = toolId => {
100
+ unsubs.get(toolId)?.();
101
+ unsubs.delete(toolId);
102
+ activeModes.delete(toolId);
103
+ if (timers[toolId]) {
104
+ clearTimeout(timers[toolId]);
105
+ delete timers[toolId];
106
+ }
107
+ log(`${LOG} Stopped watching "${toolId}"`, enableLogs);
108
+ };
109
+ const sendCapabilities = () => {
110
+ const message = {
111
+ persistentDeviceId: deviceId,
112
+ protocolVersion: SYNC_PROTOCOL_VERSION,
113
+ tools: Object.entries(getTools()).map(([toolId, tool]) => ({
114
+ toolId,
115
+ version: tool.version,
116
+ actions: Object.keys(tool.actions ?? {})
117
+ }))
118
+ };
119
+ safeEmit("devtool-capabilities", message);
120
+ };
121
+ const handleWatch = message => {
122
+ if (!isForThisDevice(message.targetDeviceId)) return;
123
+ if (message.watching) {
124
+ // `stream` is only trustworthy once a broker has merged every
125
+ // dashboard's request (see DevToolWatchMessage.consolidated). Without
126
+ // that, an unmerged capture request could quiet a dashboard that is
127
+ // actively painting, so default to streaming.
128
+ const mode = message.consolidated === true && message.stream === false ? "capture" : "stream";
129
+ watched.set(message.toolId, mode);
130
+ watchTool(message.toolId, mode);
131
+ // Only a streaming audience gets the priming snapshot. A capture-only
132
+ // client reads with `request-devtool-state` when it actually wants one;
133
+ // pushing here would hand it the very payload it asked not to receive.
134
+ if (mode === "stream") sendSnapshot(message.toolId);
135
+ } else {
136
+ watched.delete(message.toolId);
137
+ unwatchTool(message.toolId);
138
+ }
139
+ };
140
+ const handleRequest = message => {
141
+ if (!isForThisDevice(message.targetDeviceId)) return;
142
+ sendSnapshot(message.toolId);
143
+ };
144
+ const handleAction = async message => {
145
+ if (!isForThisDevice(message.targetDeviceId)) return;
146
+ const {
147
+ requestId,
148
+ toolId,
149
+ action,
150
+ params
151
+ } = message;
152
+ const result = {
153
+ requestId,
154
+ toolId,
155
+ action,
156
+ persistentDeviceId: deviceId,
157
+ timestamp: Date.now(),
158
+ ok: false
159
+ };
160
+ const tool = getTools()[toolId];
161
+ const fn = tool?.actions?.[action];
162
+ if (!fn) {
163
+ result.error = tool ? `Unknown action "${action}" on tool "${toolId}"` : `Unknown tool "${toolId}"`;
164
+ safeEmit("devtool-action-result", result);
165
+ return;
166
+ }
167
+ try {
168
+ result.result = await fn(params);
169
+ result.ok = true;
170
+ } catch (error) {
171
+ result.error = error instanceof Error ? error.message : String(error);
172
+ }
173
+ if (result.ok) {
174
+ const {
175
+ over,
176
+ approxBytes
177
+ } = approxSizeOver(result.result, MAX_ACTION_RESULT_BYTES);
178
+ if (over) {
179
+ result.ok = false;
180
+ result.result = undefined;
181
+ result.error = `Result ≈${(approxBytes / 1e6).toFixed(1)}MB exceeds the ${MAX_ACTION_RESULT_BYTES / 1e6}MB sync budget. Narrow the request (e.g. includeValues:false, a limit, or a more specific action).`;
182
+ }
183
+ }
184
+ result.timestamp = Date.now();
185
+ safeEmit("devtool-action-result", result);
186
+ };
187
+ const handleConnect = () => {
188
+ sendCapabilities();
189
+ };
190
+ if (socket.connected) sendCapabilities();
191
+ for (const [toolId, mode] of watched) {
192
+ if (getTools()[toolId]) watchTool(toolId, mode);
193
+ }
194
+ socket.on("connect", handleConnect);
195
+ socket.on("devtool-watch", handleWatch);
196
+ socket.on("request-devtool-state", handleRequest);
197
+ socket.on("devtool-action", handleAction);
198
+ return () => {
199
+ socket.off("connect", handleConnect);
200
+ socket.off("devtool-watch", handleWatch);
201
+ socket.off("request-devtool-state", handleRequest);
202
+ socket.off("devtool-action", handleAction);
203
+ for (const toolId of [...unsubs.keys()]) unwatchTool(toolId);
204
+ };
205
+ }
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Emit-layer byte budgets — the choke-point guard.
5
+ *
6
+ * Every adapter's snapshot and every action result funnels through
7
+ * socket.emit, whose serializer walks the ENTIRE payload synchronously on
8
+ * the JS thread (and JSON.stringify does not honor structural sharing).
9
+ * Per-adapter guards get missed — the redux adapter shipping raw state
10
+ * trees froze and native-OOM-crashed enterprise apps whenever a dashboard
11
+ * was connected. One budget here closes every adapter's hole at once:
12
+ * oversized payloads are dropped (snapshots) or replaced with an error
13
+ * (action results) instead of being serialized.
14
+ *
15
+ * The estimator is O(limit) — early-abort walk, O(1) for giant strings —
16
+ * never O(payload). Copied per-package by design (see events/redux copies).
17
+ */
18
+
19
+ /** Max approx bytes for one tool snapshot emit. */
20
+ export const MAX_SNAPSHOT_EMIT_BYTES = 2 * 1024 * 1024;
21
+ /** Max approx bytes for one action-result emit (explicit requests get more). */
22
+ export const MAX_ACTION_RESULT_BYTES = 8 * 1024 * 1024;
23
+ export function approxSizeOver(value, limit) {
24
+ let bytes = 0;
25
+ const stack = [value];
26
+ const seen = new Set();
27
+ while (stack.length > 0) {
28
+ if (bytes > limit) return {
29
+ over: true,
30
+ approxBytes: bytes
31
+ };
32
+ const v = stack.pop();
33
+ if (v === null || v === undefined) bytes += 4;else if (typeof v === "string") bytes += v.length + 2;else if (typeof v === "number") bytes += 8;else if (typeof v === "boolean") bytes += 5;else if (typeof v === "object") {
34
+ if (seen.has(v)) continue;
35
+ seen.add(v);
36
+ if (Array.isArray(v)) {
37
+ bytes += 2 + v.length;
38
+ for (let i = 0; i < v.length; i++) stack.push(v[i]);
39
+ } else {
40
+ bytes += 2;
41
+ for (const k in v) {
42
+ bytes += k.length + 4;
43
+ stack.push(v[k]);
44
+ }
45
+ }
46
+ } else bytes += 4;
47
+ }
48
+ return {
49
+ over: bytes > limit,
50
+ approxBytes: bytes
51
+ };
52
+ }
@@ -57,7 +57,20 @@ export const SYNC_PROTOCOL_VERSION = 1;
57
57
  // DASHBOARD -> DEVICE MESSAGES
58
58
  // ============================================================
59
59
 
60
- /** Server-consolidated signal that a tool gained/lost its audience. */
60
+ /**
61
+ * Server-consolidated signal that a tool gained/lost its audience.
62
+ *
63
+ * `watching` arms CAPTURE: the device subscribes to the tool, which is what
64
+ * makes its store start recording. `stream` decides whether the device also
65
+ * PUSHES a snapshot on every change (throttled), which is the expensive half —
66
+ * it serializes the whole payload on the JS thread ~5x/sec.
67
+ *
68
+ * Those are separate because their audiences are: a dashboard renders every
69
+ * snapshot, so it wants both. A request/response client (MCP) reads on demand
70
+ * via `request-devtool-state` and would throw the stream away — but it still
71
+ * needs the device to be RECORDING between its reads, or it only ever sees an
72
+ * empty list and concludes the app made no requests.
73
+ */
61
74
 
62
75
  /** Explicit request for a tool's current state (works even while unwatched). */
63
76
 
@@ -1,61 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  import { useEffect, useRef } from "react";
4
- import { log } from "./utils/logger";
5
- import { SYNC_PROTOCOL_VERSION } from "./types";
6
- const LOG = "[ExternalSync]";
7
-
8
- // ==========================================================================
9
- // Emit-layer byte budgets — the choke-point guard.
10
- //
11
- // Every adapter's snapshot and every action result funnels through
12
- // socket.emit, whose serializer walks the ENTIRE payload synchronously on
13
- // the JS thread (and JSON.stringify does not honor structural sharing).
14
- // Per-adapter guards get missed — the redux adapter shipping raw state
15
- // trees froze and native-OOM-crashed enterprise apps whenever a dashboard
16
- // was connected. One budget here closes every adapter's hole at once:
17
- // oversized payloads are dropped (snapshots) or replaced with an error
18
- // (action results) instead of being serialized.
19
- //
20
- // The estimator is O(limit) — early-abort walk, O(1) for giant strings —
21
- // never O(payload). Copied per-package by design (see events/redux copies).
22
- // ==========================================================================
23
-
24
- /** Max approx bytes for one tool snapshot emit. */
25
- const MAX_SNAPSHOT_EMIT_BYTES = 2 * 1024 * 1024;
26
- /** Max approx bytes for one action-result emit (explicit requests get more). */
27
- const MAX_ACTION_RESULT_BYTES = 8 * 1024 * 1024;
28
- function approxSizeOver(value, limit) {
29
- let bytes = 0;
30
- const stack = [value];
31
- const seen = new Set();
32
- while (stack.length > 0) {
33
- if (bytes > limit) return {
34
- over: true,
35
- approxBytes: bytes
36
- };
37
- const v = stack.pop();
38
- if (v === null || v === undefined) bytes += 4;else if (typeof v === "string") bytes += v.length + 2;else if (typeof v === "number") bytes += 8;else if (typeof v === "boolean") bytes += 5;else if (typeof v === "object") {
39
- if (seen.has(v)) continue;
40
- seen.add(v);
41
- if (Array.isArray(v)) {
42
- bytes += 2 + v.length;
43
- for (let i = 0; i < v.length; i++) stack.push(v[i]);
44
- } else {
45
- bytes += 2;
46
- for (const k in v) {
47
- bytes += k.length + 4;
48
- stack.push(v[k]);
49
- }
50
- }
51
- } else bytes += 4;
52
- }
53
- return {
54
- over: bytes > limit,
55
- approxBytes: bytes
56
- };
57
- }
58
-
4
+ import { attachDeviceSync } from "./attachDeviceSync";
59
5
  /**
60
6
  * Device-side sync bridge: syncs any number of dev tools to the desktop
61
7
  * dashboard and executes actions invoked remotely.
@@ -96,183 +42,21 @@ export function useExternalSync({
96
42
 
97
43
  // Watch state persists across effect re-runs (e.g. when tools are
98
44
  // added/removed) so an already-watched tool stays subscribed.
99
- const watchedRef = useRef(new Set());
45
+ const watchedRef = useRef(new Map());
100
46
 
101
47
  // Re-run the effect only when tools are added/removed, not on every
102
48
  // object-reference change.
103
49
  const toolIdsKey = Object.keys(tools).sort().join(",");
104
50
  useEffect(() => {
105
51
  if (!socket || !deviceId || !enabled) return;
106
- const unsubs = new Map();
107
- const lastEmit = {};
108
- const timers = {};
109
- const isForThisDevice = targetDeviceId => targetDeviceId === "All" || targetDeviceId === deviceId;
110
- const safeEmit = (event, message) => {
111
- try {
112
- socket.emit(event, message);
113
- } catch (error) {
114
- log(`${LOG} Failed to emit "${event}": ${error}. Payload must be JSON-serializable.`, enableLogs, "error");
115
- }
116
- };
117
-
118
- // One warning per tool per session — the drop repeats every snapshot.
119
- const warnedOversized = new Set();
120
- const sendSnapshot = toolId => {
121
- const tool = toolsRef.current[toolId];
122
- if (!tool) return;
123
- lastEmit[toolId] = Date.now();
124
- const payload = tool.getSnapshot();
125
-
126
- // Emit-layer budget: never hand an unbounded payload to the socket
127
- // serializer. A dropped snapshot means a stale dashboard panel for
128
- // that tool; serializing it would freeze (and can OOM-kill) the app.
129
- const {
130
- over,
131
- approxBytes
132
- } = approxSizeOver(payload, MAX_SNAPSHOT_EMIT_BYTES);
133
- if (over) {
134
- if (!warnedOversized.has(toolId)) {
135
- warnedOversized.add(toolId);
136
- console.warn(`${LOG} "${toolId}" snapshot ≈${(approxBytes / 1e6).toFixed(1)}MB exceeds the ${MAX_SNAPSHOT_EMIT_BYTES / 1e6}MB sync budget — dropped (dashboard will be stale for this tool). The adapter should ship a wire-form snapshot (summaries + on-demand detail).`);
137
- }
138
- return;
139
- }
140
- const message = {
141
- toolId,
142
- persistentDeviceId: deviceId,
143
- timestamp: lastEmit[toolId],
144
- version: tool.version,
145
- kind: "snapshot",
146
- payload
147
- };
148
- safeEmit("devtool-sync", message);
149
- };
150
-
151
- // Leading + trailing throttle: emit immediately when outside the window,
152
- // otherwise schedule a flush for the window's end so the dashboard always
153
- // converges to the latest state.
154
- const scheduleSnapshot = toolId => {
155
- if (timers[toolId]) return;
156
- const elapsed = Date.now() - (lastEmit[toolId] ?? 0);
157
- if (elapsed >= throttleMs) {
158
- sendSnapshot(toolId);
159
- return;
160
- }
161
- timers[toolId] = setTimeout(() => {
162
- delete timers[toolId];
163
- sendSnapshot(toolId);
164
- }, throttleMs - elapsed);
165
- };
166
- const watchTool = toolId => {
167
- const tool = toolsRef.current[toolId];
168
- if (!tool || unsubs.has(toolId)) return;
169
- unsubs.set(toolId, tool.subscribe(() => scheduleSnapshot(toolId)));
170
- log(`${LOG} Watching "${toolId}"`, enableLogs);
171
- };
172
- const unwatchTool = toolId => {
173
- unsubs.get(toolId)?.();
174
- unsubs.delete(toolId);
175
- if (timers[toolId]) {
176
- clearTimeout(timers[toolId]);
177
- delete timers[toolId];
178
- }
179
- log(`${LOG} Stopped watching "${toolId}"`, enableLogs);
180
- };
181
- const sendCapabilities = () => {
182
- const message = {
183
- persistentDeviceId: deviceId,
184
- protocolVersion: SYNC_PROTOCOL_VERSION,
185
- tools: Object.entries(toolsRef.current).map(([toolId, tool]) => ({
186
- toolId,
187
- version: tool.version,
188
- actions: Object.keys(tool.actions ?? {})
189
- }))
190
- };
191
- safeEmit("devtool-capabilities", message);
192
- };
193
- const handleWatch = message => {
194
- if (!isForThisDevice(message.targetDeviceId)) return;
195
- if (message.watching) {
196
- watchedRef.current.add(message.toolId);
197
- watchTool(message.toolId);
198
- sendSnapshot(message.toolId);
199
- } else {
200
- watchedRef.current.delete(message.toolId);
201
- unwatchTool(message.toolId);
202
- }
203
- };
204
- const handleRequest = message => {
205
- if (!isForThisDevice(message.targetDeviceId)) return;
206
- sendSnapshot(message.toolId);
207
- };
208
- const handleAction = async message => {
209
- if (!isForThisDevice(message.targetDeviceId)) return;
210
- const {
211
- requestId,
212
- toolId,
213
- action,
214
- params
215
- } = message;
216
- const result = {
217
- requestId,
218
- toolId,
219
- action,
220
- persistentDeviceId: deviceId,
221
- timestamp: Date.now(),
222
- ok: false
223
- };
224
- const tool = toolsRef.current[toolId];
225
- const fn = tool?.actions?.[action];
226
- if (!fn) {
227
- result.error = tool ? `Unknown action "${action}" on tool "${toolId}"` : `Unknown tool "${toolId}"`;
228
- safeEmit("devtool-action-result", result);
229
- return;
230
- }
231
- try {
232
- result.result = await fn(params);
233
- result.ok = true;
234
- } catch (error) {
235
- result.error = error instanceof Error ? error.message : String(error);
236
- }
237
-
238
- // Same budget for action results: e.g. redux getState({includeValues:
239
- // true}) on a huge tree would otherwise OOM through this exact emit.
240
- if (result.ok) {
241
- const {
242
- over,
243
- approxBytes
244
- } = approxSizeOver(result.result, MAX_ACTION_RESULT_BYTES);
245
- if (over) {
246
- result.ok = false;
247
- result.result = undefined;
248
- result.error = `Result ≈${(approxBytes / 1e6).toFixed(1)}MB exceeds the ${MAX_ACTION_RESULT_BYTES / 1e6}MB sync budget. Narrow the request (e.g. includeValues:false, a limit, or a more specific action).`;
249
- }
250
- }
251
- result.timestamp = Date.now();
252
- safeEmit("devtool-action-result", result);
253
- };
254
- const handleConnect = () => {
255
- // The server replies with current watch state for this device.
256
- sendCapabilities();
257
- };
258
- if (socket.connected) sendCapabilities();
259
-
260
- // Restore subscriptions for tools that were watched before this effect
261
- // re-ran (or that arrived after the watch message).
262
- for (const toolId of watchedRef.current) {
263
- if (toolsRef.current[toolId]) watchTool(toolId);
264
- }
265
- socket.on("connect", handleConnect);
266
- socket.on("devtool-watch", handleWatch);
267
- socket.on("request-devtool-state", handleRequest);
268
- socket.on("devtool-action", handleAction);
269
- return () => {
270
- socket.off("connect", handleConnect);
271
- socket.off("devtool-watch", handleWatch);
272
- socket.off("request-devtool-state", handleRequest);
273
- socket.off("devtool-action", handleAction);
274
- for (const toolId of [...unsubs.keys()]) unwatchTool(toolId);
275
- };
52
+ return attachDeviceSync({
53
+ socket,
54
+ deviceId,
55
+ getTools: () => toolsRef.current,
56
+ watched: watchedRef.current,
57
+ throttleMs,
58
+ enableLogs
59
+ });
276
60
  // toolIdsKey stands in for `tools` (see comment above its definition).
277
61
  // eslint-disable-next-line react-hooks/exhaustive-deps
278
62
  }, [socket, deviceId, enabled, throttleMs, enableLogs, toolIdsKey]);
@@ -0,0 +1,32 @@
1
+ import { type ToolSyncAdapter } from "./types";
2
+ /** Minimal socket surface the device-side bridge needs. */
3
+ export type SyncSocket = {
4
+ connected: boolean;
5
+ emit: (event: string, ...args: unknown[]) => unknown;
6
+ on: (event: string, listener: (...args: any[]) => void) => unknown;
7
+ off: (event: string, listener: (...args: any[]) => void) => unknown;
8
+ };
9
+ /**
10
+ * "capture" records into the tool's store without pushing snapshots; "stream"
11
+ * also emits on every change. See `DevToolWatchMessage.stream`.
12
+ */
13
+ export type WatchMode = "capture" | "stream";
14
+ export type AttachDeviceSyncOptions = {
15
+ socket: SyncSocket;
16
+ deviceId: string;
17
+ getTools: () => Record<string, ToolSyncAdapter>;
18
+ /**
19
+ * Survives attach/detach so an already-watched tool stays subscribed — and
20
+ * at the same MODE, or a re-attach would silently start streaming to a
21
+ * client that only asked to record.
22
+ */
23
+ watched: Map<string, WatchMode>;
24
+ throttleMs?: number;
25
+ enableLogs?: boolean;
26
+ };
27
+ /**
28
+ * Device-side sync bridge (no React). A tool's adapter is only subscribed
29
+ * while a dashboard is watching it (`devtool-watch`).
30
+ */
31
+ export declare function attachDeviceSync({ socket, deviceId, getTools, watched, throttleMs, enableLogs, }: AttachDeviceSyncOptions): () => void;
32
+ //# sourceMappingURL=attachDeviceSync.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachDeviceSync.d.ts","sourceRoot":"","sources":["../../src/attachDeviceSync.ts"],"names":[],"mappings":"AAMA,OAAO,EAQL,KAAK,eAAe,EACrB,MAAM,SAAS,CAAC;AAIjB,2DAA2D;AAC3D,MAAM,MAAM,UAAU,GAAG;IACvB,SAAS,EAAE,OAAO,CAAC;IACnB,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;IACrD,EAAE,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,KAAK,OAAO,CAAC;IACnE,GAAG,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,KAAK,OAAO,CAAC;CACrE,CAAC;AAEF;;;GAGG;AACH,MAAM,MAAM,SAAS,GAAG,SAAS,GAAG,QAAQ,CAAC;AAE7C,MAAM,MAAM,uBAAuB,GAAG;IACpC,MAAM,EAAE,UAAU,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IAChD;;;;OAIG;IACH,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IAChC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB,CAAC;AAEF;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,EAC/B,MAAM,EACN,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,UAAgB,EAChB,UAAkB,GACnB,EAAE,uBAAuB,GAAG,MAAM,IAAI,CA2NtC"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Emit-layer byte budgets — the choke-point guard.
3
+ *
4
+ * Every adapter's snapshot and every action result funnels through
5
+ * socket.emit, whose serializer walks the ENTIRE payload synchronously on
6
+ * the JS thread (and JSON.stringify does not honor structural sharing).
7
+ * Per-adapter guards get missed — the redux adapter shipping raw state
8
+ * trees froze and native-OOM-crashed enterprise apps whenever a dashboard
9
+ * was connected. One budget here closes every adapter's hole at once:
10
+ * oversized payloads are dropped (snapshots) or replaced with an error
11
+ * (action results) instead of being serialized.
12
+ *
13
+ * The estimator is O(limit) — early-abort walk, O(1) for giant strings —
14
+ * never O(payload). Copied per-package by design (see events/redux copies).
15
+ */
16
+ /** Max approx bytes for one tool snapshot emit. */
17
+ export declare const MAX_SNAPSHOT_EMIT_BYTES: number;
18
+ /** Max approx bytes for one action-result emit (explicit requests get more). */
19
+ export declare const MAX_ACTION_RESULT_BYTES: number;
20
+ export declare function approxSizeOver(value: unknown, limit: number): {
21
+ over: boolean;
22
+ approxBytes: number;
23
+ };
24
+ //# sourceMappingURL=emitBudget.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"emitBudget.d.ts","sourceRoot":"","sources":["../../src/emitBudget.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,mDAAmD;AACnD,eAAO,MAAM,uBAAuB,QAAkB,CAAC;AACvD,gFAAgF;AAChF,eAAO,MAAM,uBAAuB,QAAkB,CAAC;AAEvD,wBAAgB,cAAc,CAC5B,KAAK,EAAE,OAAO,EACd,KAAK,EAAE,MAAM,GACZ;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CA2BxC"}
@@ -60,11 +60,44 @@ export interface DevToolActionResultMessage {
60
60
  /** Present when not ok. */
61
61
  error?: string;
62
62
  }
63
- /** Server-consolidated signal that a tool gained/lost its audience. */
63
+ /**
64
+ * Server-consolidated signal that a tool gained/lost its audience.
65
+ *
66
+ * `watching` arms CAPTURE: the device subscribes to the tool, which is what
67
+ * makes its store start recording. `stream` decides whether the device also
68
+ * PUSHES a snapshot on every change (throttled), which is the expensive half —
69
+ * it serializes the whole payload on the JS thread ~5x/sec.
70
+ *
71
+ * Those are separate because their audiences are: a dashboard renders every
72
+ * snapshot, so it wants both. A request/response client (MCP) reads on demand
73
+ * via `request-devtool-state` and would throw the stream away — but it still
74
+ * needs the device to be RECORDING between its reads, or it only ever sees an
75
+ * empty list and concludes the app made no requests.
76
+ */
64
77
  export interface DevToolWatchMessage {
65
78
  toolId: string;
66
79
  targetDeviceId: string;
67
80
  watching: boolean;
81
+ /**
82
+ * Push snapshots on change. Defaults to TRUE when absent, so a dashboard
83
+ * that predates this field behaves exactly as before.
84
+ *
85
+ * Only honored alongside `consolidated` — see below.
86
+ */
87
+ stream?: boolean;
88
+ /**
89
+ * Set by the BROKER to mean "I merged every dashboard's request and this is
90
+ * the mode the device should be in".
91
+ *
92
+ * It exists because the desktop and the MCP server each embed their own copy
93
+ * of the broker and ship on separate cycles, so a new client can end up
94
+ * talking through an old broker. An old broker forwards a dashboard's watch
95
+ * message verbatim — it has no idea modes exist — so an agent's
96
+ * `stream: false` would reach the device unmerged and silently stop the
97
+ * developer's panel from updating. Requiring this flag makes that
98
+ * combination degrade to plain streaming (today's behavior) instead.
99
+ */
100
+ consolidated?: boolean;
68
101
  }
69
102
  /** Explicit request for a tool's current state (works even while unwatched). */
70
103
  export interface DevToolRequestMessage {
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AA0B/C,yEAAyE;AACzE,eAAO,MAAM,qBAAqB,IAAI,CAAC;AAEvC,+EAA+E;AAC/E,MAAM,MAAM,QAAQ,GAAG,UAAU,CAAC;AAMlC,mFAAmF;AACnF,MAAM,MAAM,UAAU,GAAG,CAAC,MAAM,EAAE,OAAO,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAEzE;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC9B,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;IAChB,oFAAoF;IACpF,WAAW,EAAE,MAAM,OAAO,CAAC;IAC3B;;;;OAIG;IACH,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,CAAC;IAChD,sDAAsD;IACtD,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;CACtC;AAMD,4EAA4E;AAC5E,MAAM,WAAW,yBAAyB;IACxC,kBAAkB,EAAE,MAAM,CAAC;IAC3B,eAAe,EAAE,MAAM,CAAC;IACxB,KAAK,EAAE,cAAc,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,kBAAkB,EAAE,MAAM,CAAC;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,sDAAsD;IACtD,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,QAAQ,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,8CAA8C;AAC9C,MAAM,WAAW,0BAA0B;IACzC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,kBAAkB,EAAE,MAAM,CAAC;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,EAAE,EAAE,OAAO,CAAC;IACZ,uEAAuE;IACvE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,2BAA2B;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAMD,uEAAuE;AACvE,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,gFAAgF;AAChF,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,iDAAiD;AACjD,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAMD,MAAM,WAAW,oBAAoB;IACnC,yEAAyE;IACzE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IACvC,8DAA8D;IAC9D,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,oCAAoC;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,yCAAyC;IACzC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qBAAqB;IACrB,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAMD;;GAEG;AACH,MAAM,MAAM,UAAU,GAClB,KAAK,GACL,SAAS,GACT,KAAK,GACL,SAAS,GACT,OAAO,GACP,QAAQ,GACR,IAAI,GACJ,MAAM,GACN,UAAU,GACV,aAAa,CAAC;AAMlB,MAAM,WAAW,0BAA0B;IACzC,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzC,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,QAAQ,EAAE,UAAU,CAAC;IACrB,qBAAqB;IACrB,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AA0B/C,yEAAyE;AACzE,eAAO,MAAM,qBAAqB,IAAI,CAAC;AAEvC,+EAA+E;AAC/E,MAAM,MAAM,QAAQ,GAAG,UAAU,CAAC;AAMlC,mFAAmF;AACnF,MAAM,MAAM,UAAU,GAAG,CAAC,MAAM,EAAE,OAAO,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAEzE;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC9B,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;IAChB,oFAAoF;IACpF,WAAW,EAAE,MAAM,OAAO,CAAC;IAC3B;;;;OAIG;IACH,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,CAAC;IAChD,sDAAsD;IACtD,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;CACtC;AAMD,4EAA4E;AAC5E,MAAM,WAAW,yBAAyB;IACxC,kBAAkB,EAAE,MAAM,CAAC;IAC3B,eAAe,EAAE,MAAM,CAAC;IACxB,KAAK,EAAE,cAAc,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,kBAAkB,EAAE,MAAM,CAAC;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,sDAAsD;IACtD,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,QAAQ,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,8CAA8C;AAC9C,MAAM,WAAW,0BAA0B;IACzC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,kBAAkB,EAAE,MAAM,CAAC;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,EAAE,EAAE,OAAO,CAAC;IACZ,uEAAuE;IACvE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,2BAA2B;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAMD;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;;OAWG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,gFAAgF;AAChF,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,iDAAiD;AACjD,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAMD,MAAM,WAAW,oBAAoB;IACnC,yEAAyE;IACzE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IACvC,8DAA8D;IAC9D,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,oCAAoC;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,yCAAyC;IACzC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qBAAqB;IACrB,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAMD;;GAEG;AACH,MAAM,MAAM,UAAU,GAClB,KAAK,GACL,SAAS,GACT,KAAK,GACL,SAAS,GACT,OAAO,GACP,QAAQ,GACR,IAAI,GACJ,MAAM,GACN,UAAU,GACV,aAAa,CAAC;AAMlB,MAAM,WAAW,0BAA0B;IACzC,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzC,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,QAAQ,EAAE,UAAU,CAAC;IACrB,qBAAqB;IACrB,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB"}
@@ -1,4 +1,4 @@
1
- import { type UseExternalSyncProps } from "./types";
1
+ import type { UseExternalSyncProps } from "./types";
2
2
  /**
3
3
  * Device-side sync bridge: syncs any number of dev tools to the desktop
4
4
  * dashboard and executes actions invoked remotely.
@@ -1 +1 @@
1
- {"version":3,"file":"useExternalSync.d.ts","sourceRoot":"","sources":["../../src/useExternalSync.ts"],"names":[],"mappings":"AAEA,OAAO,EAQL,KAAK,oBAAoB,EAC1B,MAAM,SAAS,CAAC;AAyDjB;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,MAAM,EACN,QAAQ,EACR,OAAc,EACd,UAAgB,EAChB,UAAkB,GACnB,EAAE,oBAAoB,QAkNtB"}
1
+ {"version":3,"file":"useExternalSync.d.ts","sourceRoot":"","sources":["../../src/useExternalSync.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AAEpD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,MAAM,EACN,QAAQ,EACR,OAAc,EACd,UAAgB,EAChB,UAAkB,GACnB,EAAE,oBAAoB,QA0BtB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buoy-gg/external-sync",
3
- "version": "7.0.17",
3
+ "version": "7.0.19",
4
4
  "description": "Generic device-to-desktop sync for Buoy dev tools",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -53,7 +53,12 @@
53
53
  "sourceMaps": false
54
54
  }
55
55
  ],
56
- "typescript"
56
+ [
57
+ "typescript",
58
+ {
59
+ "project": "tsconfig.build.json"
60
+ }
61
+ ]
57
62
  ]
58
63
  },
59
64
  "repository": {