@scp-dev/broker 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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>;
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;
@@ -887,21 +894,21 @@ function startBridge(options) {
887
894
  if (event.machine === machine) return;
888
895
  if (event.type === "message" && Array.isArray(event.to) && account && event.to.some((to) => typeof to === "string" && to.startsWith(`${account}/${machine}/`)))
889
896
  return;
890
- const local = {
897
+ const local2 = {
891
898
  ...event,
892
899
  ...event.from !== void 0 ? { from: localAddress(event.from) } : {},
893
900
  ...event.endpoint !== void 0 ? { endpoint: localAddress(event.endpoint) } : {}
894
901
  };
895
- if (Array.isArray(event.to)) local.to = event.to.map(localAddress);
902
+ if (Array.isArray(event.to)) local2.to = event.to.map(localAddress);
896
903
  if (event.type === "activity" && typeof event.machine === "string" && typeof event.endpoint === "string") {
897
904
  try {
898
905
  const proxy = core.remoteEndpoint(event.machine, event.endpoint.slice(event.endpoint.lastIndexOf("/") + 1));
899
906
  const row = core.event(proxy, { kind: event.kind, tool: event.tool, summary: event.summary });
900
- local.id = row.id;
907
+ local2.id = row.id;
901
908
  } catch {
902
909
  }
903
910
  }
904
- hooks.fleet(local);
911
+ hooks.fleet(local2);
905
912
  }
906
913
  function linkUp(ms) {
907
914
  if (connected) return Promise.resolve(true);
@@ -1017,6 +1024,30 @@ function startBridge(options) {
1017
1024
  if (handle === null) return;
1018
1025
  fireAndForget("event", { from: handle, kind: row.kind, tool: row.tool ?? void 0, summary: options.redact ? void 0 : row.summary ?? void 0 });
1019
1026
  }
1027
+ function local(event) {
1028
+ if (!mirrorLocal || !ready || localQueue.length >= LOCAL_BUFFER) return;
1029
+ let from;
1030
+ let to;
1031
+ try {
1032
+ from = handleOf(event.from);
1033
+ to = event.to.flatMap((endpoint) => {
1034
+ const handle = handleOf(endpoint);
1035
+ return handle === null ? [] : [handle];
1036
+ });
1037
+ } catch {
1038
+ return;
1039
+ }
1040
+ if (from === null || !to.length) return;
1041
+ localQueue.push({ from, to: to.slice(0, LOCAL_EVENTS.recipients), kind: event.kind, at: event.at, id: idHash(event.id), thread: idHash(event.thread) });
1042
+ localTimer ??= setTimeout(flushLocal, localFlushMs);
1043
+ }
1044
+ function flushLocal() {
1045
+ localTimer = void 0;
1046
+ const batch = localQueue;
1047
+ localQueue = [];
1048
+ if (!ready) return;
1049
+ for (let i = 0; i < batch.length; i += LOCAL_EVENTS.perFrame) fireAndForget("local_events", { events: batch.slice(i, i + LOCAL_EVENTS.perFrame) });
1050
+ }
1020
1051
  async function contacts() {
1021
1052
  if (!connected) throw unavailable();
1022
1053
  return (await request("list", { contacts: true })).contacts;
@@ -1038,6 +1069,8 @@ function startBridge(options) {
1038
1069
  stopped = true;
1039
1070
  shutDown = true;
1040
1071
  clearTimeout(retryTimer);
1072
+ clearTimeout(localTimer);
1073
+ localQueue = [];
1041
1074
  for (const fn of waitingForLink.splice(0)) fn();
1042
1075
  const socket = ws;
1043
1076
  ws = null;
@@ -1048,11 +1081,11 @@ function startBridge(options) {
1048
1081
  if (socket && socket.readyState <= 1) socket.close(1e3, "the broker stopped");
1049
1082
  }
1050
1083
  connect();
1051
- return { forward, changed, resync, renamed, activity, contacts, status, relink, stop };
1084
+ return { forward, changed, resync, renamed, activity, local, contacts, status, relink, stop };
1052
1085
  }
1053
1086
 
1054
1087
  // src/server.ts
1055
- import { createHash as createHash3 } from "crypto";
1088
+ import { createHash as createHash4 } from "crypto";
1056
1089
  import { chmodSync as chmodSync2, existsSync as existsSync2, lstatSync as lstatSync2, readFileSync as readFileSync6, realpathSync, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
1057
1090
  import { createServer } from "net";
1058
1091
  import { homedir as homedir2 } from "os";
@@ -1089,11 +1122,11 @@ var JudgeGate = class extends HubJudgeGate {
1089
1122
  };
1090
1123
 
1091
1124
  // src/sessions.ts
1092
- import { createHash as createHash2, randomUUID } from "crypto";
1125
+ import { createHash as createHash3, randomUUID } from "crypto";
1093
1126
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync5, renameSync as renameSync2, rmSync, writeFileSync } from "fs";
1094
1127
  import { join as join3 } from "path";
1095
1128
  function markerPath(dir, session) {
1096
- return join3(dir, "sessions", `${createHash2("sha256").update(session).digest("hex").slice(0, 32)}.json`);
1129
+ return join3(dir, "sessions", `${createHash3("sha256").update(session).digest("hex").slice(0, 32)}.json`);
1097
1130
  }
1098
1131
  function readSessionMarker(dir, session) {
1099
1132
  try {
@@ -1282,7 +1315,7 @@ async function startBroker(options) {
1282
1315
  core = new FederatedCore(sql, {
1283
1316
  ...compact({ user: options.user, machine, now: options.now }),
1284
1317
  hostname: shortHostname(),
1285
- hash: (value) => createHash3("sha256").update(value).digest("hex"),
1318
+ hash: (value) => createHash4("sha256").update(value).digest("hex"),
1286
1319
  transaction: (fn) => db.transaction(fn),
1287
1320
  alive: running
1288
1321
  });
@@ -1981,13 +2014,23 @@ async function startBroker(options) {
1981
2014
  }
1982
2015
  const deliver = async (from, send, origin) => {
1983
2016
  let result;
1984
- if (remoteSend(core, send.to)) {
2017
+ const remote = remoteSend(core, send.to);
2018
+ if (remote) {
1985
2019
  if (!bridge)
1986
2020
  throw new RelayError3("not_found", `no endpoint ${[send.to].flat().join(", ")}: this broker is not linked to a hub (${CLI_NAME} cloud enable)`);
1987
2021
  result = await bridge.forward(from, send, origin);
1988
2022
  } else {
1989
2023
  result = core.send(from, send, origin);
1990
2024
  }
2025
+ if (!result.duplicate && !remote)
2026
+ bridge?.local?.({
2027
+ id: result.id,
2028
+ thread: result.thread,
2029
+ kind: send.kind ?? "message",
2030
+ from,
2031
+ to: result.deliveries.map((d) => d.endpoint),
2032
+ at: (options.now ?? Date.now)()
2033
+ });
1991
2034
  if (!result.duplicate) {
1992
2035
  for (const delivery of result.deliveries) wake(delivery.endpoint);
1993
2036
  notifyFleet({
@@ -2113,6 +2156,14 @@ async function startBroker(options) {
2113
2156
  if (result.handback) {
2114
2157
  const message = core.delivered(spawner, result.handback);
2115
2158
  wake(spawner);
2159
+ bridge?.local?.({
2160
+ id: message.id,
2161
+ thread: message.thread,
2162
+ kind: "handback",
2163
+ from: message.from.endpoint,
2164
+ to: [spawner],
2165
+ at: (options.now ?? Date.now)()
2166
+ });
2116
2167
  notifyFleet({
2117
2168
  type: "message",
2118
2169
  id: message.id,
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.5",
3
+ "version": "0.2.0",
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.3.1"
40
+ "@scp-dev/core": "0.1.5",
41
+ "@scp-dev/hub": "0.5.0"
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()
@@ -636,6 +662,32 @@ export function startBridge(options: BridgeOptions): Bridge {
636
662
  fireAndForget("event", { from: handle, kind: row.kind, tool: row.tool ?? undefined, summary: options.redact ? undefined : (row.summary ?? undefined) })
637
663
  }
638
664
 
665
+ function local(event: LocalDelivery) {
666
+ if (!mirrorLocal || !ready || localQueue.length >= LOCAL_BUFFER) return
667
+ let from: string | null
668
+ let to: string[]
669
+ try {
670
+ from = handleOf(event.from)
671
+ to = event.to.flatMap((endpoint) => {
672
+ const handle = handleOf(endpoint)
673
+ return handle === null ? [] : [handle]
674
+ })
675
+ } catch {
676
+ return
677
+ }
678
+ if (from === null || !to.length) return
679
+ localQueue.push({ from, to: to.slice(0, LOCAL_EVENTS.recipients), kind: event.kind, at: event.at, id: idHash(event.id), thread: idHash(event.thread) })
680
+ localTimer ??= setTimeout(flushLocal, localFlushMs)
681
+ }
682
+
683
+ function flushLocal() {
684
+ localTimer = undefined
685
+ const batch = localQueue
686
+ localQueue = []
687
+ if (!ready) return
688
+ for (let i = 0; i < batch.length; i += LOCAL_EVENTS.perFrame) fireAndForget("local_events", { events: batch.slice(i, i + LOCAL_EVENTS.perFrame) })
689
+ }
690
+
639
691
  async function contacts(): Promise<XListed[]> {
640
692
  if (!connected) throw unavailable()
641
693
  return (await request<{ contacts: XListed[] }>("list", { contacts: true })).contacts
@@ -660,6 +712,8 @@ export function startBridge(options: BridgeOptions): Bridge {
660
712
  stopped = true
661
713
  shutDown = true
662
714
  clearTimeout(retryTimer)
715
+ clearTimeout(localTimer)
716
+ localQueue = []
663
717
  for (const fn of waitingForLink.splice(0)) fn()
664
718
  const socket = ws
665
719
  ws = null
@@ -671,5 +725,5 @@ export function startBridge(options: BridgeOptions): Bridge {
671
725
  }
672
726
 
673
727
  connect()
674
- return { forward, changed, resync, renamed, activity, contacts, status, relink, stop }
728
+ return { forward, changed, resync, renamed, activity, local, contacts, status, relink, stop }
675
729
  }
package/src/server.ts CHANGED
@@ -996,13 +996,23 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
996
996
  /** a send as `from` to local endpoints or, through the bridge, other machines; wakes the recipients and tells the watchers */
997
997
  const deliver: Deliver = async (from, send, origin) => {
998
998
  let result: SendResult
999
- if (remoteSend(core, send.to)) {
999
+ const remote = remoteSend(core, send.to)
1000
+ if (remote) {
1000
1001
  if (!bridge)
1001
1002
  throw new RelayError("not_found", `no endpoint ${[send.to].flat().join(", ")}: this broker is not linked to a hub (${CLI_NAME} cloud enable)`)
1002
1003
  result = await bridge.forward(from, send, origin)
1003
1004
  } else {
1004
1005
  result = core.send(from, send, origin)
1005
1006
  }
1007
+ if (!result.duplicate && !remote)
1008
+ bridge?.local?.({
1009
+ id: result.id,
1010
+ thread: result.thread,
1011
+ kind: send.kind ?? "message",
1012
+ from,
1013
+ to: result.deliveries.map((d) => d.endpoint),
1014
+ at: (options.now ?? Date.now)(),
1015
+ })
1006
1016
  if (!result.duplicate) {
1007
1017
  for (const delivery of result.deliveries) wake(delivery.endpoint)
1008
1018
  notifyFleet({
@@ -1138,6 +1148,14 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
1138
1148
  if (result.handback) {
1139
1149
  const message = core.delivered(spawner, result.handback)!
1140
1150
  wake(spawner)
1151
+ bridge?.local?.({
1152
+ id: message.id,
1153
+ thread: message.thread,
1154
+ kind: "handback",
1155
+ from: message.from.endpoint,
1156
+ to: [spawner],
1157
+ at: (options.now ?? Date.now)(),
1158
+ })
1141
1159
  notifyFleet({
1142
1160
  type: "message",
1143
1161
  id: message.id,
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() }