@buoy-gg/sync-broker 7.0.17 → 7.0.18

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/dist/index.d.mts CHANGED
@@ -113,11 +113,31 @@ interface DevToolActionResultMessage {
113
113
  /** Present when not ok. */
114
114
  error?: string;
115
115
  }
116
- /** Server-consolidated signal that a tool gained/lost its audience. */
116
+ /**
117
+ * Server-consolidated signal that a tool gained/lost its audience.
118
+ *
119
+ * `watching` arms CAPTURE (the device subscribes, which starts the tool's
120
+ * store recording); `stream` decides whether it also PUSHES a snapshot on
121
+ * every change. A dashboard wants both; a request/response client (MCP) wants
122
+ * only the first, and reads via `request-devtool-state`.
123
+ *
124
+ * The SERVER consolidates these across dashboards — see `handleDevtoolWatch`.
125
+ * A capture-only watcher must never downgrade a streaming one, or opening an
126
+ * agent session would silently freeze the desktop's panel.
127
+ */
117
128
  interface DevToolWatchMessage {
118
129
  toolId: string;
119
130
  targetDeviceId: string;
120
131
  watching: boolean;
132
+ /** Push snapshots on change. Absent means TRUE (pre-`stream` dashboards). */
133
+ stream?: boolean;
134
+ /**
135
+ * Set by the BROKER on the message it sends the device, meaning "this is the
136
+ * merged mode". Devices ignore `stream` without it, so an old broker (which
137
+ * forwards a dashboard's message verbatim) can't let one client's
138
+ * capture-only request quiet another client's live panel.
139
+ */
140
+ consolidated?: boolean;
121
141
  }
122
142
  /** Explicit request for a tool's current state (works even while unwatched). */
