@buoy-gg/external-sync 7.0.9 → 7.0.11

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.
@@ -9,6 +9,57 @@ var _logger = require("./utils/logger");
9
9
  var _types = require("./types");
10
10
  const LOG = "[ExternalSync]";
11
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
+
12
63
  /**
13
64
  * Device-side sync bridge: syncs any number of dev tools to the desktop
14
65
  * dashboard and executes actions invoked remotely.
@@ -67,17 +118,36 @@ function useExternalSync({
67
118
  (0, _logger.log)(`${LOG} Failed to emit "${event}": ${error}. Payload must be JSON-serializable.`, enableLogs, "error");
68
119
  }
69
120
  };
121
+
122
+ // One warning per tool per session — the drop repeats every snapshot.
123
+ const warnedOversized = new Set();
70
124
  const sendSnapshot = toolId => {
71
125
  const tool = toolsRef.current[toolId];
72
126
  if (!tool) return;
73
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
+ }
74
144
  const message = {
75
145
  toolId,
76
146
  persistentDeviceId: deviceId,
77
147
  timestamp: lastEmit[toolId],
78
148
  version: tool.version,
79
149
  kind: "snapshot",
80
- payload: tool.getSnapshot()
150
+ payload
81
151
  };
82
152
  safeEmit("devtool-sync", message);
83
153
  };
@@ -168,6 +238,20 @@ function useExternalSync({
168
238
  } catch (error) {
169
239
  result.error = error instanceof Error ? error.message : String(error);
170
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
+ }
171
255
  result.timestamp = Date.now();
172
256
  safeEmit("devtool-action-result", result);
173
257
  };
@@ -5,6 +5,57 @@ import { log } from "./utils/logger";
5
5
  import { SYNC_PROTOCOL_VERSION } from "./types";
6
6
  const LOG = "[ExternalSync]";
7
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
+
8
59
  /**
9
60
  * Device-side sync bridge: syncs any number of dev tools to the desktop
10
61
  * dashboard and executes actions invoked remotely.
@@ -63,17 +114,36 @@ export function useExternalSync({
63
114
  log(`${LOG} Failed to emit "${event}": ${error}. Payload must be JSON-serializable.`, enableLogs, "error");
64
115
  }
65
116
  };
117
+
118
+ // One warning per tool per session — the drop repeats every snapshot.
119
+ const warnedOversized = new Set();
66
120
  const sendSnapshot = toolId => {
67
121
  const tool = toolsRef.current[toolId];
68
122
  if (!tool) return;
69
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
+ }
70
140
  const message = {
71
141
  toolId,
72
142
  persistentDeviceId: deviceId,
73
143
  timestamp: lastEmit[toolId],
74
144
  version: tool.version,
75
145
  kind: "snapshot",
76
- payload: tool.getSnapshot()
146
+ payload
77
147
  };
78
148
  safeEmit("devtool-sync", message);
79
149
  };
@@ -164,6 +234,20 @@ export function useExternalSync({
164
234
  } catch (error) {
165
235
  result.error = error instanceof Error ? error.message : String(error);
166
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
+ }
167
251
  result.timestamp = Date.now();
168
252
  safeEmit("devtool-action-result", result);
169
253
  };
@@ -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;AAIjB;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,MAAM,EACN,QAAQ,EACR,OAAc,EACd,UAAgB,EAChB,UAAkB,GACnB,EAAE,oBAAoB,QAgLtB"}
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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buoy-gg/external-sync",
3
- "version": "7.0.9",
3
+ "version": "7.0.11",
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",