import { createLightNode } from "js-waku/lib/create_waku"; import { utils } from "js-waku"; import { waitForRemotePeer } from "js-waku/lib/wait_for_remote_peer"; import { Fleet, getPredefinedBootstrapNodes, } from "js-waku/lib/predefined_bootstrap_nodes"; import { PeerDiscoveryStaticPeers } from "js-waku/lib/peer_discovery_static_list"; import { Protocols } from "js-waku"; import * as noise from "@waku/noise"; import protobuf from "protobufjs"; import QRCode from "qrcode"; // Protobuf const ProtoMessage = new protobuf.Type("Message").add( new protobuf.Field("data", 3, "string") ); main(); async function main() { const ui = initUI(); ui.waku.connecting(); // Starting the node const node = await createLightNode({ libp2p: { peerDiscovery: [ new PeerDiscoveryStaticPeers(getPredefinedBootstrapNodes(Fleet.Test)), ], }, }); try { await node.start(); await waitForRemotePeer(node, [Protocols.Filter, Protocols.LightPush]); ui.waku.connected(); const [sender, responder] = getSenderAndResponder(node); const myStaticKey = noise.generateX25519KeyPair(); const urlPairingInfo = getPairingInfoFromURL(); const pairingObj = new noise.WakuPairing( sender, responder, myStaticKey, urlPairingInfo || new noise.ResponderParameters() ); const pExecute = pairingObj.execute(120000); // timeout after 2m scheduleHandshakeAuthConfirmation(pairingObj, ui); let sendWakuMessage; let listenToWakuMessages; try { ui.handshake.waiting(); if (!urlPairingInfo) { const pairingURL = buildPairingURLFromObj(pairingObj); ui.shareInfo.setURL(pairingURL); ui.shareInfo.renderQR(pairingURL); ui.shareInfo.show(); } [sendWakuMessage, listenToWakuMessages] = await buildWakuMessage( node, pExecute ); ui.handshake.connected(); ui.shareInfo.hide(); } catch (err) { ui.handshake.error(err.message); ui.hide(); } /* // The information needs to be backed up to decrypt messages sent with // codecs generated with the handshake. The `handshakeResult` variable // contains private information that needs to be stored safely const contentTopic = pairingObj.contentTopic; const handshakeResult = pairingObj.getHandshakeResult(); // To restore the codecs for decrypting older messages, or continuing an existing // session, use this: [encoder, decoder] = WakuPairing.getSecureCodec(contentTopic, handshakeResult); */ ui.message.display(); const { peerConnection, sendMessage: sendRTCMessage } = initRTC({ ui, onReceive: ui.message.onReceive.bind(ui.message), }); peerConnection.onicecandidate = async (event) => { if (event.candidate) { console.log("candidate sent"); try { // if (!peerConnection.remoteDescription) return; ui.rtc.sendingCandidate(); await sendWakuMessage({ type: "candidate", candidate: event.candidate, }); } catch (error) { ui.rtc.error(error.message); } } }; const sendOffer = async () => { console.log("offer sent"); ui.rtc.sendingOffer(); try { const offer = await peerConnection.createOffer(); await peerConnection.setLocalDescription(offer); await sendWakuMessage({ type: "offer", offer, }); } catch (error) { ui.rtc.error(error.message); } }; const sendAnswer = async (data) => { console.log("answer sent"); ui.rtc.sendingAnswer(); try { await peerConnection.setRemoteDescription( new RTCSessionDescription(data.offer) ); const answer = await peerConnection.createAnswer(); peerConnection.setLocalDescription(answer); await sendWakuMessage({ type: "answer", answer, }); } catch (error) { ui.rtc.error(error.message); } }; const receiveAnswer = async (data) => { try { console.log("answer received"); await peerConnection.setRemoteDescription( new RTCSessionDescription(data.answer) ); console.log("answer saved"); await sendWakuMessage({ type: "ready", text: "received answer", }); } catch (error) { ui.rtc.error(error.message); } }; const receiveCandidate = async (data) => { try { // if (!peerConnection.pendingRemoteDescription) return; console.log("candidate saved"); await peerConnection.addIceCandidate( new RTCIceCandidate(data.candidate) ); } catch (error) { ui.rtc.error(error.message); } }; const handleWakuMessages = async (data) => { if (data.type === "offer") { await sendAnswer(data); } if (data.type === "answer") { await receiveAnswer(data); } if (data.type === "ready") { console.log("RTC: partner is", data.text); } if (data.type === "candidate") { await receiveCandidate(data); } }; await listenToWakuMessages(handleWakuMessages); ui.message.onSend(sendRTCMessage); // if we are initiator of Noise handshake // let's initiate Web RTC as well if (!urlPairingInfo) { await sendOffer(); } } catch (err) { ui.waku.error(err.message); ui.hide(); } } function buildPairingURLFromObj(pairingObj) { const pInfo = pairingObj.getPairingInfo(); // Data to encode in the QR code. The qrMessageNametag too to the QR string (separated by ) const messageNameTagParam = `messageNameTag=${utils.bytesToHex( pInfo.qrMessageNameTag )}`; const qrCodeParam = `qrCode=${encodeURIComponent(pInfo.qrCode)}`; return `${window.location.href}?${messageNameTagParam}&${qrCodeParam}`; } function getPairingInfoFromURL() { const urlParams = new URLSearchParams(window.location.search); const messageNameTag = urlParams.get("messageNameTag"); const qrCodeString = urlParams.get("qrCode"); if (!(messageNameTag && qrCodeString)) { return undefined; } return new noise.InitiatorParameters( decodeURIComponent(qrCodeString), utils.hexToBytes(messageNameTag) ); } function getSenderAndResponder(node) { const sender = { async publish(encoder, msg) { await node.lightPush.push(encoder, msg); }, }; const msgQueue = new Array(); const subscriptions = new Map(); const intervals = new Map(); const responder = { async subscribe(decoder) { const subscription = await node.filter.subscribe( [decoder], (wakuMessage) => { msgQueue.push(wakuMessage); } ); subscriptions.set(decoder.contentTopic, subscription); }, async nextMessage(contentTopic) { if (msgQueue.length != 0) { const oldestMsg = msgQueue.shift(); if (oldestMsg.contentTopic === contentTopic) { return oldestMsg; } } return new Promise((resolve) => { const interval = setInterval(() => { if (msgQueue.length != 0) { clearInterval(interval); const oldestMsg = msgQueue.shift(); if (oldestMsg.contentTopic === contentTopic) { resolve(oldestMsg); } } }, 100); intervals.set(contentTopic, interval); }); }, async stop(contentTopic) { if (intervals.has(contentTopic)) { clearInterval(intervals.get(contentTopic)); intervals.delete(contentTopic); } if (subscriptions.has(contentTopic)) { await subscriptions.get(contentTopic)(); subscriptions.delete(contentTopic); } else { console.log("Subscriptipon doesnt exist"); } }, }; return [sender, responder]; } async function scheduleHandshakeAuthConfirmation(pairingObj, ui) { const authCode = await pairingObj.getAuthCode(); ui.handshake.connecting(); pairingObj.validateAuthCode(confirm("Confirm that authcode is: " + authCode)); } async function buildWakuMessage(node, noiseExecute) { const [encoder, decoder] = await noiseExecute; const sendMessage = async (message) => { let payload = ProtoMessage.create({ // data: utils.utf8ToBytes(JSON.stringify(message)), data: JSON.stringify(message), }); payload = ProtoMessage.encode(payload).finish(); return node.lightPush.push(encoder, { payload }); }; const listenToMessages = async (fn) => { return node.filter.subscribe([decoder], ({ payload }) => { const { data } = ProtoMessage.decode(payload); // fn(JSON.parse(utils.bytesToUtf8(data))); fn(JSON.parse(data)); }); }; return [sendMessage, listenToMessages]; } function initRTC({ ui, onReceive }) { const configuration = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }], }; const peerConnection = new RTCPeerConnection(configuration); const sendChannel = peerConnection.createDataChannel("chat"); let receiveChannel; sendChannel.onopen = (event) => { ui.rtc.ready(); console.log("onopen send", event); }; peerConnection.ondatachannel = (event) => { receiveChannel = event.channel; receiveChannel.onmessage = (event) => { onReceive(JSON.parse(event.data)); }; receiveChannel.onopen = (event) => { ui.rtc.ready(); console.log("onopen receive", event); }; }; const sendMessage = (text, nick) => { sendChannel.send(JSON.stringify({ text, nick, timestamp: Date.now() })); }; return { peerConnection, sendChannel, receiveChannel, sendMessage, }; } function initUI() { const messagesList = document.getElementById("messages"); const nicknameInput = document.getElementById("nick-input"); const textInput = document.getElementById("text-input"); const sendButton = document.getElementById("send-btn"); const chatArea = document.getElementById("chat-area"); const wakuStatusSpan = document.getElementById("waku-status"); const handshakeStatusSpan = document.getElementById("handshake-status"); const qrCanvas = document.getElementById("qr-canvas"); const qrUrlContainer = document.getElementById("qr-url-container"); const qrUrl = document.getElementById("qr-url"); const copyURLButton = document.getElementById("copy-url"); const openTabButton = document.getElementById("open-tab"); const rtcStatus = document.getElementById("rtc-status"); const connectChat = document.getElementById("connect-chat-btn"); copyURLButton.onclick = () => { const copyText = document.getElementById("qr-url"); // need to get it each time otherwise copying does not work copyText.select(); copyText.setSelectionRange(0, 99999); navigator.clipboard.writeText(copyText.value); }; openTabButton.onclick = () => { window.open(qrUrl.value, "_blank"); }; const disableChatUIStateIfNeeded = () => { const readyToSend = nicknameInput.value !== ""; textInput.disabled = !readyToSend; sendButton.disabled = !readyToSend; }; nicknameInput.onchange = disableChatUIStateIfNeeded; nicknameInput.onblur = disableChatUIStateIfNeeded; return { shareInfo: { setURL(url) { qrUrl.value = url; }, hide() { qrUrlContainer.style.display = "none"; }, show() { qrUrlContainer.style.display = "flex"; }, renderQR(url) { QRCode.toCanvas(qrCanvas, url, (err) => { if (err) { throw err; } }); }, }, waku: { _val(msg) { wakuStatusSpan.innerText = msg; }, _class(name) { wakuStatusSpan.className = name; }, connecting() { this._val("connecting..."); this._class("progress"); }, connected() { this._val("connected"); this._class("success"); }, error(msg) { this._val(msg); this._class("error"); }, }, handshake: { _val(val) { handshakeStatusSpan.innerText = val; }, _class(name) { handshakeStatusSpan.className = name; }, error(msg) { this._val(msg); this._class("error"); }, waiting() { this._val("waiting for handshake..."); this._class("progress"); }, generating() { this._val("generating QR code..."); this._class("progress"); }, connecting() { this._val("executing handshake..."); this._class("progress"); }, connected() { this._val("handshake completed!"); this._class("success"); }, }, message: { _render({ time, text, nick }) { messagesList.innerHTML += `
  • (${nick}) ${text} [${new Date(time).toISOString()}]
  • `; }, _status(text, className) { sendButton.className = className; }, onReceive(data) { const { timestamp, nick, text } = data; this._render({ nick, time: timestamp * 1000, text, }); }, onSend(cb) { sendButton.addEventListener("click", async () => { try { this._status("sending...", "progress"); await cb(textInput.value, nicknameInput.value); this._status("sent", "success"); this._render({ time: Date.now(), // a bit different from what receiver will see but for the matter of example is good enough text: textInput.value, nick: nicknameInput.value, }); textInput.value = ""; } catch (e) { this._status(`error: ${e.message}`, "error"); } }); }, display() { chatArea.style.display = "block"; this._status("waiting for input", "progress"); }, }, rtc: { _val(msg) { rtcStatus.innerText = msg; }, _class(name) { rtcStatus.className = name; }, sendingOffer() { this._val("sending offer"); this._class("progress"); }, sendingAnswer() { this._val("sending answer"); this._class("progress"); }, sendingCandidate() { this._val("sending ice candidate"); this._class("progress"); }, ready() { this._val("ready"); this._class("success"); }, error(msg) { this._val(msg); this._class("error"); }, onConnect(cb) { connectChat.addEventListener("click", cb); }, }, hide() { this.shareInfo.hide(); chatArea.style.display = "none"; }, }; }