123
143
  interface DevToolRequestMessage {
package/dist/index.d.ts CHANGED
@@ -113,11 +113,31 @@ interface DevToolActionResultMessage {
113
113
  /** Present when not ok. */
114
114
  error?: string;
115
115
  }
116
- /** Server-consolidated signal that a tool gained/lost its audience. */
116
+ /**
117
+ * Server-consolidated signal that a tool gained/lost its audience.
118
+ *
119
+ * `watching` arms CAPTURE (the device subscribes, which starts the tool's
120
+ * store recording); `stream` decides whether it also PUSHES a snapshot on
121
+ * every change. A dashboard wants both; a request/response client (MCP) wants
122
+ * only the first, and reads via `request-devtool-state`.
123
+ *
124
+ * The SERVER consolidates these across dashboards — see `handleDevtoolWatch`.
125
+ * A capture-only watcher must never downgrade a streaming one, or opening an
126
+ * agent session would silently freeze the desktop's panel.
127
+ */
117
128
  interface DevToolWatchMessage {
118
129
  toolId: string;
119
130
  targetDeviceId: string;
120
131
  watching: boolean;
132
+ /** Push snapshots on change. Absent means TRUE (pre-`stream` dashboards). */
133
+ stream?: boolean;
134
+ /**
135
+ * Set by the BROKER on the message it sends the device, meaning "this is the
136
+ * merged mode". Devices ignore `stream` without it, so an old broker (which
137
+ * forwards a dashboard's message verbatim) can't let one client's
138
+ * capture-only request quiet another client's live panel.
139
+ */
140
+ consolidated?: boolean;
121
141
  }
122
142
  /** Explicit request for a tool's current state (works even while unwatched). */
123
143
  interface DevToolRequestMessage {
package/dist/index.js CHANGED
@@ -41,6 +41,88 @@ module.exports = __toCommonJS(index_exports);
41
41
  // src/protocol.ts
42
42
  var SYNC_PROTOCOL_VERSION = 1;
43
43
 
44
+ // src/watchRegistry.ts
45
+ var toWatchKey = (targetDeviceId, toolId) => JSON.stringify([targetDeviceId, toolId]);
46
+ var fromWatchKey = (key) => JSON.parse(key);
47
+ var WatchRegistry = class {
48
+ constructor() {
49
+ /** Per dashboard: the key it watches → the mode it asked for. */
50
+ this.byDashboard = /* @__PURE__ */ new Map();
51
+ this.streamCounts = /* @__PURE__ */ new Map();
52
+ this.captureCounts = /* @__PURE__ */ new Map();
53
+ }
54
+ counts(mode) {
55
+ return mode === "stream" ? this.streamCounts : this.captureCounts;
56
+ }
57
+ bump(mode, key, delta) {
58
+ const counts = this.counts(mode);
59
+ const next = (counts.get(key) ?? 0) + delta;
60
+ if (next > 0) counts.set(key, next);
61
+ else counts.delete(key);
62
+ }
63
+ /** The strongest mode any dashboard currently wants; null for nobody. */
64
+ effective(key) {
65
+ if ((this.streamCounts.get(key) ?? 0) > 0) return "stream";
66
+ if ((this.captureCounts.get(key) ?? 0) > 0) return "capture";
67
+ return null;
68
+ }
69
+ /** Every key with at least one watcher (for replay to a reconnecting device). */
70
+ activeKeys() {
71
+ return [
72
+ .../* @__PURE__ */ new Set([...this.streamCounts.keys(), ...this.captureCounts.keys()])
73
+ ];
74
+ }
75
+ /**
76
+ * Record a watch. Returns the mode to send the device — always non-null,
77
+ * because the device answers a watch with a snapshot and that is how a
78
+ * dashboard joining a quiet tool gets its first paint. Re-watching at the
79
+ * same mode is therefore NOT a no-op at the socket layer.
80
+ */
81
+ watch(dashboardId, key, mode) {
82
+ let held = this.byDashboard.get(dashboardId);
83
+ if (!held) {
84
+ held = /* @__PURE__ */ new Map();
85
+ this.byDashboard.set(dashboardId, held);
86
+ }
87
+ const previous = held.get(key);
88
+ if (previous !== mode) {
89
+ if (previous) this.bump(previous, key, -1);
90
+ this.bump(mode, key, 1);
91
+ held.set(key, mode);
92
+ }
93
+ return this.effective(key);
94
+ }
95
+ /**
96
+ * Drop one dashboard's claim. Returns the new effective mode ONLY when it
97
+ * changed — the device is left alone while someone else still wants the
98
+ * same thing.
99
+ */
100
+ unwatch(dashboardId, key) {
101
+ const held = this.byDashboard.get(dashboardId);
102
+ const mode = held?.get(key);
103
+ if (!held || !mode) return { changed: false, mode: this.effective(key) };
104
+ const before = this.effective(key);
105
+ held.delete(key);
106
+ this.bump(mode, key, -1);
107
+ const after = this.effective(key);
108
+ return { changed: after !== before, mode: after };
109
+ }
110
+ /** Release everything a disconnecting dashboard held. */
111
+ releaseDashboard(dashboardId) {
112
+ const held = this.byDashboard.get(dashboardId);
113
+ if (!held) return [];
114
+ this.byDashboard.delete(dashboardId);
115
+ const changes = [];
116
+ for (const [key, mode] of held) {
117
+ const before = this.effective(key);
118
+ this.bump(mode, key, -1);
119
+ const after = this.effective(key);
120
+ if (after !== before) changes.push({ key, mode: after });
121
+ }
122
+ return changes;
123
+ }
124
+ };
125
+
44
126
  // src/socketHandle.ts
45
127
  var users = [];
46
128
  var allDevices = [];
@@ -48,10 +130,7 @@ var dashboardClients = [];
48
130
  var deviceIdMap = /* @__PURE__ */ new Map();
49
131
  var activeConnectionsMap = /* @__PURE__ */ new Map();
50
132
  var deviceCapabilities = /* @__PURE__ */ new Map();
51
- var dashboardWatches = /* @__PURE__ */ new Map();
52
- var watchCounts = /* @__PURE__ */ new Map();
53
- var toWatchKey = (targetDeviceId, toolId) => JSON.stringify([targetDeviceId, toolId]);
54
- var fromWatchKey = (key) => JSON.parse(key);
133
+ var watches = new WatchRegistry();
55
134
  var brokerLogBuffer = [];
56
135
  var BROKER_LOG_BUFFER_MAX = 200;
57
136
  var MAX_OFFLINE_AGE_MS = 24 * 60 * 60 * 1e3;
@@ -299,54 +378,42 @@ function socketHandle({ io }) {
299
378
  }
300
379
  }
301
380
  }
