UNPKG

@substrate-system/mergeparty

Version:
8 lines (7 loc) 18.6 kB
{ "version": 3, "sources": ["../../src/server/relay.ts"], "sourcesContent": ["import type * as Party from 'partykit/server'\nimport { encode as cborEncode, decode as cborDecode } from 'cborg'\nimport { EventEmitter } from 'eventemitter3'\nimport Debug, { type Debugger } from '@substrate-system/debug/cloudflare'\nimport {\n type NetworkAdapterEvents,\n type NetworkAdapter,\n type PeerId,\n type PeerMetadata,\n} from '@substrate-system/automerge-repo-slim'\nimport {\n CORS,\n type BaseMsg,\n type PeerMessage,\n SUPPORTED_PROTOCOL_VERSION\n} from './index.js'\nimport {\n type FromServerMessage,\n type FromClientMessage,\n type ProtocolVersion\n} from '@substrate-system/automerge-repo-network-websocket'\nimport {\n isJoinMessage,\n type JoinMessage as JoinMsg,\n} from '@substrate-system/automerge-repo-network-websocket/messages'\nimport {\n ProtocolV1\n} from '@substrate-system/automerge-repo-network-websocket/protocolVersion'\nimport { toArrayBuffer, toU8, assert } from '../util.js'\n\nconst debug = Debug('mergeparty:relay')\n\n/**\n * Relay-only server.\n * - No storage; just routes messages between peers in the same room\n * - Handshake: expect `join`, reply with `peer`\n * - Messages: forward anything with a `targetId` to the mapped peer\n *\n * The network adapter does not know about the repo at all. It emits events\n * that the repo listens to.\n *\n * @event 'peer-candidate'\n * @event 'message'\n *\n * Based on {@link https://github.com/automerge/automerge-repo-sync-server|automerge-repo-sync-server|}\n * @see {@link https://github.com/substrate-system/automerge-repo-slim/blob/main/src/network/NetworkAdapterInterface.ts | NetworkAdapter interface}\n * @see {@link https://github.com/automerge/automerge-repo/blob/main/packages/automerge-repo-network-websocket/src/WebSocketServerAdapter.ts | Websocket server adapter}\n */\nexport class Relay\n extends EventEmitter\n implements NetworkAdapter, Party.Server\n{ // eslint-disable-line brace-style\n readonly room:Party.Room\n readonly serverPeerId:string\n readonly isStorageServer:boolean = false\n peerId?:PeerId // our peer ID\n peerMetadata?:PeerMetadata // our peer metadata\n sockets:{ [peerId:PeerId]:Party.Connection } = {}\n _log:Debugger\n _baseLog:Debugger\n\n // Connection -> meta { peerId?:string, joined:boolean }\n protected byConn = new Map<Party.Connection, {\n peerId?:string;\n joined:boolean\n }>()\n\n constructor (room:Party.Room) {\n super()\n this.room = room\n // Use a deterministic server peer id per room so clients can address\n // the server if they want\n this.serverPeerId = `server:${room.id}`\n this._baseLog = Debug('mergeparty')\n this._log = this._baseLog.extend('relay') // mergeparty:relay\n }\n\n listenerCount<T extends keyof NetworkAdapterEvents> (\n _event:T\n ):number {\n return 0\n }\n\n eventNames ():(keyof NetworkAdapterEvents)[] {\n type EventKeys = keyof NetworkAdapterEvents\n const eventKeys = [\n 'close',\n 'peer-candidate',\n 'peer-disconnected',\n 'message',\n ] as const satisfies EventKeys[]\n\n return eventKeys\n }\n\n // --- NetworkAdapterInterface required methods ---\n\n isReady ():boolean {\n return true\n }\n\n /**\n * Called by the Repo to start things.\n * @param {PeerId} peerId The peerId of *this repo*.\n * @param {PeerMetadata} meta How this adapter should present itselft\n * to other peers.\n */\n connect (peerId:PeerId, meta:PeerMetadata):void {\n this.peerId = peerId\n this.peerMetadata = meta\n }\n\n disconnect ():void {\n // obsolete in partykit\n }\n\n send (message:FromServerMessage):void {\n if ('data' in message && message.data?.byteLength === 0) {\n throw new Error('Tried to send a zero-length message')\n }\n\n // const senderId = this.peerId\n // if (!senderId) throw new Error('Not senderId')\n // const socket = this.room.getConnection(senderId)\n const to = this.sockets[message.targetId as string]\n if (!to) {\n this._log(`Tried to send to disconnected peer: ${message.targetId}`)\n return\n }\n\n const encoded = cborEncode(message)\n to.send(toArrayBuffer(encoded))\n }\n\n open ():void {}\n close ():void {}\n subscribe ():void {}\n unsubscribe ():void {}\n\n get networkId ():string {\n return this.room.id\n }\n\n // Abstract method from NetworkAdapter\n whenReady ():Promise<void> {\n return Promise.resolve()\n }\n\n // ---- WebSocket lifecycle ----\n\n onConnect (conn:Party.Connection) {\n this.byConn.set(conn, { joined: false })\n }\n\n onClose (conn:Party.Connection) {\n const meta = this.byConn.get(conn)\n if (meta?.peerId) {\n delete this.sockets[meta.peerId]\n }\n this.byConn.delete(conn)\n this.emit('peer-disconnected', {\n peerId: this.byConn.get(conn)?.peerId\n })\n }\n\n protected cborEncode (data:Record<any, any>) {\n return cborEncode(data)\n }\n\n protected cborDecode<T=any> (raw:ArrayBuffer):T {\n return cborDecode(toU8(raw))\n }\n\n /**\n * Decode CBOR messages.\n * This handles 'join' + handshake process.\n * Emits `peer-candidate` and `message` events.\n *\n * @fires peer-candidate\n * @fires message\n */\n async onMessage (raw:ArrayBuffer|string, conn:Party.Connection) {\n debug('[Relay] Received message from client')\n\n if (typeof raw === 'string') {\n this.sendErrorAndClose(\n conn,\n 'Expected binary CBOR frame, got string'\n )\n return\n }\n\n let message:FromClientMessage\n try {\n message = cborDecode(toU8(raw))\n } catch (_err) {\n const err = _err as Error\n console.error(err.message)\n conn.close()\n return\n }\n\n const meta = this.byConn.get(conn) ?? { joined: false }\n\n // --- Handshake: first message must be `join` ---\n if (!meta.joined) {\n if (!isJoinMessage(message)) {\n // emit message and stop\n // this follows the automerge websocket server protocol\n // https://github.com/automerge/automerge-repo/blob/0c791e660723d8701a817c02d88bed4bf249b588/packages/automerge-repo-network-websocket/src/WebSocketServerAdapter.ts#L71\n\n /**\n * @see {@link https://github.com/automerge/automerge-repo/blob/0c791e660723d8701a817c02d88bed4bf249b588/packages/automerge-repo-network-websocket/src/WebSocketServerAdapter.ts#L178}\n * If not a 'join' message, `this.emit('message', msg)`\n */\n this.emit('message', message)\n return\n }\n\n // --- message is \"join\" type ---\n const join = message as JoinMsg\n const versions = join.supportedProtocolVersions ?? ['1']\n if (!versions.includes(SUPPORTED_PROTOCOL_VERSION)) {\n return this.sendErrorAndClose(\n conn,\n 'Unsupported protocol version. ' +\n `Server supports ${SUPPORTED_PROTOCOL_VERSION}`\n )\n }\n\n if (!join.senderId || typeof join.senderId !== 'string') {\n this.sendErrorAndClose(conn, '`senderId` missing or invalid')\n return\n }\n\n // ---------- message is valid join type ----------\n\n const { senderId, peerMetadata, supportedProtocolVersions } = join\n // Let the repo know that we have a new connection.\n this.emit('peer-candidate', {\n peerId: senderId,\n peerMetadata,\n })\n this.sockets[senderId] = conn\n\n // map peerID to connection\n this.sockets[join.senderId] = conn\n // connection to peerID\n this.byConn.set(conn, { joined: true, peerId: join.senderId })\n\n const selectedProtocolVersion = selectProtocol(supportedProtocolVersions)\n if (selectedProtocolVersion === null) {\n // invalid protocol version\n this.send({\n type: 'error',\n senderId: this.peerId!,\n message: 'unsupported protocol version',\n targetId: senderId,\n })\n this.sockets[senderId].close()\n delete this.sockets[senderId]\n } else {\n // Tell the new person that this server is a peer.\n this.send({\n type: 'peer',\n senderId: this.peerId!,\n peerMetadata: this.peerMetadata!,\n selectedProtocolVersion: ProtocolV1,\n targetId: senderId,\n })\n }\n\n // 1) Tell the new client about all existing peers\n // for (const existingId of this.peers.keys()) {\n for (const existingId of Object.keys(this.sockets)) {\n if (existingId === join.senderId) continue\n this.announce(existingId, join.senderId)\n }\n\n // 2) Tell all existing peers about the new client\n // for (const [existingId, _existingConn] of this.peers) {\n for (const existingId of Object.keys(this.sockets)) {\n if (existingId === join.senderId) continue\n this.announce(join.senderId, existingId)\n }\n\n // 3) If this is a storage server,\n // then announce ourselves as a peer\n if (this.isStorageServer) {\n assert(this.peerId)\n this.announce(this.peerId, join.senderId)\n }\n\n return\n }\n\n // --- Post-handshake: relay all messages as raw binary ---\n const msg = message as BaseMsg\n const t = msg.targetId as string|undefined\n const isAliasToServer = typeof t === 'string' && t.startsWith('server:')\n const deliverToLocal = t === this.peerId || isAliasToServer\n\n // 1) Let the repo process frames addressed to THIS adapter\n if (deliverToLocal) {\n const localMsg = isAliasToServer ?\n { ...msg, targetId: this.peerId } :\n msg\n this.emit('message', localMsg)\n }\n\n // 2) Relay to an explicit target if present\n if (t) {\n const target = this.sockets[t]\n if (target) {\n target.send(raw)\n return\n }\n }\n\n // 3) Optional fan-out for \"server:*\" convention\n if (isAliasToServer) {\n for (const [peerId, conn] of Object.entries(this.sockets)) {\n if (peerId === msg.senderId) continue\n conn.send(toArrayBuffer(cborEncode({\n ...msg,\n targetId: peerId\n })))\n }\n }\n }\n\n private announce (announcedPeerId:string, toClientId:string) {\n const msg:PeerMessage = {\n type: 'peer',\n senderId: announcedPeerId, // the peer being announced\n targetId: toClientId, // the client who should learn about it\n selectedProtocolVersion: SUPPORTED_PROTOCOL_VERSION,\n peerMetadata: {},\n }\n\n const toConn = this.sockets[toClientId]\n if (toConn) {\n toConn.send(toArrayBuffer(cborEncode(msg)))\n }\n }\n\n // HTTP endpoint for health check\n async onRequest (req:Party.Request) {\n if (new URL(req.url).pathname.includes('/health')) {\n return Response.json({\n status: 'ok',\n room: this.room.id,\n connectedPeers: Array.from(this.room.getConnections()).length\n }, { status: 200, headers: CORS })\n }\n\n return new Response('\uD83D\uDC4D All good', { status: 200, headers: CORS })\n }\n\n // ---- helpers ----\n protected sendErrorAndClose (conn:Party.Connection, message:string):void {\n const errorMsg = { type: 'error', message }\n try {\n conn.send(toArrayBuffer(cborEncode(errorMsg)))\n } finally {\n conn.close()\n }\n }\n}\n\n// Usage notes:\n// * Clients connect with Repo configured for WebSocket network adapter\n// pointing to your Party URL:\n// ws(s)://<your-domain>/parties/<projectName>/<roomId>\n// * Each room gives you isolation: peers in the same room can address each\n// other by `peerId`.\n// * This server does NOT persist or synthesize Automerge sync messages\u2014it only\n// forwards CBOR frames.\n\nconst selectProtocol = (versions?:ProtocolVersion[]) => {\n if (versions === undefined) return ProtocolV1\n if (versions.includes(ProtocolV1)) return ProtocolV1\n return null\n}\n\n// function joinMessage (\n// senderId: PeerId,\n// peerMetadata: PeerMetadata\n// ):JoinMsg {\n// return {\n// type: 'join',\n// senderId,\n// peerMetadata,\n// supportedProtocolVersions: [ProtocolV1],\n// }\n// }\n"], "mappings": "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", "names": ["Debug", "cborEncode", "cborDecode", "conn"] }