@substrate-system/mergeparty
Version: 
Automerge + Partykit
294 lines (293 loc) • 9.51 kB
JavaScript
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
var __export = (target, all) => {
  for (var name in all)
    __defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
  if (from && typeof from === "object" || typeof from === "function") {
    for (let key of __getOwnPropNames(from))
      if (!__hasOwnProp.call(to, key) && key !== except)
        __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
  }
  return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
  // If the importer is in node compatibility mode or this is not an ESM
  // file that has been converted to a CommonJS file using a Babel-
  // compatible transform (i.e. "__esModule" has not been set), then set
  // "default" to the CommonJS "module.exports" for node compatibility.
  isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
  mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var relay_exports = {};
__export(relay_exports, {
  Relay: () => Relay
});
module.exports = __toCommonJS(relay_exports);
var import_cborg = require("cborg");
var import_eventemitter3 = require("eventemitter3");
var import_cloudflare = __toESM(require("@substrate-system/debug/cloudflare"), 1);
var import_index = require("./index.js");
var import_messages = require("@substrate-system/automerge-repo-network-websocket/messages");
var import_protocolVersion = require("@substrate-system/automerge-repo-network-websocket/protocolVersion");
var import_util = require("../util.js");
const debug = (0, import_cloudflare.default)("mergeparty:relay");
class Relay extends import_eventemitter3.EventEmitter {
  static {
    __name(this, "Relay");
  }
  // eslint-disable-line brace-style
  room;
  serverPeerId;
  isStorageServer = false;
  peerId;
  // our peer ID
  peerMetadata;
  // our peer metadata
  sockets = {};
  _log;
  _baseLog;
  // Connection -> meta { peerId?:string, joined:boolean }
  byConn = /* @__PURE__ */ new Map();
  constructor(room) {
    super();
    this.room = room;
    this.serverPeerId = `server:${room.id}`;
    this._baseLog = (0, import_cloudflare.default)("mergeparty");
    this._log = this._baseLog.extend("relay");
  }
  listenerCount(_event) {
    return 0;
  }
  eventNames() {
    const eventKeys = [
      "close",
      "peer-candidate",
      "peer-disconnected",
      "message"
    ];
    return eventKeys;
  }
  // --- NetworkAdapterInterface required methods ---
  isReady() {
    return true;
  }
  /**
   * Called by the Repo to start things.
   * @param {PeerId} peerId The peerId of *this repo*.
   * @param {PeerMetadata} meta How this adapter should present itselft
   *        to other peers.
   */
  connect(peerId, meta) {
    this.peerId = peerId;
    this.peerMetadata = meta;
  }
  disconnect() {
  }
  send(message) {
    if ("data" in message && message.data?.byteLength === 0) {
      throw new Error("Tried to send a zero-length message");
    }
    const to = this.sockets[message.targetId];
    if (!to) {
      this._log(`Tried to send to disconnected peer: ${message.targetId}`);
      return;
    }
    const encoded = (0, import_cborg.encode)(message);
    to.send((0, import_util.toArrayBuffer)(encoded));
  }
  open() {
  }
  close() {
  }
  subscribe() {
  }
  unsubscribe() {
  }
  get networkId() {
    return this.room.id;
  }
  // Abstract method from NetworkAdapter
  whenReady() {
    return Promise.resolve();
  }
  // ---- WebSocket lifecycle ----
  onConnect(conn) {
    this.byConn.set(conn, { joined: false });
  }
  onClose(conn) {
    const meta = this.byConn.get(conn);
    if (meta?.peerId) {
      delete this.sockets[meta.peerId];
    }
    this.byConn.delete(conn);
    this.emit("peer-disconnected", {
      peerId: this.byConn.get(conn)?.peerId
    });
  }
  cborEncode(data) {
    return (0, import_cborg.encode)(data);
  }
  cborDecode(raw) {
    return (0, import_cborg.decode)((0, import_util.toU8)(raw));
  }
  /**
   * Decode CBOR messages.
   * This handles 'join' + handshake process.
   * Emits `peer-candidate` and `message` events.
   *
   * @fires peer-candidate
   * @fires message
   */
  async onMessage(raw, conn) {
    debug("[Relay] Received message from client");
    if (typeof raw === "string") {
      this.sendErrorAndClose(
        conn,
        "Expected binary CBOR frame, got string"
      );
      return;
    }
    let message;
    try {
      message = (0, import_cborg.decode)((0, import_util.toU8)(raw));
    } catch (_err) {
      const err = _err;
      console.error(err.message);
      conn.close();
      return;
    }
    const meta = this.byConn.get(conn) ?? { joined: false };
    if (!meta.joined) {
      if (!(0, import_messages.isJoinMessage)(message)) {
        this.emit("message", message);
        return;
      }
      const join = message;
      const versions = join.supportedProtocolVersions ?? ["1"];
      if (!versions.includes(import_index.SUPPORTED_PROTOCOL_VERSION)) {
        return this.sendErrorAndClose(
          conn,
          `Unsupported protocol version. Server supports ${import_index.SUPPORTED_PROTOCOL_VERSION}`
        );
      }
      if (!join.senderId || typeof join.senderId !== "string") {
        this.sendErrorAndClose(conn, "`senderId` missing or invalid");
        return;
      }
      const { senderId, peerMetadata, supportedProtocolVersions } = join;
      this.emit("peer-candidate", {
        peerId: senderId,
        peerMetadata
      });
      this.sockets[senderId] = conn;
      this.sockets[join.senderId] = conn;
      this.byConn.set(conn, { joined: true, peerId: join.senderId });
      const selectedProtocolVersion = selectProtocol(supportedProtocolVersions);
      if (selectedProtocolVersion === null) {
        this.send({
          type: "error",
          senderId: this.peerId,
          message: "unsupported protocol version",
          targetId: senderId
        });
        this.sockets[senderId].close();
        delete this.sockets[senderId];
      } else {
        this.send({
          type: "peer",
          senderId: this.peerId,
          peerMetadata: this.peerMetadata,
          selectedProtocolVersion: import_protocolVersion.ProtocolV1,
          targetId: senderId
        });
      }
      for (const existingId of Object.keys(this.sockets)) {
        if (existingId === join.senderId) continue;
        this.announce(existingId, join.senderId);
      }
      for (const existingId of Object.keys(this.sockets)) {
        if (existingId === join.senderId) continue;
        this.announce(join.senderId, existingId);
      }
      if (this.isStorageServer) {
        (0, import_util.assert)(this.peerId);
        this.announce(this.peerId, join.senderId);
      }
      return;
    }
    const msg = message;
    const t = msg.targetId;
    const isAliasToServer = typeof t === "string" && t.startsWith("server:");
    const deliverToLocal = t === this.peerId || isAliasToServer;
    if (deliverToLocal) {
      const localMsg = isAliasToServer ? { ...msg, targetId: this.peerId } : msg;
      this.emit("message", localMsg);
    }
    if (t) {
      const target = this.sockets[t];
      if (target) {
        target.send(raw);
        return;
      }
    }
    if (isAliasToServer) {
      for (const [peerId, conn2] of Object.entries(this.sockets)) {
        if (peerId === msg.senderId) continue;
        conn2.send((0, import_util.toArrayBuffer)((0, import_cborg.encode)({
          ...msg,
          targetId: peerId
        })));
      }
    }
  }
  announce(announcedPeerId, toClientId) {
    const msg = {
      type: "peer",
      senderId: announcedPeerId,
      // the peer being announced
      targetId: toClientId,
      // the client who should learn about it
      selectedProtocolVersion: import_index.SUPPORTED_PROTOCOL_VERSION,
      peerMetadata: {}
    };
    const toConn = this.sockets[toClientId];
    if (toConn) {
      toConn.send((0, import_util.toArrayBuffer)((0, import_cborg.encode)(msg)));
    }
  }
  // HTTP endpoint for health check
  async onRequest(req) {
    if (new URL(req.url).pathname.includes("/health")) {
      return Response.json({
        status: "ok",
        room: this.room.id,
        connectedPeers: Array.from(this.room.getConnections()).length
      }, { status: 200, headers: import_index.CORS });
    }
    return new Response("\u{1F44D} All good", { status: 200, headers: import_index.CORS });
  }
  // ---- helpers ----
  sendErrorAndClose(conn, message) {
    const errorMsg = { type: "error", message };
    try {
      conn.send((0, import_util.toArrayBuffer)((0, import_cborg.encode)(errorMsg)));
    } finally {
      conn.close();
    }
  }
}
const selectProtocol = /* @__PURE__ */ __name((versions) => {
  if (versions === void 0) return import_protocolVersion.ProtocolV1;
  if (versions.includes(import_protocolVersion.ProtocolV1)) return import_protocolVersion.ProtocolV1;
  return null;
}, "selectProtocol");
//# sourceMappingURL=relay.cjs.map