diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ab102e4a..b17f4bdd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,4 +57,18 @@ jobs: yarn build cd ../../examples/canvas yarn install --frozen-lockfile + yarn build + + build-example-chat: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - shell: bash + run: | + # needs to build dist beforehand + cd packages/node + yarn install --frozen-lockfile + yarn build + cd ../../examples/chat + yarn install --frozen-lockfile yarn build \ No newline at end of file diff --git a/examples/chat/.gitignore b/examples/chat/.gitignore new file mode 100644 index 00000000..abca009d --- /dev/null +++ b/examples/chat/.gitignore @@ -0,0 +1,4 @@ +dist/ +docs/ +node_modules/ +public/static \ No newline at end of file diff --git a/examples/chat/README.md b/examples/chat/README.md new file mode 100644 index 00000000..d6623dcc --- /dev/null +++ b/examples/chat/README.md @@ -0,0 +1,27 @@ +# Topology Protocol Example + +This is an example of Topology Protocol usage in a chat system where a user can create or connect to a chat room, send and read the messages sent in the group chat. + +## Specifics + +Messages are represented as strings in the format (timestamp, content, senderId). Chat is a class which extends TopologyObject and has Gset\ as an attribute to store the list of messages. + +## How to run locally + +After cloning the repository, run the following commands: + +```bash +cd ts-topology/examples/chat +yarn +yarn build +yarn dev +``` + +Debugging is made easier by setting the mode in `webpack.config.js` to "development": + +```js +module.exports = { + mode: "development", + ... +} +``` diff --git a/examples/chat/package.json b/examples/chat/package.json new file mode 100644 index 00000000..dc23e559 --- /dev/null +++ b/examples/chat/package.json @@ -0,0 +1,33 @@ +{ + "name": "topology-example-chat", + "version": "1.0.0", + "description": "Topology Protocol Chat Exmaple", + "main": "src/index.ts", + "repository": "https://github.com/topology-foundation/ts-topology.git", + "license": "MIT", + "dependencies": { + "@topology-foundation/crdt": "file:../../packages/crdt", + "@topology-foundation/network": "file:../../packages/network", + "@topology-foundation/node": "file:../../packages/node", + "@topology-foundation/object": "file:../../packages/object", + "crypto-browserify": "^3.12.0", + "process": "^0.11.10", + "stream-browserify": "^3.0.0", + "ts-node": "^10.9.2", + "vm-browserify": "^1.1.2" + }, + "devDependencies": { + "@types/node": "^20.11.16", + "ts-loader": "^9.3.1", + "typescript": "^4.7.4", + "webpack": "^5.74.0", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^5.0.4" + }, + "scripts": { + "build": "webpack", + "clean": "rm -rf dist/ node_modules/", + "dev": "webpack serve", + "start": "ts-node ./src/index.ts" + } +} diff --git a/examples/chat/public/index.html b/examples/chat/public/index.html new file mode 100644 index 00000000..928d948b --- /dev/null +++ b/examples/chat/public/index.html @@ -0,0 +1,51 @@ + + + + + + Topology - Chat + + +
+

Topology Protocol - Chat

+

Current peer ID

+

Connected to

+

peers:

+

discovery_peers:

+

object_peers:

