@weaveprotocol/cli 0.1.0 → 0.1.2

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/weave.mjs +362 -88
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -30,7 +30,7 @@ To have `weave` everywhere:
30
30
  npm install -g @weaveprotocol/cli # or run it without installing: npx @weaveprotocol/cli <command>
31
31
  ```
32
32
 
33
- From this repo: `cd cli && npm install && npm run bundle && npm link`, or a
33
+ From this repo: `npm install` at the repo root (the CLI is a workspace), then `cd cli && npm run bundle && npm link`, or a
34
34
  binary with no Node at all: `bun build.ts --native`.
35
35
 
36
36
  ```bash
package/dist/weave.mjs CHANGED
@@ -6583,6 +6583,19 @@ function createEmitter() {
6583
6583
 
6584
6584
  // ../src/network/signaling.ts
6585
6585
  var SIGNAL_KINDS = /* @__PURE__ */ new Set(["offer", "answer", "candidate"]);
6586
+ function iceFrom(payload) {
6587
+ const { servers, expiresAt } = payload ?? {};
6588
+ if (!Array.isArray(servers) || typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) return null;
6589
+ const kept = [];
6590
+ for (const server of servers.slice(0, 4)) {
6591
+ const urls = (Array.isArray(server?.urls) ? server.urls : [server?.urls]).filter(
6592
+ (url) => typeof url === "string" && url.length < 256 && /^turns?:/.test(url)
6593
+ );
6594
+ if (urls.length === 0 || typeof server.username !== "string" || typeof server.credential !== "string") continue;
6595
+ kept.push({ urls, username: server.username, credential: server.credential });
6596
+ }
6597
+ return kept.length > 0 ? { servers: kept, expiresAt } : null;
6598
+ }
6586
6599
  function createSignalingClient(url, did) {
6587
6600
  let ws = null;
6588
6601
  let connected = false;
@@ -6619,7 +6632,10 @@ function createSignalingClient(url, did) {
6619
6632
  try {
6620
6633
  const msg = JSON.parse(event.data);
6621
6634
  if (SIGNAL_KINDS.has(msg.type)) emit("signal", msg);
6622
- else if (typeof msg.room !== "string") return;
6635
+ else if (msg.type === "ice") {
6636
+ const offered2 = iceFrom(msg.payload);
6637
+ if (offered2) emit("ice", offered2.servers, offered2.expiresAt);
6638
+ } else if (typeof msg.room !== "string") return;
6623
6639
  else if (msg.type === "join") emit("peer-joined", msg.from, msg.room);
6624
6640
  else if (msg.type === "leave") emit("peer-left", msg.from, msg.room);
6625
6641
  } catch (err2) {
@@ -6675,6 +6691,7 @@ function createSignalingClient(url, did) {
6675
6691
  if (rooms.delete(room)) sendMessage({ type: "leave", room });
6676
6692
  },
6677
6693
  signal: (kind, targetDid, payload) => sendMessage({ type: kind, to: targetDid, payload }),
6694
+ requestIce: () => sendMessage({ type: "ice" }),
6678
6695
  on,
6679
6696
  off,
6680
6697
  isConnected: () => connected
@@ -6690,6 +6707,7 @@ function createMultiSignalingClient(urls, did) {
6690
6707
  const { on, off, emit } = createEmitter();
6691
6708
  const routes = /* @__PURE__ */ new Map();
6692
6709
  const presence = /* @__PURE__ */ new Map();
6710
+ const turn = /* @__PURE__ */ new Map();
6693
6711
  const connectedCount = () => clients.filter((client) => client.isConnected()).length;
6694
6712
  const remember = (peer, client) => {
6695
6713
  (routes.get(peer) ?? routes.set(peer, /* @__PURE__ */ new Set()).get(peer)).add(client);
@@ -6718,6 +6736,11 @@ function createMultiSignalingClient(urls, did) {
6718
6736
  remember(message.from, client);
6719
6737
  emit("signal", message);
6720
6738
  });
6739
+ client.on("ice", (servers, expiresAt) => {
6740
+ turn.set(client, { servers, expiresAt });
6741
+ const offers = [...turn.values()];
6742
+ emit("ice", offers.flatMap((offer) => offer.servers), Math.min(...offers.map((offer) => offer.expiresAt)));
6743
+ });
6721
6744
  client.on("connected", () => {
6722
6745
  if (connectedCount() === 1) emit("connected");
6723
6746
  });
@@ -6740,6 +6763,7 @@ function createMultiSignalingClient(urls, did) {
6740
6763
  for (const client of clients) client.disconnect();
6741
6764
  routes.clear();
6742
6765
  presence.clear();
6766
+ turn.clear();
6743
6767
  },
6744
6768
  join: (room) => {
6745
6769
  for (const client of clients) client.join(room);
@@ -6751,6 +6775,9 @@ function createMultiSignalingClient(urls, did) {
6751
6775
  signal: (kind, target, payload) => {
6752
6776
  for (const client of routesFor(target)) client.signal(kind, target, payload);
6753
6777
  },
6778
+ requestIce: () => {
6779
+ for (const client of clients) if (client.isConnected()) client.requestIce();
6780
+ },
6754
6781
  on,
6755
6782
  off,
6756
6783
  isConnected: () => connectedCount() > 0
@@ -6806,7 +6833,8 @@ var DEFAULT_ICE_SERVERS = [
6806
6833
  { urls: "stun:stun1.l.google.com:19302" }
6807
6834
  ];
6808
6835
  function createRTCTransport(config) {
6809
- const iceServers = config?.iceServers ?? DEFAULT_ICE_SERVERS;
6836
+ const configured = config?.iceServers ?? DEFAULT_ICE_SERVERS;
6837
+ const iceServers = () => typeof configured === "function" ? configured() : configured;
6810
6838
  const connections = /* @__PURE__ */ new Map();
6811
6839
  const { on, off, emit } = createEmitter();
6812
6840
  const setupDataChannel = (peerId, channel) => {
@@ -6834,7 +6862,7 @@ function createRTCTransport(config) {
6834
6862
  if (connections.has(peerId)) {
6835
6863
  close(peerId);
6836
6864
  }
6837
- const connection = new RTCPeerConnection({ iceServers: [...iceServers] });
6865
+ const connection = new RTCPeerConnection({ iceServers: [...iceServers()] });
6838
6866
  connections.set(peerId, { connection, channel: null });
6839
6867
  connection.onicecandidate = (event) => {
6840
6868
  if (event.candidate) onCandidate(event.candidate.toJSON());
@@ -7137,10 +7165,20 @@ function createWebSocketTransport(config) {
7137
7165
  }
7138
7166
 
7139
7167
  // ../src/network/mesh.ts
7168
+ var ICE_REFRESH_MS = 10 * 6e4;
7169
+ var ICE_WAIT_MS = 1500;
7140
7170
  function createMesh(config) {
7141
7171
  const { did } = config;
7142
- const transport = config.createTransport?.() ?? createRTCTransport({ iceServers: config.iceServers });
7143
7172
  const signaling = createMultiSignalingClient(config.relays, did);
7173
+ const configuredIce = config.iceServers ?? DEFAULT_ICE_SERVERS;
7174
+ let relayIce = null;
7175
+ let iceAskedAt = 0;
7176
+ signaling.on("ice", (servers, expiresAt) => {
7177
+ relayIce = { servers, expiresAt };
7178
+ iceAskedAt = 0;
7179
+ });
7180
+ const currentIce = () => relayIce && relayIce.expiresAt > Date.now() ? [...configuredIce, ...relayIce.servers] : configuredIce;
7181
+ const transport = config.createTransport?.() ?? createRTCTransport({ iceServers: currentIce });
7144
7182
  const introduce = config.introductions !== false;
7145
7183
  const authTimeoutMs = config.authTimeoutMs ?? 1e4;
7146
7184
  const rooms = /* @__PURE__ */ new Map();
@@ -7341,6 +7379,23 @@ function createMesh(config) {
7341
7379
  return started;
7342
7380
  };
7343
7381
  return {
7382
+ async iceServers() {
7383
+ const stale = !relayIce || relayIce.expiresAt - Date.now() < ICE_REFRESH_MS;
7384
+ if (signaling.isConnected() && stale && Date.now() - iceAskedAt > ICE_REFRESH_MS) {
7385
+ iceAskedAt = Date.now();
7386
+ await new Promise((resolve) => {
7387
+ const done = () => {
7388
+ clearTimeout(timer);
7389
+ signaling.off("ice", done);
7390
+ resolve();
7391
+ };
7392
+ const timer = setTimeout(done, ICE_WAIT_MS);
7393
+ signaling.on("ice", done);
7394
+ signaling.requestIce();
7395
+ });
7396
+ }
7397
+ return currentIce();
7398
+ },
7344
7399
  join(name, auth = null) {
7345
7400
  const room = { auth, peers: /* @__PURE__ */ new Map(), handshakes: /* @__PURE__ */ new Map(), events: createEmitter() };
7346
7401
  const send = (peer, message) => {
@@ -8156,6 +8211,13 @@ var writeCapability = (spaceId) => ({
8156
8211
  can: "expression/write"
8157
8212
  });
8158
8213
  var noteCid = (encoded) => cidFromBytes(utf8Encode(encoded));
8214
+ var NO_ACCOUNT = Object.freeze({ account: null, agent: false });
8215
+ var WHO_MESSAGE = "who";
8216
+ var LIVE_MESSAGE = "live";
8217
+ var MAX_LIVE_BYTES = 64 * 1024;
8218
+ var MAX_NOTE_LENGTH = 16 * 1024;
8219
+ var LIVE_BURST = 60;
8220
+ var LIVE_PER_SECOND = 20;
8159
8221
  var STANDS = { ok: true };
8160
8222
  function looksEncrypted(body) {
8161
8223
  const envelope = body;
@@ -8657,11 +8719,45 @@ async function openSpaceRuntime(deps) {
8657
8719
  }
8658
8720
  }
8659
8721
  const connectedPeers = () => [...new Set(networks.flatMap((network) => network.getPeers().map((peer) => peer.did)))];
8722
+ const peerAccounts = /* @__PURE__ */ new Map();
8723
+ const knownAccounts = /* @__PURE__ */ new Map();
8724
+ const liveAllowance = /* @__PURE__ */ new Map();
8725
+ async function accountOf(peer, note) {
8726
+ if (typeof note !== "string" || note.length > MAX_NOTE_LENGTH) return NO_ACCOUNT;
8727
+ const chain = await resolveDelegationRoot(note, () => null, provider).catch(() => null);
8728
+ if (!chain?.valid || chain.audience !== peer || !chain.rootDid) return NO_ACCOUNT;
8729
+ return { account: chain.rootDid, agent: isAgentNote(note) };
8730
+ }
8731
+ function withinAllowance(peer) {
8732
+ const now = Date.now();
8733
+ const held = liveAllowance.get(peer) ?? { tokens: LIVE_BURST, at: now };
8734
+ const tokens = Math.min(LIVE_BURST, held.tokens + (now - held.at) / 1e3 * LIVE_PER_SECOND);
8735
+ liveAllowance.set(peer, { tokens: Math.max(0, tokens - 1), at: now });
8736
+ return tokens >= 1;
8737
+ }
8738
+ async function onLive(peer, payload) {
8739
+ if (!withinAllowance(peer)) return;
8740
+ if ((JSON.stringify(payload ?? null)?.length ?? 0) > MAX_LIVE_BYTES) return;
8741
+ const { account, agent } = await peerAccounts.get(peer) ?? NO_ACCOUNT;
8742
+ if (!routes.has(peer)) return;
8743
+ emit({ type: "message", space: space2.id, from: account, peer, agent, message: payload });
8744
+ }
8660
8745
  for (const network of networks) {
8661
8746
  network.on("message", (message) => {
8662
8747
  if (message.type === "sync") void sync.handleMessage(message.from, message.payload);
8748
+ else if (message.type === WHO_MESSAGE) {
8749
+ const note = message.payload?.note;
8750
+ const checked = accountOf(message.from, note);
8751
+ peerAccounts.set(message.from, checked);
8752
+ void checked.then((known) => {
8753
+ if (peerAccounts.get(message.from) !== checked) return;
8754
+ knownAccounts.set(message.from, known);
8755
+ emit({ type: "status", space: space2.id });
8756
+ });
8757
+ } else if (message.type === LIVE_MESSAGE) void onLive(message.from, message.payload);
8663
8758
  });
8664
8759
  network.on("peer-connected", (info) => {
8760
+ network.send(info.did, { type: WHO_MESSAGE, from: session.did, payload: { note: session.proof() } });
8665
8761
  routes.set(info.did, network);
8666
8762
  sync.addPeer(info.did);
8667
8763
  sync.notifyPeers([info.did]);
@@ -8671,6 +8767,9 @@ async function openSpaceRuntime(deps) {
8671
8767
  if (routes.get(info.did) === network) {
8672
8768
  routes.delete(info.did);
8673
8769
  sync.removePeer(info.did);
8770
+ peerAccounts.delete(info.did);
8771
+ knownAccounts.delete(info.did);
8772
+ liveAllowance.delete(info.did);
8674
8773
  }
8675
8774
  emit({ type: "status", space: space2.id });
8676
8775
  });
@@ -8681,10 +8780,16 @@ async function openSpaceRuntime(deps) {
8681
8780
  }
8682
8781
  if (networks.length > 0) {
8683
8782
  connection = "connecting";
8684
- void Promise.allSettled(networks.map((network) => network.connect())).then((results) => {
8685
- connection = results.some((result) => result.status === "fulfilled") ? "connected" : "error";
8686
- emit({ type: "status", space: space2.id });
8687
- });
8783
+ void Promise.any(networks.map((network) => network.connect())).then(
8784
+ () => {
8785
+ connection = "connected";
8786
+ emit({ type: "status", space: space2.id });
8787
+ },
8788
+ () => {
8789
+ connection = "error";
8790
+ emit({ type: "status", space: space2.id });
8791
+ }
8792
+ );
8688
8793
  sync.start();
8689
8794
  }
8690
8795
  let watchTimer = null;
@@ -9031,10 +9136,19 @@ async function openSpaceRuntime(deps) {
9031
9136
  space: space2.id,
9032
9137
  connection,
9033
9138
  peers: connectedPeers(),
9139
+ accounts: Object.fromEntries([...knownAccounts].flatMap(([peer, known]) => known.account && routes.has(peer) ? [[peer, known.account]] : [])),
9034
9140
  root: await storage.getRootCid(),
9035
9141
  rejected
9036
9142
  };
9037
9143
  },
9144
+ async send(message, to) {
9145
+ const encoded = JSON.stringify(message ?? null);
9146
+ if (encoded.length > MAX_LIVE_BYTES) throw new Error("A live message can be at most 64 KB");
9147
+ for (const peer of connectedPeers()) {
9148
+ if (to && to !== peer && (await peerAccounts.get(peer))?.account !== to) continue;
9149
+ routes.get(peer)?.send(peer, { type: LIVE_MESSAGE, from: session.did, payload: message ?? null });
9150
+ }
9151
+ },
9038
9152
  async close() {
9039
9153
  if (watchTimer) clearInterval(watchTimer);
9040
9154
  if (announceTimer) clearTimeout(announceTimer);
@@ -9114,6 +9228,7 @@ async function createNode(config) {
9114
9228
  const registryStore = await config.stores("registry", { seal: true });
9115
9229
  const registry = createSpaceManager(registryStore, provider);
9116
9230
  const runtimes = /* @__PURE__ */ new Map();
9231
+ const holds2 = /* @__PURE__ */ new Map();
9117
9232
  const account = config.accountKey ? await deriveAccountRegistry(config.accountKey, config.signer.did, provider) : null;
9118
9233
  const accountSpaceId = account?.space.id ?? null;
9119
9234
  async function findRecord(spaceId) {
@@ -9224,6 +9339,7 @@ async function createNode(config) {
9224
9339
  }
9225
9340
  }
9226
9341
  async function closeRuntime(spaceId) {
9342
+ holds2.delete(spaceId);
9227
9343
  const open2 = runtimes.get(spaceId);
9228
9344
  if (!open2) return;
9229
9345
  runtimes.delete(spaceId);
@@ -9406,10 +9522,28 @@ async function createNode(config) {
9406
9522
  await registry.remove(spaceId);
9407
9523
  emit({ type: "spaces" });
9408
9524
  },
9409
- async open(spaceId) {
9410
- await runtime(spaceId);
9525
+ async hold(spaceId) {
9526
+ const held = holds2.get(spaceId) ?? holds2.set(spaceId, { count: 0 }).get(spaceId);
9527
+ held.count += 1;
9528
+ let released = false;
9529
+ const release = async () => {
9530
+ if (released) return;
9531
+ released = true;
9532
+ if (holds2.get(spaceId) !== held) return;
9533
+ held.count -= 1;
9534
+ if (held.count === 0) await closeRuntime(spaceId);
9535
+ };
9536
+ try {
9537
+ await runtime(spaceId);
9538
+ } catch (error) {
9539
+ await release();
9540
+ throw error;
9541
+ }
9542
+ return release;
9543
+ },
9544
+ async send(spaceId, message, to) {
9545
+ await (await runtime(spaceId)).send(message, to);
9411
9546
  },
9412
- close: closeRuntime,
9413
9547
  async status(spaceId) {
9414
9548
  const status = await (await runtime(spaceId)).status();
9415
9549
  if (!accountSpaceId) return { ...status, own: [], carriers: [] };
@@ -9618,9 +9752,11 @@ async function createNode(config) {
9618
9752
  closeInvite: person("close invites"),
9619
9753
  revoke: person("revoke notes"),
9620
9754
  access: async (spaceId) => (inside(spaceId), spaces.access(spaceId)),
9621
- open: async (spaceId) => (inside(spaceId), spaces.open(spaceId)),
9755
+ hold: async (spaceId) => (inside(spaceId), spaces.hold(spaceId)),
9622
9756
  status: async (spaceId) => (inside(spaceId), spaces.status(spaceId)),
9623
9757
  profiles: async (spaceId) => (inside(spaceId), spaces.profiles(spaceId)),
9758
+ // It would arrive as the person: a live message carries no note of its own to say "via agent".
9759
+ send: person("send live messages"),
9624
9760
  authenticator: async () => null
9625
9761
  });
9626
9762
  const agentRecords = Object.freeze({
@@ -9670,6 +9806,7 @@ async function createNode(config) {
9670
9806
  account: Object.freeze({ profile: accountApi.profile, setName: person("rename the account"), revoke: person("revoke notes") }),
9671
9807
  carriers: Object.freeze({ list: carriers.list, add: person("add a carrier"), remove: person("remove a carrier") }),
9672
9808
  delegation: () => note,
9809
+ iceServers: node.iceServers,
9673
9810
  delegate: person("pass its access on"),
9674
9811
  asAgent: person("start another agent"),
9675
9812
  subscribe: (listener) => node.subscribe((event) => {
@@ -9690,6 +9827,7 @@ async function createNode(config) {
9690
9827
  carriers,
9691
9828
  asAgent,
9692
9829
  delegation: () => current,
9830
+ iceServers: async () => mesh ? mesh.iceServers() : config.network?.iceServers ?? DEFAULT_ICE_SERVERS,
9693
9831
  async delegate(params) {
9694
9832
  const token = await delegateCapabilities(
9695
9833
  {
@@ -10549,6 +10687,201 @@ async function unlock(home, account, secret) {
10549
10687
  // src/serve.ts
10550
10688
  import { createServer } from "node:http";
10551
10689
  import { WebSocketServer } from "ws";
10690
+
10691
+ // ../server/relay.mjs
10692
+ import { createHmac } from "node:crypto";
10693
+ var MAX_MESSAGE_BYTES = 64 * 1024;
10694
+ var MAX_CONNECTIONS = 5e3;
10695
+ var MAX_CONNECTIONS_PER_IP = 32;
10696
+ var MAX_ROOMS = 1e4;
10697
+ var MAX_ROOMS_PER_SOCKET = 256;
10698
+ var MAX_PEERS_PER_ROOM = 64;
10699
+ var MAX_ROOM_LENGTH = 128;
10700
+ var MAX_DID_LENGTH = 256;
10701
+ var RATE_PER_SECOND = 50;
10702
+ var RATE_BURST = 100;
10703
+ var HEARTBEAT_MS = 15e3;
10704
+ var MAX_BUFFERED_BYTES = 1024 * 1024;
10705
+ var ROUTED = /* @__PURE__ */ new Set(["offer", "answer", "candidate"]);
10706
+ var DID_PATTERN = /^did:key:z[1-9A-HJ-NP-Za-km-z]+$/;
10707
+ var CLOSE_DID_TAKEN = 4009;
10708
+ function turnFromEnv(env) {
10709
+ const secret = env.TURN_SECRET ?? "";
10710
+ const urls = (env.TURN_URLS ?? "").split(",").map((url) => url.trim()).filter(Boolean);
10711
+ if (!secret || urls.length === 0) return void 0;
10712
+ return { secret, urls, ttlSeconds: Number(env.TURN_TTL_SECONDS ?? 4 * 3600) };
10713
+ }
10714
+ function createRelay(options = {}) {
10715
+ const { turn } = options;
10716
+ const log = options.log ?? (() => {
10717
+ });
10718
+ const rooms = /* @__PURE__ */ new Map();
10719
+ const clients = /* @__PURE__ */ new Set();
10720
+ const perIp = /* @__PURE__ */ new Map();
10721
+ function canEnter(room) {
10722
+ if (typeof room !== "string" || room.length === 0 || room.length > MAX_ROOM_LENGTH) return false;
10723
+ const peers = rooms.get(room);
10724
+ return peers ? peers.size < MAX_PEERS_PER_ROOM : rooms.size < MAX_ROOMS;
10725
+ }
10726
+ function accept(ws, legacyRoom, ip) {
10727
+ const client = { ws, legacyRoom, ip, did: null, rooms: /* @__PURE__ */ new Set(), alive: true, tokens: RATE_BURST, refilled: Date.now() };
10728
+ clients.add(client);
10729
+ perIp.set(ip, (perIp.get(ip) ?? 0) + 1);
10730
+ ws.on("pong", () => client.alive = true);
10731
+ ws.on("message", (data, isBinary) => {
10732
+ if (!withinBudget(client)) {
10733
+ ws.terminate();
10734
+ return;
10735
+ }
10736
+ if (!isBinary) handleMessage(client, data.toString("utf8"));
10737
+ });
10738
+ let dropped = false;
10739
+ const drop = () => {
10740
+ if (dropped) return;
10741
+ dropped = true;
10742
+ clients.delete(client);
10743
+ const left = (perIp.get(ip) ?? 1) - 1;
10744
+ if (left > 0) perIp.set(ip, left);
10745
+ else perIp.delete(ip);
10746
+ for (const room of [...client.rooms]) leave(client, room);
10747
+ };
10748
+ ws.on("close", drop);
10749
+ ws.on("error", () => ws.terminate());
10750
+ }
10751
+ function handleMessage(client, raw) {
10752
+ let message;
10753
+ try {
10754
+ message = JSON.parse(raw);
10755
+ } catch {
10756
+ return;
10757
+ }
10758
+ if (!message || typeof message.type !== "string") return;
10759
+ const room = message.room ?? client.legacyRoom;
10760
+ if (message.type === "join") {
10761
+ if (client.did ? message.from !== client.did : !isDid(message.from)) return;
10762
+ if (client.rooms.has(room) || client.rooms.size >= MAX_ROOMS_PER_SOCKET || !canEnter(room)) return;
10763
+ if (findPeer(room, message.from)) {
10764
+ client.ws.close(CLOSE_DID_TAKEN, "DID already in room");
10765
+ return;
10766
+ }
10767
+ const first = !client.did;
10768
+ client.did = message.from;
10769
+ client.rooms.add(room);
10770
+ if (!rooms.has(room)) rooms.set(room, /* @__PURE__ */ new Set());
10771
+ rooms.get(room).add(client);
10772
+ announce(client, room, "join");
10773
+ if (first) offerTurn(client);
10774
+ log(`peer joined room ${short(room)} (${rooms.get(room).size} present)`);
10775
+ return;
10776
+ }
10777
+ if (message.type === "ice") {
10778
+ if (client.did) offerTurn(client);
10779
+ return;
10780
+ }
10781
+ if (message.type === "leave") {
10782
+ if (client.rooms.has(room)) leave(client, room);
10783
+ return;
10784
+ }
10785
+ if (!client.did || !ROUTED.has(message.type) || typeof message.to !== "string") return;
10786
+ for (const shared of client.rooms) {
10787
+ const target = findPeer(shared, message.to);
10788
+ if (target && target !== client) {
10789
+ send(target, JSON.stringify({ type: message.type, from: client.did, to: message.to, payload: message.payload }));
10790
+ return;
10791
+ }
10792
+ }
10793
+ }
10794
+ function offerTurn(client) {
10795
+ if (!turn) return;
10796
+ const expires = Math.floor(Date.now() / 1e3) + turn.ttlSeconds;
10797
+ const username = String(expires);
10798
+ const credential = createHmac("sha1", turn.secret).update(username).digest("base64");
10799
+ send(client, JSON.stringify({ type: "ice", payload: { servers: [{ urls: turn.urls, username, credential }], expiresAt: expires * 1e3 } }));
10800
+ }
10801
+ function leave(client, room) {
10802
+ client.rooms.delete(room);
10803
+ const peers = rooms.get(room);
10804
+ peers?.delete(client);
10805
+ if (peers?.size === 0) rooms.delete(room);
10806
+ announce(client, room, "leave");
10807
+ log(`peer left room ${short(room)}`);
10808
+ }
10809
+ function announce(sender, room, type) {
10810
+ const payload = JSON.stringify({ type, from: sender.did, room });
10811
+ for (const peer of rooms.get(room) ?? []) {
10812
+ if (peer !== sender) send(peer, payload);
10813
+ }
10814
+ }
10815
+ function send(peer, text) {
10816
+ if (peer.ws.readyState !== peer.ws.OPEN) return;
10817
+ if (peer.ws.bufferedAmount > MAX_BUFFERED_BYTES) {
10818
+ peer.ws.terminate();
10819
+ return;
10820
+ }
10821
+ peer.ws.send(text);
10822
+ }
10823
+ function findPeer(room, did) {
10824
+ for (const peer of rooms.get(room) ?? []) {
10825
+ if (peer.did === did) return peer;
10826
+ }
10827
+ return null;
10828
+ }
10829
+ function withinBudget(client) {
10830
+ const now = Date.now();
10831
+ client.tokens = Math.min(RATE_BURST, client.tokens + (now - client.refilled) / 1e3 * RATE_PER_SECOND);
10832
+ client.refilled = now;
10833
+ if (client.tokens < 1) return false;
10834
+ client.tokens -= 1;
10835
+ return true;
10836
+ }
10837
+ const heartbeat = setInterval(() => {
10838
+ for (const client of clients) {
10839
+ if (!client.alive) {
10840
+ client.ws.terminate();
10841
+ continue;
10842
+ }
10843
+ client.alive = false;
10844
+ client.ws.ping();
10845
+ }
10846
+ }, HEARTBEAT_MS);
10847
+ heartbeat.unref();
10848
+ return {
10849
+ /**
10850
+ * Takes an HTTP upgrade request. Everything that can be refused is refused
10851
+ * here, before a socket is opened.
10852
+ *
10853
+ * @param {import('ws').WebSocketServer} wss A `ws` server made with `noServer: true` and `maxPayload: MAX_MESSAGE_BYTES`
10854
+ */
10855
+ upgrade(wss, req, socket, head) {
10856
+ socket.on("error", () => socket.destroy());
10857
+ const legacyRoom = new URL(req.url ?? "/", "http://relay").searchParams.get("room");
10858
+ const ip = clientIp(req);
10859
+ const refusal2 = legacyRoom !== null && !canEnter(legacyRoom) ? [503, "Room full"] : clients.size >= MAX_CONNECTIONS ? [503, "Relay full"] : (perIp.get(ip) ?? 0) >= MAX_CONNECTIONS_PER_IP ? [429, "Too many connections"] : null;
10860
+ if (refusal2) {
10861
+ socket.end(`HTTP/1.1 ${refusal2[0]} ${refusal2[1]}\r
10862
+ Connection: close\r
10863
+ Content-Length: 0\r
10864
+ \r
10865
+ `);
10866
+ return;
10867
+ }
10868
+ wss.handleUpgrade(req, socket, head, (ws) => accept(ws, legacyRoom, ip));
10869
+ },
10870
+ /** Drops every socket and stops the heartbeat */
10871
+ close() {
10872
+ clearInterval(heartbeat);
10873
+ for (const client of clients) client.ws.terminate();
10874
+ }
10875
+ };
10876
+ }
10877
+ var isDid = (value) => typeof value === "string" && value.length <= MAX_DID_LENGTH && DID_PATTERN.test(value);
10878
+ function clientIp(req) {
10879
+ const forwarded = process.env.FLY_APP_NAME ? req.headers["fly-client-ip"] : void 0;
10880
+ return typeof forwarded === "string" && forwarded || req.socket.remoteAddress || "unknown";
10881
+ }
10882
+ var short = (room) => room.length > 12 ? `${room.slice(0, 12)}\u2026` : room;
10883
+
10884
+ // src/serve.ts
10552
10885
  function createSpacePeers() {
10553
10886
  const sockets = /* @__PURE__ */ new Map();
10554
10887
  const listeners = {};
@@ -10613,84 +10946,21 @@ function createInboundPeers() {
10613
10946
  count: () => spaces.size
10614
10947
  };
10615
10948
  }
10616
- var RELAY_MAX_ROOM_LENGTH = 128;
10617
- var RELAY_MAX_PEERS_PER_ROOM = 64;
10618
- var RELAY_MESSAGES_PER_SECOND = 50;
10619
- var RELAY_BURST = 100;
10620
- var DID_PATTERN = /^did:key:z[1-9A-HJ-NP-Za-km-z]{1,250}$/;
10621
- function createRelay() {
10622
- const rooms = /* @__PURE__ */ new Map();
10623
- const broadcast = (sender, payload) => {
10624
- for (const peer of rooms.get(sender.room) ?? []) {
10625
- if (peer !== sender && peer.did && peer.socket.readyState === peer.socket.OPEN) peer.socket.send(payload);
10626
- }
10627
- };
10628
- return {
10629
- rooms,
10630
- accept(socket, room) {
10631
- if (room.length > RELAY_MAX_ROOM_LENGTH || (rooms.get(room)?.size ?? 0) >= RELAY_MAX_PEERS_PER_ROOM) {
10632
- socket.close(1008, "room refused");
10633
- return;
10634
- }
10635
- const client = { socket, room, did: null };
10636
- if (!rooms.has(room)) rooms.set(room, /* @__PURE__ */ new Set());
10637
- rooms.get(room).add(client);
10638
- let tokens = RELAY_BURST;
10639
- let refilled = Date.now();
10640
- socket.on("message", (data, isBinary) => {
10641
- const now = Date.now();
10642
- tokens = Math.min(RELAY_BURST, tokens + (now - refilled) / 1e3 * RELAY_MESSAGES_PER_SECOND);
10643
- refilled = now;
10644
- if ((tokens -= 1) < 0) {
10645
- socket.close(1008, "too many messages");
10646
- return;
10647
- }
10648
- if (isBinary) return;
10649
- let message;
10650
- try {
10651
- message = JSON.parse(String(data));
10652
- } catch {
10653
- return;
10654
- }
10655
- if (typeof message?.type !== "string") return;
10656
- if (message.type === "join") {
10657
- if (client.did || typeof message.from !== "string" || !DID_PATTERN.test(message.from)) return;
10658
- if ([...rooms.get(room)].some((peer) => peer.did === message.from)) {
10659
- socket.close(4009, "that DID is already in this room");
10660
- return;
10661
- }
10662
- client.did = message.from;
10663
- broadcast(client, JSON.stringify({ type: "join", from: client.did }));
10664
- return;
10665
- }
10666
- if (!client.did || !["offer", "answer", "candidate"].includes(message.type) || typeof message.to !== "string") return;
10667
- const target = [...rooms.get(room) ?? []].find((peer) => peer.did === message.to);
10668
- const forwarded = { type: message.type, from: client.did, to: message.to, payload: message.payload };
10669
- if (target && target.socket.readyState === target.socket.OPEN) target.socket.send(JSON.stringify(forwarded));
10670
- });
10671
- socket.on("close", () => {
10672
- const peers = rooms.get(room);
10673
- if (!peers?.delete(client)) return;
10674
- if (peers.size === 0) rooms.delete(room);
10675
- if (client.did) broadcast(client, JSON.stringify({ type: "leave", from: client.did }));
10676
- });
10677
- }
10678
- };
10679
- }
10949
+ var DID_PATTERN2 = /^did:key:z[1-9A-HJ-NP-Za-km-z]{1,250}$/;
10680
10950
  function parseHello(data, isBinary) {
10681
10951
  if (isBinary) return null;
10682
10952
  try {
10683
10953
  const hello = JSON.parse(String(data));
10684
- return hello.type === "hello" && typeof hello.did === "string" && DID_PATTERN.test(hello.did) && typeof hello.nonce === "string" ? { did: hello.did, nonce: hello.nonce, proof: { sig: hello.sig, read: hello.read } } : null;
10954
+ return hello.type === "hello" && typeof hello.did === "string" && DID_PATTERN2.test(hello.did) && typeof hello.nonce === "string" ? { did: hello.did, nonce: hello.nonce, proof: { sig: hello.sig, read: hello.read } } : null;
10685
10955
  } catch {
10686
10956
  return null;
10687
10957
  }
10688
10958
  }
10689
10959
  async function serve(options) {
10690
10960
  const { node, inbound } = options;
10691
- const relay = createRelay();
10961
+ const relay = createRelay({ turn: turnFromEnv(process.env) });
10692
10962
  const sockets = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 * 1024 });
10693
- const relaySockets = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
10963
+ const relaySockets = new WebSocketServer({ noServer: true, maxPayload: MAX_MESSAGE_BYTES, perMessageDeflate: false, clientTracking: false });
10694
10964
  const http = createServer((req, res) => {
10695
10965
  if (req.url === "/health") {
10696
10966
  res.writeHead(200, { "content-type": "application/json" });
@@ -10700,6 +10970,7 @@ async function serve(options) {
10700
10970
  res.writeHead(426, { "content-type": "text/plain" });
10701
10971
  res.end("This endpoint speaks WebSocket only.\n");
10702
10972
  });
10973
+ const served = /* @__PURE__ */ new Set();
10703
10974
  const onPeer = async (socket, url) => {
10704
10975
  const spaceId = url.searchParams.get("space");
10705
10976
  if (!spaceId) {
@@ -10721,7 +10992,10 @@ async function serve(options) {
10721
10992
  socket.close(4003, "not a reader of this space");
10722
10993
  return;
10723
10994
  }
10724
- await node.spaces.open(spaceId);
10995
+ if (!served.has(spaceId)) {
10996
+ await node.spaces.hold(spaceId);
10997
+ served.add(spaceId);
10998
+ }
10725
10999
  const sig = await authenticator.welcome(node.sessionDid, hello.nonce);
10726
11000
  socket.send(JSON.stringify({ type: "welcome", did: node.sessionDid, sig }));
10727
11001
  inbound.accept(spaceId, socket, hello.did);
@@ -10734,7 +11008,7 @@ async function serve(options) {
10734
11008
  if (url.pathname === "/peer") {
10735
11009
  sockets.handleUpgrade(req, socket, head, (ws) => void onPeer(ws, url).catch(() => ws.close(1011, "internal error")));
10736
11010
  } else {
10737
- relaySockets.handleUpgrade(req, socket, head, (ws) => relay.accept(ws, url.searchParams.get("room") ?? "default"));
11011
+ relay.upgrade(relaySockets, req, socket, head);
10738
11012
  }
10739
11013
  });
10740
11014
  await new Promise((resolve, reject) => {
@@ -10746,7 +11020,8 @@ async function serve(options) {
10746
11020
  return {
10747
11021
  port,
10748
11022
  async close() {
10749
- for (const client of [...sockets.clients, ...relaySockets.clients]) client.terminate();
11023
+ for (const client of sockets.clients) client.terminate();
11024
+ relay.close();
10750
11025
  sockets.close();
10751
11026
  relaySockets.close();
10752
11027
  await new Promise((resolve) => http.close(() => resolve()));
@@ -10771,18 +11046,17 @@ async function startDaemon(options) {
10771
11046
  ...options.nodes?.length ? { nodes: options.nodes } : {}
10772
11047
  }
10773
11048
  });
10774
- const open2 = /* @__PURE__ */ new Set();
11049
+ const open2 = /* @__PURE__ */ new Map();
10775
11050
  const rescan = async () => {
10776
11051
  const held = new Set((await node.spaces.list()).map((space2) => space2.id));
10777
11052
  for (const id of held) {
10778
11053
  if (open2.has(id)) continue;
10779
- await node.spaces.open(id);
10780
- open2.add(id);
11054
+ open2.set(id, await node.spaces.hold(id));
10781
11055
  log(`serving space ${id}`);
10782
11056
  }
10783
- for (const id of [...open2]) {
11057
+ for (const [id, release] of [...open2]) {
10784
11058
  if (held.has(id)) continue;
10785
- await node.spaces.close(id);
11059
+ await release();
10786
11060
  open2.delete(id);
10787
11061
  log(`stopped serving space ${id}`);
10788
11062
  }
@@ -11145,7 +11419,7 @@ async function startAgentNode(home, options = {}) {
11145
11419
  for (const space2 of grant.spaces) if (!held.has(space2.id)) await base.spaces.join(space2.invite).catch(() => {
11146
11420
  });
11147
11421
  const node = await base.asAgent({ keys: key2.keys, note: grant.token });
11148
- for (const space2 of await node.spaces.list()) void node.spaces.open(space2.id).catch(() => {
11422
+ for (const space2 of await node.spaces.list()) void node.spaces.hold(space2.id).catch(() => {
11149
11423
  });
11150
11424
  return {
11151
11425
  node,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weaveprotocol/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "The Weave protocol from a terminal: manage spaces, run an always-on node, serve MCP to agents",
5
5
  "type": "module",
6
6
  "bin": {