@scp-dev/broker 0.1.6 → 0.2.1

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.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ScopePreset, Claim, EndpointView, ScopeView, ScopeChange, Envelope, StopWaitResult, PeerView, WhyResult, SendParams, SendResult, ActivityRow, XListed, Selector } from '@scp-dev/core';
2
- import { JudgeGate as JudgeGate$1, JudgeGateOptions, FederatedCore, Deliver, JudgeClientFactory } from '@scp-dev/hub';
2
+ import { LOCAL_EVENT_KINDS, JudgeGate as JudgeGate$1, JudgeGateOptions, FederatedCore, Deliver, JudgeClientFactory } from '@scp-dev/hub';
3
3
  export { Deliver } from '@scp-dev/hub';
4
4
 
5
5
  interface Hello {
@@ -203,6 +203,10 @@ interface BridgeConfig {
203
203
  redact?: boolean | undefined;
204
204
  /** let messages marked private leave this machine */
205
205
  privateOk?: boolean | undefined;
206
+ /** report metadata of deliveries made on this machine to the hub, never bodies (default true) */
207
+ mirrorLocal?: boolean | undefined;
208
+ /** how often reported local deliveries are sent, batched (default 1 s) */
209
+ localFlushMs?: number | undefined;
206
210
  /** a WebSocket constructor (tests) */
207
211
  webSocket?: WebSocketWithHeaders | undefined;
208
212
  /** reconnect backoff bounds (default 1 s to 60 s) */
@@ -236,6 +240,8 @@ interface Bridge {
236
240
  renamed(endpoint: string, was: string): void;
237
241
  /** a local endpoint's activity, posted to the hub */
238
242
  activity(endpoint: string, row: ActivityRow): void;
243
+ /** a delivery between endpoints of this machine: its metadata is reported to the hub in the next batch */
244
+ local?(event: LocalDelivery): void;
239
245
  /** the endpoints of other accounts this account's contacts let it reach */
240
246
  contacts(): Promise<XListed[]>;
241
247
  status(): BridgeStatus;
@@ -243,6 +249,15 @@ interface Bridge {
243
249
  relink(): void;
244
250
  stop(): Promise<void>;
245
251
  }
252
+ /** a delivery the broker made on its own machine, by local endpoint ids */
253
+ interface LocalDelivery {
254
+ id: string;
255
+ thread: string;
256
+ kind: (typeof LOCAL_EVENT_KINDS)[number];
257
+ from: string;
258
+ to: string[];
259
+ at: number;
260
+ }
246
261
  /** a WebSocket constructor that takes request headers, as Bun's and Node's do */
247
262
  type WebSocketWithHeaders = new (url: string, options: {
248
263
  headers: Record<string, string>;
@@ -319,6 +334,8 @@ interface BrokerOptions {
319
334
  link?: LinkFactory | undefined;
320
335
  /** how often presence that decays without a request (a hook-only endpoint) is checked for changes (default 60 s) */
321
336
  presenceEveryMs?: number | undefined;
337
+ /** how often the broker checks it still owns its socket and lock, stopping once another broker or a wiped state directory took them (default 2 s) */
338
+ ownershipEveryMs?: number | undefined;
322
339
  /** where a judge model's key_env is read (default the broker's environment) */
323
340
  env?: Record<string, string | undefined> | undefined;
324
341
  /** the judge model client for a policy's `judge.model` (default the provider's HTTP API) */
package/dist/index.js CHANGED
@@ -524,6 +524,7 @@ async function connectBroker(dir, start, timeoutMs = 5e3) {
524
524
  }
525
525
 
526
526
  // src/bridge.ts
527
+ import { createHash as createHash2 } from "crypto";
527
528
  import {
528
529
  findSecret,
529
530
  matches,
@@ -532,7 +533,9 @@ import {
532
533
  RelayError as RelayError2,
533
534
  XLIMITS
534
535
  } from "@scp-dev/core";
535
- import { HUB_LIMITS } from "@scp-dev/hub";
536
+ import { HUB_LIMITS, LOCAL_EVENTS } from "@scp-dev/hub";
537
+ var LOCAL_BUFFER = 1e3;
538
+ var idHash = (id) => createHash2("sha256").update(id).digest("hex").slice(0, 32);
536
539
  var CONNECT_TIMEOUT_MS = 15e3;
537
540
  var MIRROR_FRAME_BYTES = HUB_LIMITS.frameBytes - 1024;
538
541
  var DEAD_LETTER_ATTEMPTS = 3;
@@ -578,6 +581,10 @@ function startBridge(options) {
578
581
  const inflight = /* @__PURE__ */ new Set();
579
582
  const injectFailures = /* @__PURE__ */ new Map();
580
583
  const fromHub = /* @__PURE__ */ new WeakSet();
584
+ const mirrorLocal = options.mirrorLocal !== false;
585
+ const localFlushMs = options.localFlushMs ?? 1e3;
586
+ let localQueue = [];
587
+ let localTimer;
581
588
  const setState = (up, error) => {
582
589
  if (up !== connected || error !== lastError) since = (/* @__PURE__ */ new Date()).toISOString();
583
590
  connected = up;
@@ -699,7 +706,8 @@ function startBridge(options) {
699
706
  byMachine.set(view.machine, [...byMachine.get(view.machine) ?? [], view]);
700
707
  }
701
708
  if (full) {
702
- for (const view of core.remoteViews()) if (view.machine !== account && !byMachine.has(view.machine)) byMachine.set(view.machine, []);
709
+ core.dropMachine(machine);
710
+ for (const view of core.remoteViews()) if (view.machine !== account && !byMachine.has(view.machine)) core.dropMachine(view.machine);
703
711
  }
704
712
  for (const [name, list] of byMachine) {
705
713
  try {
@@ -800,7 +808,8 @@ function startBridge(options) {
800
808
  if (typeof params.machine !== "string" || params.machine === machine) return;
801
809
  if (typeof params.own === "boolean") core.setOther(params.machine, !params.own);
802
810
  try {
803
- core.mirror(params.machine, params.endpoints, { full: params.full === true });
811
+ if (params.released === true) core.dropMachine(params.machine);
812
+ else core.mirror(params.machine, params.endpoints, { full: params.full === true });
804
813
  } catch (error) {
805
814
  log(`relay bridge: the endpoints of ${params.machine} were not applied: ${error instanceof Error ? error.message : String(error)}`);
806
815
  }
@@ -887,21 +896,21 @@ function startBridge(options) {
887
896
  if (event.machine === machine) return;
888
897
  if (event.type === "message" && Array.isArray(event.to) && account && event.to.some((to) => typeof to === "string" && to.startsWith(`${account}/${machine}/`)))
889
898
  return;
890
- const local = {
899
+ const local2 = {
891
900
  ...event,
892
901
  ...event.from !== void 0 ? { from: localAddress(event.from) } : {},
893
902
  ...event.endpoint !== void 0 ? { endpoint: localAddress(event.endpoint) } : {}
894
903
  };
895
- if (Array.isArray(event.to)) local.to = event.to.map(localAddress);
904
+ if (Array.isArray(event.to)) local2.to = event.to.map(localAddress);
896
905
  if (event.type === "activity" && typeof event.machine === "string" && typeof event.endpoint === "string") {
897
906
  try {
898
907
  const proxy = core.remoteEndpoint(event.machine, event.endpoint.slice(event.endpoint.lastIndexOf("/") + 1));
899
908
  const row = core.event(proxy, { kind: event.kind, tool: event.tool, summary: event.summary });
900
- local.id = row.id;
909
+ local2.id = row.id;
901
910
  } catch {
902
911
  }
903
912
  }
904
- hooks.fleet(local);
913
+ hooks.fleet(local2);
905
914
  }
906
915
  function linkUp(ms) {
907
916
  if (connected) return Promise.resolve(true);
@@ -1017,6 +1026,30 @@ function startBridge(options) {
1017
1026
  if (handle === null) return;
1018
1027
  fireAndForget("event", { from: handle, kind: row.kind, tool: row.tool ?? void 0, summary: options.redact ? void 0 : row.summary ?? void 0 });
1019
1028
  }
1029
+ function local(event) {
1030
+ if (!mirrorLocal || !ready || localQueue.length >= LOCAL_BUFFER) return;
1031
+ let from;
1032
+ let to;
1033
+ try {
1034
+ from = handleOf(event.from);
1035
+ to = event.to.flatMap((endpoint) => {
1036
+ const handle = handleOf(endpoint);
1037
+ return handle === null ? [] : [handle];
1038
+ });
1039
+ } catch {
1040
+ return;
1041
+ }
1042
+ if (from === null || !to.length) return;
1043
+ localQueue.push({ from, to: to.slice(0, LOCAL_EVENTS.recipients), kind: event.kind, at: event.at, id: idHash(event.id), thread: idHash(event.thread) });
1044
+ localTimer ??= setTimeout(flushLocal, localFlushMs);
1045
+ }
1046
+ function flushLocal() {
1047
+ localTimer = void 0;
1048
+ const batch = localQueue;
1049
+ localQueue = [];
1050
+ if (!ready) return;
1051
+ for (let i = 0; i < batch.length; i += LOCAL_EVENTS.perFrame) fireAndForget("local_events", { events: batch.slice(i, i + LOCAL_EVENTS.perFrame) });
1052
+ }
1020
1053
  async function contacts() {
1021
1054
  if (!connected) throw unavailable();
1022
1055
  return (await request("list", { contacts: true })).contacts;
@@ -1038,6 +1071,8 @@ function startBridge(options) {
1038
1071
  stopped = true;
1039
1072
  shutDown = true;
1040
1073
  clearTimeout(retryTimer);
1074
+ clearTimeout(localTimer);
1075
+ localQueue = [];
1041
1076
  for (const fn of waitingForLink.splice(0)) fn();
1042
1077
  const socket = ws;
1043
1078
  ws = null;
@@ -1048,11 +1083,11 @@ function startBridge(options) {
1048
1083
  if (socket && socket.readyState <= 1) socket.close(1e3, "the broker stopped");
1049
1084
  }
1050
1085
  connect();
1051
- return { forward, changed, resync, renamed, activity, contacts, status, relink, stop };
1086
+ return { forward, changed, resync, renamed, activity, local, contacts, status, relink, stop };
1052
1087
  }
1053
1088
 
1054
1089
  // src/server.ts
1055
- import { createHash as createHash3 } from "crypto";
1090
+ import { createHash as createHash4 } from "crypto";
1056
1091
  import { chmodSync as chmodSync2, existsSync as existsSync2, lstatSync as lstatSync2, readFileSync as readFileSync6, realpathSync, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
1057
1092
  import { createServer } from "net";
1058
1093
  import { homedir as homedir2 } from "os";
@@ -1089,11 +1124,11 @@ var JudgeGate = class extends HubJudgeGate {
1089
1124
  };
1090
1125
 
1091
1126
  // src/sessions.ts
1092
- import { createHash as createHash2, randomUUID } from "crypto";
1127
+ import { createHash as createHash3, randomUUID } from "crypto";
1093
1128
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync5, renameSync as renameSync2, rmSync, writeFileSync } from "fs";
1094
1129
  import { join as join3 } from "path";
1095
1130
  function markerPath(dir, session) {
1096
- return join3(dir, "sessions", `${createHash2("sha256").update(session).digest("hex").slice(0, 32)}.json`);
1131
+ return join3(dir, "sessions", `${createHash3("sha256").update(session).digest("hex").slice(0, 32)}.json`);
1097
1132
  }
1098
1133
  function readSessionMarker(dir, session) {
1099
1134
  try {
@@ -1191,6 +1226,7 @@ function openDatabase(path) {
1191
1226
  // src/server.ts
1192
1227
  var SWEEP_MS = 10 * 6e4;
1193
1228
  var PRESENCE_MS = 6e4;
1229
+ var OWNERSHIP_MS = 2e3;
1194
1230
  var OBSERVER_METHODS = /* @__PURE__ */ new Set(["list", "snapshot", "watch", "log", "policy", "policy_check", "cloud_status", "bridge_relink"]);
1195
1231
  var SCOPE_METHODS = /* @__PURE__ */ new Set([
1196
1232
  "rules",
@@ -1271,6 +1307,7 @@ async function startBroker(options) {
1271
1307
  let judge;
1272
1308
  let bridge = null;
1273
1309
  let listener;
1310
+ let socketIno;
1274
1311
  try {
1275
1312
  privateDir(dirname2(socket));
1276
1313
  db = openDatabase(join4(dir, "relay.db"));
@@ -1282,11 +1319,12 @@ async function startBroker(options) {
1282
1319
  core = new FederatedCore(sql, {
1283
1320
  ...compact({ user: options.user, machine, now: options.now }),
1284
1321
  hostname: shortHostname(),
1285
- hash: (value) => createHash3("sha256").update(value).digest("hex"),
1322
+ hash: (value) => createHash4("sha256").update(value).digest("hex"),
1286
1323
  transaction: (fn) => db.transaction(fn),
1287
1324
  alive: running
1288
1325
  });
1289
1326
  core.migrate();
1327
+ core.relabelMachine();
1290
1328
  core.resetConnections();
1291
1329
  core.sweep();
1292
1330
  judge = new JudgeGate({ sql, core, now: options.now ?? Date.now, env: options.env ?? process.env, client: options.judgeClient, log, wake });
@@ -1323,6 +1361,7 @@ async function startBroker(options) {
1323
1361
  });
1324
1362
  listener.on("error", (error) => log(`relay broker: the socket listener failed: ${error.message}`));
1325
1363
  chmodSync2(socket, 384);
1364
+ socketIno = lstatSync2(socket).ino;
1326
1365
  } catch (error) {
1327
1366
  listener?.close();
1328
1367
  db?.close();
@@ -1346,6 +1385,22 @@ async function startBroker(options) {
1346
1385
  }
1347
1386
  for (const id of known.keys()) if (!live.has(id)) known.delete(id);
1348
1387
  }, options.presenceEveryMs ?? PRESENCE_MS);
1388
+ const ownership = setInterval(() => {
1389
+ if (closing || ownsState()) return;
1390
+ log(`relay broker: ${socket} or its lock was removed or taken over; stopping`);
1391
+ void stop();
1392
+ }, options.ownershipEveryMs ?? OWNERSHIP_MS);
1393
+ ownership.unref?.();
1394
+ function ownsSocket() {
1395
+ return lstatSync2(socket, { throwIfNoEntry: false })?.ino === socketIno;
1396
+ }
1397
+ function ownsState() {
1398
+ try {
1399
+ return ownsSocket() && readFileSync6(lock.path, "utf8") === lock.content;
1400
+ } catch {
1401
+ return false;
1402
+ }
1403
+ }
1349
1404
  const remoteHooks = {
1350
1405
  wake,
1351
1406
  fleet: notifyFleet,
@@ -1434,6 +1489,20 @@ async function startBroker(options) {
1434
1489
  function localViews() {
1435
1490
  return core.list({ all: true }).filter((view) => core.machineOf(view.id) === null);
1436
1491
  }
1492
+ function setWaker(s, endpoint) {
1493
+ const was = s.data.waker;
1494
+ if (was === endpoint) return;
1495
+ s.data.waker = endpoint;
1496
+ if (was) core.waker(was, false, { grace: s.data.transient === true });
1497
+ if (endpoint) core.waker(endpoint, true);
1498
+ for (const id of [was, endpoint]) if (id) presenceMoved(id);
1499
+ }
1500
+ function presenceMoved(id) {
1501
+ const view = core.whoami(id);
1502
+ if ((known.get(id) ?? "offline") === view.presence) return false;
1503
+ presenceChanged(view);
1504
+ return true;
1505
+ }
1437
1506
  function presenceChanged(view) {
1438
1507
  known.set(view.id, view.presence);
1439
1508
  notifyFleet({ type: "presence", endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels });
@@ -1585,6 +1654,7 @@ async function startBroker(options) {
1585
1654
  s.data.closed = true;
1586
1655
  clearTimeout(s.data.lineTimer);
1587
1656
  connections.delete(s);
1657
+ if (!closing) setWaker(s, void 0);
1588
1658
  if (s.data.endpoint && !closing && !s.data.transient && !s.data.lineage) {
1589
1659
  const before = core.whoami(s.data.endpoint).presence;
1590
1660
  const settled = core.disconnect(s.data.endpoint, { settle: !s.data.brief });
@@ -1878,7 +1948,8 @@ async function startBroker(options) {
1878
1948
  case "wake": {
1879
1949
  if (s.data.lineage && !s.data.own) throw new RelayError3("unauthorized", "only the endpoint's own session says how it wakes");
1880
1950
  const changed = core.setWake(me, params.via, hookOf(s));
1881
- if (changed) bridge?.changed(me);
1951
+ setWaker(s, params.via !== "none" && core.whoami(me).wake === params.via ? me : void 0);
1952
+ if (!presenceMoved(me) && changed) bridge?.changed(me);
1882
1953
  return { changed };
1883
1954
  }
1884
1955
  case "rename": {
@@ -1981,13 +2052,23 @@ async function startBroker(options) {
1981
2052
  }
1982
2053
  const deliver = async (from, send, origin) => {
1983
2054
  let result;
1984
- if (remoteSend(core, send.to)) {
2055
+ const remote = remoteSend(core, send.to);
2056
+ if (remote) {
1985
2057
  if (!bridge)
1986
2058
  throw new RelayError3("not_found", `no endpoint ${[send.to].flat().join(", ")}: this broker is not linked to a hub (${CLI_NAME} cloud enable)`);
1987
2059
  result = await bridge.forward(from, send, origin);
1988
2060
  } else {
1989
2061
  result = core.send(from, send, origin);
1990
2062
  }
2063
+ if (!result.duplicate && !remote)
2064
+ bridge?.local?.({
2065
+ id: result.id,
2066
+ thread: result.thread,
2067
+ kind: send.kind ?? "message",
2068
+ from,
2069
+ to: result.deliveries.map((d) => d.endpoint),
2070
+ at: (options.now ?? Date.now)()
2071
+ });
1991
2072
  if (!result.duplicate) {
1992
2073
  for (const delivery of result.deliveries) wake(delivery.endpoint);
1993
2074
  notifyFleet({
@@ -2113,6 +2194,14 @@ async function startBroker(options) {
2113
2194
  if (result.handback) {
2114
2195
  const message = core.delivered(spawner, result.handback);
2115
2196
  wake(spawner);
2197
+ bridge?.local?.({
2198
+ id: message.id,
2199
+ thread: message.thread,
2200
+ kind: "handback",
2201
+ from: message.from.endpoint,
2202
+ to: [spawner],
2203
+ at: (options.now ?? Date.now)()
2204
+ });
2116
2205
  notifyFleet({
2117
2206
  type: "message",
2118
2207
  id: message.id,
@@ -2163,6 +2252,7 @@ async function startBroker(options) {
2163
2252
  closing = true;
2164
2253
  clearInterval(sweeper);
2165
2254
  clearInterval(presenceTimer);
2255
+ clearInterval(ownership);
2166
2256
  await bridge?.stop();
2167
2257
  if (idleTimer) clearTimeout(idleTimer);
2168
2258
  for (const set of [...waiters.values()]) for (const fn of [...set]) fn();
@@ -2170,11 +2260,13 @@ async function startBroker(options) {
2170
2260
  while (pending > 0 && Date.now() < deadline) await sleep2(5);
2171
2261
  await Promise.race([judge.settled(), sleep2(Math.max(0, deadline - Date.now()))]);
2172
2262
  for (const s of connections) s.end();
2173
- listener.close();
2174
- try {
2175
- unlinkSync2(socket);
2176
- } catch {
2177
- }
2263
+ if (ownsSocket()) {
2264
+ listener.close();
2265
+ try {
2266
+ unlinkSync2(socket);
2267
+ } catch {
2268
+ }
2269
+ } else listener.unref();
2178
2270
  if (socket !== join4(dir, "relay.sock")) {
2179
2271
  try {
2180
2272
  rmdirSync(dirname2(socket));
package/dist/testing.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { RelayHub, HubSocket } from '@scp-dev/hub';
1
+ import { RelayHub, HubSocket, DashboardEvent } from '@scp-dev/hub';
2
2
 
3
3
  interface FakeToken {
4
4
  tokenId: string;
@@ -27,6 +27,8 @@ declare function startFakeHubServer(options: {
27
27
  tokens: Record<string, FakeToken>;
28
28
  now?: (() => number) | undefined;
29
29
  contacts?: [string, string][] | undefined;
30
+ /** receives every account's dashboard events */
31
+ dashboard?: ((account: string, event: DashboardEvent) => void) | undefined;
30
32
  }): FakeHubServer;
31
33
 
32
34
  export { type FakeHubServer, type FakeToken, startFakeHubServer };
package/dist/testing.js CHANGED
@@ -37,7 +37,8 @@ function startFakeHubServer(options) {
37
37
  sockets: () => [...sockets.values()],
38
38
  save: () => {
39
39
  },
40
- ...options.contacts ? { crossAccount: linkOf(name) } : {}
40
+ ...options.contacts ? { crossAccount: linkOf(name) } : {},
41
+ ...options.dashboard ? { dashboard: (event) => options.dashboard(name, event) } : {}
41
42
  });
42
43
  hub.init({ user: name });
43
44
  found = { hub, sockets, traps: /* @__PURE__ */ new Map() };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/broker",
3
- "version": "0.1.6",
3
+ "version": "0.2.1",
4
4
  "description": "Swarm Communication Protocol local broker: a unix socket JSON-RPC daemon over the relay core, its client and the bridge to an account hub.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://scp.dev",
@@ -37,8 +37,8 @@
37
37
  "typecheck": "tsc -p ."
38
38
  },
39
39
  "dependencies": {
40
- "@scp-dev/core": "0.1.4",
41
- "@scp-dev/hub": "0.4.0"
40
+ "@scp-dev/core": "0.1.6",
41
+ "@scp-dev/hub": "0.5.1"
42
42
  },
43
43
  "devDependencies": {
44
44
  "tsup": "^8.5.1"
package/src/bridge.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto"
1
2
  import {
2
3
  type ActivityRow,
3
4
  type EndpointView,
@@ -13,7 +14,7 @@ import {
13
14
  XLIMITS,
14
15
  type XListed,
15
16
  } from "@scp-dev/core"
16
- import { type FederatedCore, HUB_LIMITS, type RemoteView } from "@scp-dev/hub"
17
+ import { type FederatedCore, HUB_LIMITS, type LOCAL_EVENT_KINDS, LOCAL_EVENTS, type RemoteView } from "@scp-dev/hub"
17
18
 
18
19
  /** what the broker gives the bridge: waking local waiters, telling local watchers, the local policy's state */
19
20
  export interface BridgeHooks {
@@ -42,6 +43,10 @@ export interface BridgeConfig {
42
43
  redact?: boolean | undefined
43
44
  /** let messages marked private leave this machine */
44
45
  privateOk?: boolean | undefined
46
+ /** report metadata of deliveries made on this machine to the hub, never bodies (default true) */
47
+ mirrorLocal?: boolean | undefined
48
+ /** how often reported local deliveries are sent, batched (default 1 s) */
49
+ localFlushMs?: number | undefined
45
50
  /** a WebSocket constructor (tests) */
46
51
  webSocket?: WebSocketWithHeaders | undefined
47
52
  /** reconnect backoff bounds (default 1 s to 60 s) */
@@ -77,6 +82,8 @@ export interface Bridge {
77
82
  renamed(endpoint: string, was: string): void
78
83
  /** a local endpoint's activity, posted to the hub */
79
84
  activity(endpoint: string, row: ActivityRow): void
85
+ /** a delivery between endpoints of this machine: its metadata is reported to the hub in the next batch */
86
+ local?(event: LocalDelivery): void
80
87
  /** the endpoints of other accounts this account's contacts let it reach */
81
88
  contacts(): Promise<XListed[]>
82
89
  status(): BridgeStatus
@@ -85,6 +92,21 @@ export interface Bridge {
85
92
  stop(): Promise<void>
86
93
  }
87
94
 
95
+ /** a delivery the broker made on its own machine, by local endpoint ids */
96
+ export interface LocalDelivery {
97
+ id: string
98
+ thread: string
99
+ kind: (typeof LOCAL_EVENT_KINDS)[number]
100
+ from: string
101
+ to: string[]
102
+ at: number
103
+ }
104
+
105
+ /** local deliveries waiting for the next batch; past this, new ones are dropped until it is sent */
106
+ const LOCAL_BUFFER = 1000
107
+
108
+ const idHash = (id: string): string => createHash("sha256").update(id).digest("hex").slice(0, 32)
109
+
88
110
  /** a WebSocket constructor that takes request headers, as Bun's and Node's do */
89
111
  export type WebSocketWithHeaders = new (url: string, options: { headers: Record<string, string> }) => WebSocket
90
112
 
@@ -153,6 +175,10 @@ export function startBridge(options: BridgeOptions): Bridge {
153
175
  const inflight = new Set<number>()
154
176
  const injectFailures = new Map<number, number>()
155
177
  const fromHub = new WeakSet<Error>()
178
+ const mirrorLocal = options.mirrorLocal !== false
179
+ const localFlushMs = options.localFlushMs ?? 1000
180
+ let localQueue: object[] = []
181
+ let localTimer: ReturnType<typeof setTimeout> | undefined
156
182
 
157
183
  const setState = (up: boolean, error: string | null) => {
158
184
  if (up !== connected || error !== lastError) since = new Date().toISOString()
@@ -274,14 +300,20 @@ export function startBridge(options: BridgeOptions): Bridge {
274
300
  for (const fn of waitingForLink.splice(0)) fn()
275
301
  }
276
302
 
277
- /** the other machines' endpoints, grouped by machine; with `full`, a known machine missing from the list goes offline */
303
+ /**
304
+ * the other machines' endpoints, grouped by machine; with `full`, a known machine the hub no longer lists was released and its rows go,
305
+ * as do rows mirrored under this machine's own name from before it was renamed back to it
306
+ */
278
307
  function applyRemote(views: RemoteView[], full: boolean) {
279
308
  const byMachine = new Map<string, RemoteView[]>()
280
309
  for (const view of views) {
281
310
  if (view.machine === machine) continue
282
311
  byMachine.set(view.machine, [...(byMachine.get(view.machine) ?? []), view])
283
312
  }
284
- if (full) for (const view of core.remoteViews()) if (view.machine !== account && !byMachine.has(view.machine)) byMachine.set(view.machine, [])
313
+ if (full) {
314
+ core.dropMachine(machine)
315
+ for (const view of core.remoteViews()) if (view.machine !== account && !byMachine.has(view.machine)) core.dropMachine(view.machine)
316
+ }
285
317
  for (const [name, list] of byMachine) {
286
318
  try {
287
319
  core.mirror(name, list, { full })
@@ -397,7 +429,8 @@ export function startBridge(options: BridgeOptions): Bridge {
397
429
  if (typeof params.machine !== "string" || params.machine === machine) return
398
430
  if (typeof params.own === "boolean") core.setOther(params.machine, !params.own)
399
431
  try {
400
- core.mirror(params.machine, params.endpoints, { full: params.full === true })
432
+ if (params.released === true) core.dropMachine(params.machine)
433
+ else core.mirror(params.machine, params.endpoints, { full: params.full === true })
401
434
  } catch (error) {
402
435
  log(`relay bridge: the endpoints of ${params.machine} were not applied: ${error instanceof Error ? error.message : String(error)}`)
403
436
  }
@@ -636,6 +669,32 @@ export function startBridge(options: BridgeOptions): Bridge {
636
669
  fireAndForget("event", { from: handle, kind: row.kind, tool: row.tool ?? undefined, summary: options.redact ? undefined : (row.summary ?? undefined) })
637
670
  }
638
671
 
672
+ function local(event: LocalDelivery) {
673
+ if (!mirrorLocal || !ready || localQueue.length >= LOCAL_BUFFER) return
674
+ let from: string | null
675
+ let to: string[]
676
+ try {
677
+ from = handleOf(event.from)
678
+ to = event.to.flatMap((endpoint) => {
679
+ const handle = handleOf(endpoint)
680
+ return handle === null ? [] : [handle]
681
+ })
682
+ } catch {
683
+ return
684
+ }
685
+ if (from === null || !to.length) return
686
+ localQueue.push({ from, to: to.slice(0, LOCAL_EVENTS.recipients), kind: event.kind, at: event.at, id: idHash(event.id), thread: idHash(event.thread) })
687
+ localTimer ??= setTimeout(flushLocal, localFlushMs)
688
+ }
689
+
690
+ function flushLocal() {
691
+ localTimer = undefined
692
+ const batch = localQueue
693
+ localQueue = []
694
+ if (!ready) return
695
+ for (let i = 0; i < batch.length; i += LOCAL_EVENTS.perFrame) fireAndForget("local_events", { events: batch.slice(i, i + LOCAL_EVENTS.perFrame) })
696
+ }
697
+
639
698
  async function contacts(): Promise<XListed[]> {
640
699
  if (!connected) throw unavailable()
641
700
  return (await request<{ contacts: XListed[] }>("list", { contacts: true })).contacts
@@ -660,6 +719,8 @@ export function startBridge(options: BridgeOptions): Bridge {
660
719
  stopped = true
661
720
  shutDown = true
662
721
  clearTimeout(retryTimer)
722
+ clearTimeout(localTimer)
723
+ localQueue = []
663
724
  for (const fn of waitingForLink.splice(0)) fn()
664
725
  const socket = ws
665
726
  ws = null
@@ -671,5 +732,5 @@ export function startBridge(options: BridgeOptions): Bridge {
671
732
  }
672
733
 
673
734
  connect()
674
- return { forward, changed, resync, renamed, activity, contacts, status, relink, stop }
735
+ return { forward, changed, resync, renamed, activity, local, contacts, status, relink, stop }
675
736
  }
package/src/server.ts CHANGED
@@ -64,6 +64,8 @@ export interface BrokerOptions {
64
64
  link?: LinkFactory | undefined
65
65
  /** how often presence that decays without a request (a hook-only endpoint) is checked for changes (default 60 s) */
66
66
  presenceEveryMs?: number | undefined
67
+ /** how often the broker checks it still owns its socket and lock, stopping once another broker or a wiped state directory took them (default 2 s) */
68
+ ownershipEveryMs?: number | undefined
67
69
  /** where a judge model's key_env is read (default the broker's environment) */
68
70
  env?: Record<string, string | undefined> | undefined
69
71
  /** the judge model client for a policy's `judge.model` (default the provider's HTTP API) */
@@ -114,6 +116,8 @@ interface Connection {
114
116
  process?: string
115
117
  /** a one-shot CLI command's connection: its comings and goings are not fleet presence events */
116
118
  brief?: boolean
119
+ /** the endpoint whose wake this connection set: it counts as its waker until it closes or sets none */
120
+ waker?: string | undefined
117
121
  cwd?: string | undefined
118
122
  watching: boolean
119
123
  closed: boolean
@@ -126,6 +130,7 @@ interface Connection {
126
130
 
127
131
  const SWEEP_MS = 10 * 60_000
128
132
  const PRESENCE_MS = 60_000
133
+ const OWNERSHIP_MS = 2_000
129
134
  const OBSERVER_METHODS = new Set(["list", "snapshot", "watch", "log", "policy", "policy_check", "cloud_status", "bridge_relink"])
130
135
  const SCOPE_METHODS = new Set([
131
136
  "rules",
@@ -219,6 +224,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
219
224
  let judge!: JudgeGate
220
225
  let bridge: Bridge | null = null
221
226
  let listener!: Server
227
+ let socketIno: number | bigint | undefined
222
228
  try {
223
229
  privateDir(dirname(socket))
224
230
  db = openDatabase(join(dir, "relay.db"))
@@ -235,6 +241,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
235
241
  alive: running,
236
242
  })
237
243
  core.migrate()
244
+ core.relabelMachine()
238
245
  core.resetConnections()
239
246
  core.sweep()
240
247
  judge = new JudgeGate({ sql, core, now: options.now ?? Date.now, env: options.env ?? process.env, client: options.judgeClient, log, wake })
@@ -271,6 +278,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
271
278
  })
272
279
  listener.on("error", (error) => log(`relay broker: the socket listener failed: ${error.message}`))
273
280
  chmodSync(socket, 0o600)
281
+ socketIno = lstatSync(socket).ino
274
282
  } catch (error) {
275
283
  listener?.close()
276
284
  db?.close()
@@ -294,6 +302,26 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
294
302
  }
295
303
  for (const id of known.keys()) if (!live.has(id)) known.delete(id)
296
304
  }, options.presenceEveryMs ?? PRESENCE_MS)
305
+ const ownership = setInterval(() => {
306
+ if (closing || ownsState()) return
307
+ log(`relay broker: ${socket} or its lock was removed or taken over; stopping`)
308
+ void stop()
309
+ }, options.ownershipEveryMs ?? OWNERSHIP_MS)
310
+ ownership.unref?.()
311
+
312
+ /** whether the socket path is still this broker's listener */
313
+ function ownsSocket(): boolean {
314
+ return lstatSync(socket, { throwIfNoEntry: false })?.ino === socketIno
315
+ }
316
+
317
+ /** whether the socket path and the lock are still this broker's */
318
+ function ownsState(): boolean {
319
+ try {
320
+ return ownsSocket() && readFileSync(lock.path, "utf8") === lock.content
321
+ } catch {
322
+ return false
323
+ }
324
+ }
297
325
  const remoteHooks: BridgeHooks = {
298
326
  wake,
299
327
  fleet: notifyFleet,
@@ -399,6 +427,24 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
399
427
  return core.list({ all: true }).filter((view) => core.machineOf(view.id) === null)
400
428
  }
401
429
 
430
+ /** counts `s` as the waker of `endpoint`, or of none, and reports the presence that moves */
431
+ function setWaker(s: Conn<Connection>, endpoint: string | undefined) {
432
+ const was = s.data.waker
433
+ if (was === endpoint) return
434
+ s.data.waker = endpoint
435
+ if (was) core.waker(was, false, { grace: s.data.transient === true })
436
+ if (endpoint) core.waker(endpoint, true)
437
+ for (const id of [was, endpoint]) if (id) presenceMoved(id)
438
+ }
439
+
440
+ /** reports an endpoint's presence when it is not the one last reported; whether it was not */
441
+ function presenceMoved(id: string): boolean {
442
+ const view = core.whoami(id)
443
+ if ((known.get(id) ?? "offline") === view.presence) return false
444
+ presenceChanged(view)
445
+ return true
446
+ }
447
+
402
448
  /** an endpoint's presence changed outside a connection's own hello or close: watchers and the other machines hear it */
403
449
  function presenceChanged(view: EndpointView) {
404
450
  known.set(view.id, view.presence)
@@ -583,6 +629,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
583
629
  s.data.closed = true
584
630
  clearTimeout(s.data.lineTimer)
585
631
  connections.delete(s)
632
+ if (!closing) setWaker(s, undefined)
586
633
  if (s.data.endpoint && !closing && !s.data.transient && !s.data.lineage) {
587
634
  const before = core.whoami(s.data.endpoint).presence
588
635
  const settled = core.disconnect(s.data.endpoint, { settle: !s.data.brief })
@@ -891,7 +938,8 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
891
938
  case "wake": {
892
939
  if (s.data.lineage && !s.data.own) throw new RelayError("unauthorized", "only the endpoint's own session says how it wakes")
893
940
  const changed = core.setWake(me, params.via, hookOf(s))
894
- if (changed) bridge?.changed(me)
941
+ setWaker(s, params.via !== "none" && core.whoami(me).wake === params.via ? me : undefined)
942
+ if (!presenceMoved(me) && changed) bridge?.changed(me)
895
943
  return { changed }
896
944
  }
897
945
  case "rename": {
@@ -996,13 +1044,23 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
996
1044
  /** a send as `from` to local endpoints or, through the bridge, other machines; wakes the recipients and tells the watchers */
997
1045
  const deliver: Deliver = async (from, send, origin) => {
998
1046
  let result: SendResult
999
- if (remoteSend(core, send.to)) {
1047
+ const remote = remoteSend(core, send.to)
1048
+ if (remote) {
1000
1049
  if (!bridge)
1001
1050
  throw new RelayError("not_found", `no endpoint ${[send.to].flat().join(", ")}: this broker is not linked to a hub (${CLI_NAME} cloud enable)`)
1002
1051
  result = await bridge.forward(from, send, origin)
1003
1052
  } else {
1004
1053
  result = core.send(from, send, origin)
1005
1054
  }
1055
+ if (!result.duplicate && !remote)
1056
+ bridge?.local?.({
1057
+ id: result.id,
1058
+ thread: result.thread,
1059
+ kind: send.kind ?? "message",
1060
+ from,
1061
+ to: result.deliveries.map((d) => d.endpoint),
1062
+ at: (options.now ?? Date.now)(),
1063
+ })
1006
1064
  if (!result.duplicate) {
1007
1065
  for (const delivery of result.deliveries) wake(delivery.endpoint)
1008
1066
  notifyFleet({
@@ -1138,6 +1196,14 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
1138
1196
  if (result.handback) {
1139
1197
  const message = core.delivered(spawner, result.handback)!
1140
1198
  wake(spawner)
1199
+ bridge?.local?.({
1200
+ id: message.id,
1201
+ thread: message.thread,
1202
+ kind: "handback",
1203
+ from: message.from.endpoint,
1204
+ to: [spawner],
1205
+ at: (options.now ?? Date.now)(),
1206
+ })
1141
1207
  notifyFleet({
1142
1208
  type: "message",
1143
1209
  id: message.id,
@@ -1192,6 +1258,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
1192
1258
  closing = true
1193
1259
  clearInterval(sweeper)
1194
1260
  clearInterval(presenceTimer)
1261
+ clearInterval(ownership)
1195
1262
  await bridge?.stop()
1196
1263
  if (idleTimer) clearTimeout(idleTimer)
1197
1264
  for (const set of [...waiters.values()]) for (const fn of [...set]) fn()
@@ -1199,10 +1266,13 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
1199
1266
  while (pending > 0 && Date.now() < deadline) await sleep(5)
1200
1267
  await Promise.race([judge.settled(), sleep(Math.max(0, deadline - Date.now()))])
1201
1268
  for (const s of connections) s.end()
1202
- listener.close()
1203
- try {
1204
- unlinkSync(socket)
1205
- } catch {}
1269
+ // closing a listener unlinks its path, which another broker may own by now
1270
+ if (ownsSocket()) {
1271
+ listener.close()
1272
+ try {
1273
+ unlinkSync(socket)
1274
+ } catch {}
1275
+ } else listener.unref()
1206
1276
  if (socket !== join(dir, "relay.sock")) {
1207
1277
  try {
1208
1278
  rmdirSync(dirname(socket))
package/src/testing.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Database } from "bun:sqlite"
2
2
  import type { Server, ServerWebSocket } from "bun"
3
3
  import { type CrossAccountLink, PASS_GATE, sha256Hex, type Sql, type SqlValue, type XContact } from "@scp-dev/core"
4
- import { type HubGrant, type HubSocket, RelayHub } from "@scp-dev/hub"
4
+ import { type DashboardEvent, type HubGrant, type HubSocket, RelayHub } from "@scp-dev/hub"
5
5
 
6
6
  export interface FakeToken {
7
7
  tokenId: string
@@ -40,6 +40,8 @@ export function startFakeHubServer(options: {
40
40
  tokens: Record<string, FakeToken>
41
41
  now?: (() => number) | undefined
42
42
  contacts?: [string, string][] | undefined
43
+ /** receives every account's dashboard events */
44
+ dashboard?: ((account: string, event: DashboardEvent) => void) | undefined
43
45
  }): FakeHubServer {
44
46
  const accounts = new Map<string, Account>()
45
47
 
@@ -77,6 +79,7 @@ export function startFakeHubServer(options: {
77
79
  sockets: () => [...sockets.values()],
78
80
  save: () => {},
79
81
  ...(options.contacts ? { crossAccount: linkOf(name) } : {}),
82
+ ...(options.dashboard ? { dashboard: (event: DashboardEvent) => options.dashboard!(name, event) } : {}),
80
83
  })
81
84
  hub.init({ user: name })
82
85
  found = { hub, sockets, traps: new Map() }