381
+ function emitWatchState(key, mode) {
382
+ const [targetDeviceId, toolId] = fromWatchKey(key);
383
+ const message = mode === null ? { toolId, targetDeviceId, watching: false, consolidated: true } : {
384
+ toolId,
385
+ targetDeviceId,
386
+ watching: true,
387
+ stream: mode === "stream",
388
+ consolidated: true
389
+ };
390
+ withTargetUsers(
391
+ targetDeviceId,
392
+ (user) => io.to(user.id).emit("devtool-watch", message),
393
+ `devtool ${mode === null ? "unwatch" : `watch(${mode})`} ${toolId}`
394
+ );
395
+ }
302
396
  function handleDevtoolWatch(dashboardId, message) {
303
397
  const key = toWatchKey(message.targetDeviceId, message.toolId);
304
- let watches = dashboardWatches.get(dashboardId);
305
- if (!watches) {
306
- watches = /* @__PURE__ */ new Set();
307
- dashboardWatches.set(dashboardId, watches);
308
- }
309
398
  if (message.watching) {
310
- if (!watches.has(key)) {
311
- watches.add(key);
312
- watchCounts.set(key, (watchCounts.get(key) ?? 0) + 1);
313
- }
399
+ const mode2 = message.stream === false ? "capture" : "stream";
400
+ const effective = watches.watch(dashboardId, key, mode2);
314
401
  console.log(
315
- `${LOG_PREFIX} Dashboard ${dashboardId} watching "${message.toolId}" on ${message.targetDeviceId}`
316
- );
317
- withTargetUsers(
318
- message.targetDeviceId,
319
- (user) => io.to(user.id).emit("devtool-watch", message),
320
- `devtool watch ${message.toolId}`
402
+ `${LOG_PREFIX} Dashboard ${dashboardId} watching "${message.toolId}" on ${message.targetDeviceId} (${mode2}, effective ${effective})`
321
403
  );
322
- } else if (watches.has(key)) {
323
- watches.delete(key);
324
- decrementWatch(key);
325
- }
326
- }
327
- function decrementWatch(key) {
328
- const next = (watchCounts.get(key) ?? 1) - 1;
329
- if (next > 0) {
330
- watchCounts.set(key, next);
404
+ emitWatchState(key, effective);
331
405
  return;
332
406
  }
333
- watchCounts.delete(key);
334
- const [targetDeviceId, toolId] = fromWatchKey(key);
407
+ const { changed, mode } = watches.unwatch(dashboardId, key);
408
+ if (!changed) return;
335
409
  console.log(
336
- `${LOG_PREFIX} Last dashboard stopped watching "${toolId}" on ${targetDeviceId}`
337
- );
338
- withTargetUsers(
339
- targetDeviceId,
340
- (user) => io.to(user.id).emit("devtool-watch", { toolId, targetDeviceId, watching: false }),
341
- `devtool unwatch ${toolId}`
410
+ mode === null ? `${LOG_PREFIX} Last dashboard stopped watching "${message.toolId}" on ${message.targetDeviceId}` : `${LOG_PREFIX} "${message.toolId}" on ${message.targetDeviceId} downgraded to ${mode}`
342
411
  );
412
+ emitWatchState(key, mode);
343
413
  }