+ + + + + +
+ +
+ +
+ +
+ + +
+ + + + + + diff --git a/examples/chat/src/handlers.ts b/examples/chat/src/handlers.ts new file mode 100644 index 00000000..2c6ccb83 --- /dev/null +++ b/examples/chat/src/handlers.ts @@ -0,0 +1,31 @@ +import { toString as uint8ArrayToString } from "uint8arrays/to-string"; +import { IChat } from "./objects/chat"; + +export const handleChatMessages = (chat: IChat, e: any) => { + if (e.detail.msg.topic === "topology::discovery") return; + const input = uint8ArrayToString(e.detail.msg.data); + const message = JSON.parse(input); + console.log("Received message!: ", message); + switch (message["type"]) { + case "object_update": { + const fn = uint8ArrayToString(new Uint8Array(message["data"])); + handleObjectUpdate(chat, fn); + break; + } + default: { + break; + } + } +}; + +function handleObjectUpdate(chat: IChat, fn: string) { + // In this case we only have addMessage + // `addMessage(${timestamp}, ${message}, ${node.getPeerId()})` + let args = fn.replace("addMessage(", "").replace(")", "").split(", "); + console.log("Received message: ", args); + try { + chat.addMessage(args[0], args[1], args[2]); + } catch (e) { + console.error(e); + } +} \ No newline at end of file diff --git a/examples/chat/src/index.ts b/examples/chat/src/index.ts new file mode 100644 index 00000000..d2d2888a --- /dev/null +++ b/examples/chat/src/index.ts @@ -0,0 +1,129 @@ +import { TopologyNode } from "@topology-foundation/node"; +import { Chat, IChat } from "./objects/chat"; +import { handleChatMessages } from "./handlers"; +import { GSet } from "@topology-foundation/crdt"; + +const node = new TopologyNode(); +// CRO = Conflict-free Replicated Object +let chatCRO: IChat; +let peers: string[] = []; +let discoveryPeers: string[] = []; +let objectPeers: string[] = []; + +const render = () => { + const element_peerId = document.getElementById("peerId"); + element_peerId.innerHTML = node.networkNode.peerId; + + const element_peers = document.getElementById("peers"); + element_peers.innerHTML = "[" + peers.join(", ") + "]"; + + const element_discoveryPeers = document.getElementById("discoveryPeers"); + element_discoveryPeers.innerHTML = "[" + discoveryPeers.join(", ") + "]"; + + const element_objectPeers = document.getElementById("objectPeers"); + element_objectPeers.innerHTML = "[" + objectPeers.join(", ") + "]"; + + if(!chatCRO) return; + const chat = chatCRO.getMessages(); + const element_chat = document.getElementById("chat"); + element_chat.innerHTML = ""; + + if(chat.set().size == 0){ + const div = document.createElement("div"); + div.innerHTML = "No messages yet"; + div.style.padding = "10px"; + element_chat.appendChild(div); + return; + } + Array.from(chat.set()).sort().forEach((message: string) => { + const div = document.createElement("div"); + div.innerHTML = message; + div.style.padding = "10px"; + element_chat.appendChild(div); + }); + +} + +async function sendMessage(message: string) { + let timestamp: string = Date.now().toString(); + if(!chatCRO) { + console.error("Chat CRO not initialized"); + alert("Please create or join a chat room first"); + return; + } + console.log("Sending message: ", `(${timestamp}, ${message}, ${node.networkNode.peerId})`); + chatCRO.addMessage(timestamp, message, node.networkNode.peerId); + + node.updateObject(chatCRO, `addMessage(${timestamp}, ${message}, ${node.networkNode.peerId})`); + render(); +} + +async function main() { + await node.start(); + render(); + + node.addCustomGroupMessageHandler((e) => { + handleChatMessages(chatCRO, e); + peers = node.networkNode.getAllPeers(); + discoveryPeers = node.networkNode.getGroupPeers("topology::discovery"); + if(chatCRO) objectPeers = node.networkNode.getGroupPeers(chatCRO.getObjectId()); + render(); + }); + + let button_create = document.getElementById("createRoom"); + button_create.addEventListener("click", () => { + chatCRO = new Chat(node.networkNode.peerId); + node.createObject(chatCRO); + (document.getElementById("chatId")).innerHTML = chatCRO.getObjectId(); + render(); + }); + + let button_connect = document.getElementById("joinRoom"); + button_connect.addEventListener("click", async () => { + let input: HTMLInputElement = document.getElementById("roomInput"); + let objectId = input.value; + if(!objectId){ + alert("Please enter a room id"); + return; + } + await node.subscribeObject(objectId, true); + }); + + let button_fetch = document.getElementById("fetchMessages"); + button_fetch.addEventListener("click", async () => { + let input: HTMLInputElement = document.getElementById("roomInput"); + let objectId = input.value; + try { + + let object: any = node.getObject(objectId); + console.log("Object received: ", object); + + let arr: string[] = Array.from(object["chat"]["_set"]); + object["chat"]["_set"] = new Set(arr); + object["chat"] = Object.assign(new GSet(new Set()), object["chat"]); + chatCRO = Object.assign(new Chat(node.networkNode.peerId), object); + + (document.getElementById("chatId")).innerHTML = objectId; + render(); + } catch (e) { + console.error("Error while connecting to the CRO ", objectId, e); + } + }); + + let button_send = document.getElementById("sendMessage"); + button_send.addEventListener("click", async () => { + let input: HTMLInputElement = document.getElementById("messageInput"); + let message: string = input.value; + input.value = ""; + if(!message){ + console.error("Tried sending an empty message"); + alert("Please enter a message"); + return; + } + await sendMessage(message); + const element_chat = document.getElementById("chat"); + element_chat.scrollTop = element_chat.scrollHeight; + }); +} + +main(); \ No newline at end of file diff --git a/examples/chat/src/objects/chat.ts b/examples/chat/src/objects/chat.ts new file mode 100644 index 00000000..d8333df7 --- /dev/null +++ b/examples/chat/src/objects/chat.ts @@ -0,0 +1,32 @@ +import { TopologyObject } from "@topology-foundation/object"; +import { GSet } from "@topology-foundation/crdt"; + +export interface IChat extends TopologyObject { + chat: GSet; + addMessage(timestamp: string, message: string, node_id: string): void; + getMessages(): GSet; + merge(other: Chat): void; +} + +export class Chat extends TopologyObject implements IChat { + // store messages as strings in the format (timestamp, message, peerId) + chat: GSet; + + constructor(peerId: string) { + super(peerId); + this.chat = new GSet(new Set()); + } + + addMessage(timestamp: string, message: string, node_id: string): void { + this.chat.add(`(${timestamp}, ${message}, ${node_id})`); + } + + getMessages(): GSet { + return this.chat; + } + + merge(other: Chat): void { + this.chat.merge(other.chat); + } + +} diff --git a/examples/chat/tsconfig.json b/examples/chat/tsconfig.json new file mode 100644 index 00000000..86c25cb7 --- /dev/null +++ b/examples/chat/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "ESNEXT", + "rootDir": ".", + "strict": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "allowJs": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + } +} diff --git a/examples/chat/webpack.config.js b/examples/chat/webpack.config.js new file mode 100644 index 00000000..46ac5e36 --- /dev/null +++ b/examples/chat/webpack.config.js @@ -0,0 +1,63 @@ +const path = require("path"); +const webpack = require("webpack"); +const fs = require("fs"); + +module.exports = { + mode: "production", + entry: path.resolve(__dirname, "./src/index.ts"), + devServer: { + allowedHosts: "all", + client: { + overlay: false, + }, + static: { + directory: path.join(__dirname, "public"), + }, + compress: true, + hot: true, + port: 3000, + }, + module: { + rules: [ + { + test: /\.ts?$/, + use: "ts-loader", + exclude: /node_modules/, + }, + { + test: /\.m?js$/, + resolve: { + fullySpecified: false, + }, + }, + ], + }, + resolve: { + extensions: [".ts", ".js"], + fallback: { + crypto: require.resolve("crypto-browserify"), + dgram: false, + os: false, + net: false, + path: false, + "process/browser": require.resolve("process/browser"), + stream: require.resolve("stream-browserify"), + vm: require.resolve("vm-browserify"), + }, + }, + output: { + filename: "script.js", + path: path.resolve(__dirname, "public", "static", "bundle"), + publicPath: "/static/bundle/", + }, + performance: { + hints: false, + maxEntrypointSize: 512000, + maxAssetSize: 512000, + }, + plugins: [ + new webpack.ProvidePlugin({ + process: "process/browser", + }), + ], +}; diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 36a7df9d..5e49367f 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -48,7 +48,7 @@ export class TopologyNode { const object = this.getObject(objectId); const object_message = `{ "type": "object", - "data": [${uint8ArrayFromString(JSON.stringify(object))}] + "data": [${uint8ArrayFromString(JSON.stringify(object, (_key, value) => (value instanceof Set ? [...value] : value)))}] }`; await this.networkNode.sendMessage( message["sender"],