344
414
  function releaseDashboardWatches(dashboardId) {
345
- const watches = dashboardWatches.get(dashboardId);
346
- if (!watches) return;
347
- dashboardWatches.delete(dashboardId);
348
- for (const key of watches) {
349
- decrementWatch(key);
415
+ for (const { key, mode } of watches.releaseDashboard(dashboardId)) {
416
+ emitWatchState(key, mode);
350
417
  }
351
418
  }
352
419
  io.on("connection", (socket) => {
@@ -424,15 +491,20 @@ function socketHandle({ io }) {
424
491
  }
425
492
  deviceCapabilities.set(message.persistentDeviceId, message);
426
493
  forwardToDashboards("devtool-capabilities", message);
427
- for (const key of watchCounts.keys()) {
494
+ for (const key of watches.activeKeys()) {
428
495
  const [targetDeviceId, toolId] = fromWatchKey(key);
429
- if (targetDeviceId === "All" || targetDeviceId === message.persistentDeviceId) {
430
- socket.emit("devtool-watch", {
431
- toolId,
432
- targetDeviceId,
433
- watching: true
434
- });
496
+ if (targetDeviceId !== "All" && targetDeviceId !== message.persistentDeviceId) {
497
+ continue;
435
498
  }
499
+ const mode = watches.effective(key);
500
+ if (!mode) continue;
501
+ socket.emit("devtool-watch", {
502
+ toolId,
503
+ targetDeviceId,
504
+ watching: true,
505
+ stream: mode === "stream",
506
+ consolidated: true
507
+ });
436
508
  }
437
509
  });
438
510
  socket.on("devtool-sync", (message) => {
package/dist/index.mjs CHANGED
@@ -1,6 +1,88 @@
1
1
  // src/protocol.ts
2
2
  var SYNC_PROTOCOL_VERSION = 1;
3
3
 
4
+ // src/watchRegistry.ts
5
+ var toWatchKey = (targetDeviceId, toolId) => JSON.stringify([targetDeviceId, toolId]);
6
+ var fromWatchKey = (key) => JSON.parse(key);
7
+ var WatchRegistry = class {
8
+ constructor() {
9
+ /** Per dashboard: the key it watches → the mode it asked for. */
10
+ this.byDashboard = /* @__PURE__ */ new Map();
11
+ this.streamCounts = /* @__PURE__ */ new Map();
12
+ this.captureCounts = /* @__PURE__ */ new Map();
13
+ }
14
+ counts(mode) {
15
+ return mode === "stream" ? this.streamCounts : this.captureCounts;
16
+ }
17
+ bump(mode, key, delta) {
18
+ const counts = this.counts(mode);
19
+ const next = (counts.get(key) ?? 0) + delta;
20
+ if (next > 0) counts.set(key, next);
21
+ else counts.delete(key);
22
+ }
23
+ /** The strongest mode any dashboard currently wants; null for nobody. */
24
+ effective(key) {
25
+ if ((this.streamCounts.get(key) ?? 0) > 0) return "stream";
26
+ if ((this.captureCounts.get(key) ?? 0) > 0) return "capture";
27
+ return null;
28
+ }
29
+ /** Every key with at least one watcher (for replay to a reconnecting device). */
30
+ activeKeys() {
31
+ return [
32
+ .../* @__PURE__ */ new Set([...this.streamCounts.keys(), ...this.captureCounts.keys()])
33
+ ];
34
+ }
35
+ /**
36
+ * Record a watch. Returns the mode to send the device — always non-null,
37
+ * because the device answers a watch with a snapshot and that is how a
38
+ * dashboard joining a quiet tool gets its first paint. Re-watching at the
39
+ * same mode is therefore NOT a no-op at the socket layer.
40
+ */
41
+ watch(dashboardId, key, mode) {
42
+ let held = this.byDashboard.get(dashboardId);
43
+ if (!held) {
44
+ held = /* @__PURE__ */ new Map();
45
+ this.byDashboard.set(dashboardId, held);
46
+ }
47
+ const previous = held.get(key);
48
+ if (previous !== mode) {
49
+ if (previous) this.bump(previous, key, -1);
50
+ this.bump(mode, key, 1);
51
+ held.set(key, mode);
52
+ }
53
+ return this.effective(key);
54
+ }
55
+ /**
56
+ * Drop one dashboard's claim. Returns the new effective mode ONLY when it
57
+ * changed — the device is left alone while someone else still wants the
58
+ * same thing.
59
+ */
60
+ unwatch(dashboardId, key) {
61
+ const held = this.byDashboard.get(dashboardId);
62
+ const mode = held?.get(key);
63
+ if (!held || !mode) return { changed: false, mode: this.effective(key) };
64
+ const before = this.effective(key);
65
+ held.delete(key);
66
+ this.bump(mode, key, -1);
67
+ const after = this.effective(key);
68
+ return { changed: after !== before, mode: after };
69
+ }
70
+ /** Release everything a disconnecting dashboard held. */
71
+ releaseDashboard(dashboardId) {
72
+ const held = this.byDashboard.get(dashboardId);
73
+ if (!held) return [];
74
+ this.byDashboard.delete(dashboardId);
75
+ const changes = [];
76
+ for (const [key, mode] of held) {
77
+ const before = this.effective(key);
78
+ this.bump(mode, key, -1);
79
+ const after = this.effective(key);
80
+ if (after !== before) changes.push({ key, mode: after });
81
+ }
82
+ return changes;
83
+ }
84
+ };
85
+
4
86
  // src/socketHandle.ts
5
87
  var users = [];
6
88
  var allDevices = [];
@@ -8,10 +90,7 @@ var dashboardClients = [];
8
90
  var deviceIdMap = /* @__PURE__ */ new Map();
9
91
  var activeConnectionsMap = /* @__PURE__ */ new Map();
10
92
  var deviceCapabilities = /* @__PURE__ */ new Map();
11
- var dashboardWatches = /* @__PURE__ */ new Map();
12
- var watchCounts = /* @__PURE__ */ new Map();
13
- var toWatchKey = (targetDeviceId, toolId) => JSON.stringify([targetDeviceId, toolId]);
14
- var fromWatchKey = (key) => JSON.parse(key);
93
+ var watches = new WatchRegistry();
15
94
  var brokerLogBuffer = [];
16
95
  var BROKER_LOG_BUFFER_MAX = 200;
17
96
  var MAX_OFFLINE_AGE_MS = 24 * 60 * 60 * 1e3;
@@ -259,54 +338,42 @@ function socketHandle({ io }) {
259
338
  }
260
339
  }
261
340
  }
341
+ function emitWatchState(key, mode) {
342
+ const [targetDeviceId, toolId] = fromWatchKey(key);
343
+ const message = mode === null ? { toolId, targetDeviceId, watching: false, consolidated: true } : {
344
+ toolId,
345
+ targetDeviceId,
346
+ watching: true,
347
+ stream: mode === "stream",
348
+ consolidated: true
349
+ };
350
+ withTargetUsers(
351
+ targetDeviceId,
352
+ (user) => io.to(user.id).emit("devtool-watch", message),
353
+ `devtool ${mode === null ? "unwatch" : `watch(${mode})`} ${toolId}`
354
+ );
355
+ }
262
356
  function handleDevtoolWatch(dashboardId, message) {
263
357
  const key = toWatchKey(message.targetDeviceId, message.toolId);
264
- let watches = dashboardWatches.get(dashboardId);
265
- if (!watches) {
266
- watches = /* @__PURE__ */ new Set();
267
- dashboardWatches.set(dashboardId, watches);
268
- }
269
358
  if (message.watching) {
270
- if (!watches.has(key)) {
271
- watches.add(key);
272
- watchCounts.set(key, (watchCounts.get(key) ?? 0) + 1);
273
- }
359
+ const mode2 = message.stream === false ? "capture" : "stream";
360
+ const effective = watches.watch(dashboardId, key, mode2);
274
361
  console.log(
275
- `${LOG_PREFIX} Dashboard ${dashboardId} watching "${message.toolId}" on ${message.targetDeviceId}`
276
- );
277
- withTargetUsers(
278
- message.targetDeviceId,
279
- (user) => io.to(user.id).emit("devtool-watch", message),
280
- `devtool watch ${message.toolId}`
362
+ `${LOG_PREFIX} Dashboard ${dashboardId} watching "${message.toolId}" on ${message.targetDeviceId} (${mode2}, effective ${effective})`
281
363
  );
282
- } else if (watches.has(key)) {
283
- watches.delete(key);
284
- decrementWatch(key);
285
- }
286
- }
287
- function decrementWatch(key) {
288
- const next = (watchCounts.get(key) ?? 1) - 1;
289
- if (next > 0) {
290
- watchCounts.set(key, next);
364
+ emitWatchState(key, effective);
291
365
  return;
292
366
  }
293
- watchCounts.delete(key);
294
- const [targetDeviceId, toolId] = fromWatchKey(key);
367
+ const { changed, mode } = watches.unwatch(dashboardId, key);
368
+ if (!changed) return;
295
369
  console.log(
296
- `${LOG_PREFIX} Last dashboard stopped watching "${toolId}" on ${targetDeviceId}`
297
- );
298
- withTargetUsers(
299
- targetDeviceId,
300
- (user) => io.to(user.id).emit("devtool-watch", { toolId, targetDeviceId, watching: false }),
301
- `devtool unwatch ${toolId}`
370
+ mode === null ? `${LOG_PREFIX} Last dashboard stopped watching "${message.toolId}" on ${message.targetDeviceId}` : `${LOG_PREFIX} "${message.toolId}" on ${message.targetDeviceId} downgraded to ${mode}`
302
371
  );
372
+ emitWatchState(key, mode);
303
373
  }
304
374
  function releaseDashboardWatches(dashboardId) {
305
- const watches = dashboardWatches.get(dashboardId);
306
- if (!watches) return;
307
- dashboardWatches.delete(dashboardId);
308
- for (const key of watches) {
309
- decrementWatch(key);
375
+ for (const { key, mode } of watches.releaseDashboard(dashboardId)) {
376
+ emitWatchState(key, mode);
310
377
  }
311
378
  }
312
379
  io.on("connection", (socket) => {
@@ -384,15 +451,20 @@ function socketHandle({ io }) {
384
451
  }
385
452
  deviceCapabilities.set(message.persistentDeviceId, message);
386
453
  forwardToDashboards("devtool-capabilities", message);
387
- for (const key of watchCounts.keys()) {
454
+ for (const key of watches.activeKeys()) {
388
455
  const [targetDeviceId, toolId] = fromWatchKey(key);
389
- if (targetDeviceId === "All" || targetDeviceId === message.persistentDeviceId) {
390
- socket.emit("devtool-watch", {
391
- toolId,
392
- targetDeviceId,
393
- watching: true
394
- });
456
+ if (targetDeviceId !== "All" && targetDeviceId !== message.persistentDeviceId) {
457
+ continue;
395
458
  }
459
+ const mode = watches.effective(key);
460
+ if (!mode) continue;
461
+ socket.emit("devtool-watch", {
462
+ toolId,
463
+ targetDeviceId,
464
+ watching: true,
465
+ stream: mode === "stream",
466
+ consolidated: true
467
+ });
396
468
  }
397
469
  });
398
470
  socket.on("devtool-sync", (message) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buoy-gg/sync-broker",
3
- "version": "7.0.17",
3
+ "version": "7.0.18",
4
4
  "description": "Headless Socket.IO broker for the Buoy devtool sync protocol (v1) — routes messages between devices and dashboard/MCP clients",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",