feat: add direct relay-rtc example (#260)
* add relay-rtc example * add relay chat example * update readme and texts * add default node from go-waku prod fleet * add ci steps * update readme * rename to relay-direct-chat * rename to relay-direct-rtc
This commit is contained in:
parent
c6e560af0b
commit
1bd7597db4
|
@ -20,6 +20,7 @@ jobs:
|
|||
web-chat,
|
||||
noise-js,
|
||||
noise-rtc,
|
||||
relay-direct-rtc
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
|
|
@ -42,6 +42,7 @@ pipeline {
|
|||
stage('web-chat') { steps { script { buildExample() } } }
|
||||
stage('noise-js') { steps { script { buildExample() } } }
|
||||
stage('noise-rtc') { steps { script { buildExample() } } }
|
||||
stage('relay-direct-rtc') { steps { script { buildExample() } } }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
# Direct WebRTC connection for Waku Relay
|
||||
|
||||
**Demonstrates**:
|
||||
|
||||
- Waku Relay node with direct WebRTC connection
|
||||
- Pure Javascript/HTML.
|
||||
|
||||
This example uses WebRTC transport and Waku Relay to exchange messages.
|
||||
|
||||
To test the example run `npm install` and then `npm start`.
|
||||
|
||||
The `master` branch's HEAD is deployed at https://examples.waku.org/relay-direct-chat/.
|
||||
|
||||
### Steps to run an example:
|
||||
1. Get a Waku node that implements `/libp2p/circuit/relay/0.2.0/hop` and `/libp2p/circuit/relay/0.2.0/stop`
|
||||
1.1. Find `go-waku` node or
|
||||
1.2. Build and then run `go-waku` node with following command: `./build/waku --ws true --relay true --circuit-relay true`
|
||||
2. Copy node's multiaddr (e.g `/ip4/192.168.0.101/tcp/60001/ws/p2p/16Uiu2HAm9w2xeDWFJm5eeGLZfJdaPtkNatQD1xrzK5EFWSeXdFvu`)
|
||||
3. In `relay-chat` example's folder run `npm install` and then `npm start`
|
||||
4. Use `go-waku`'s multiaddr for **Remote node multiaddr** and press dial. Repeat in two more tabs.
|
||||
5. In `tab2` copy **Local Peer Id** and use as **WebRTC Peer** in `tab1` and press dial.
|
||||
6. In `tab1` or `tab2` press **Ensure WebRTC Relay connection**
|
||||
7. In `tab1` press **Drop non WebRTC connections**
|
||||
8. In `tab1` enter **Nickname** and **Message** and send.
|
||||
9. See the message in `tab3` which was connected only to `go-waku` node.
|
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
|
@ -0,0 +1,73 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||
<title>Relay direct chat</title>
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
<link rel="apple-touch-icon" href="./favicon.png" />
|
||||
<link rel="manifest" href="./manifest.json" />
|
||||
<link rel="icon" href="./favicon.ico" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<h3>Status: <span id="status"></span></h3>
|
||||
|
||||
<h4><label for="remoteNode">Remote node multiaddr</label></h4>
|
||||
<div>
|
||||
<input
|
||||
id="remoteNode"
|
||||
value="/dns4/node-01.ac-cn-hongkong-c.go-waku.prod.statusim.net/tcp/443/wss/p2p/16Uiu2HAm1fVVprL9EaDpDw4frNX3CPfZu5cBqhtk9Ncm6WCvprpv"
|
||||
/>
|
||||
<button id="connectRemoteNode">Dial</button>
|
||||
</div>
|
||||
|
||||
<h4><label for="webrtcPeer">WebRTC Peer</label></h4>
|
||||
<div>
|
||||
<input id="webrtcPeer" />
|
||||
<button id="connectWebrtcPeer">Dial</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button id="relayWebRTC">Ensure WebRTC Relay connection</button>
|
||||
<button id="dropNonWebRTC">Drop non WebRTC connections</button>
|
||||
</div>
|
||||
|
||||
<details open>
|
||||
<summary>Peer's information</summary>
|
||||
|
||||
<h4>Content topic</h4>
|
||||
<p id="contentTopic"></p>
|
||||
|
||||
<h4>Local Peer Id</h4>
|
||||
<p id="localPeerId"></p>
|
||||
|
||||
<h4>Remote Peer Id</h4>
|
||||
<p id="remotePeerId"></p>
|
||||
|
||||
<h4>Relay mesh's protocols</h4>
|
||||
<p id="relayMeshInfo"></p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div id="messages"></div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="inputArea">
|
||||
<input type="text" id="nickText" placeholder="Nickname" />
|
||||
<textarea id="messageText" placeholder="Message"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button id="send">Send</button>
|
||||
<button id="exit">Exit chat</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="//cdn.jsdelivr.net/npm/protobufjs@7.X.X/dist/protobuf.min.js"></script>
|
||||
<script type="module" src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,340 @@
|
|||
import {
|
||||
createRelayNode,
|
||||
bytesToUtf8,
|
||||
utf8ToBytes,
|
||||
createDecoder,
|
||||
createEncoder,
|
||||
} from "@waku/sdk";
|
||||
|
||||
import { webSockets } from "@libp2p/websockets";
|
||||
import { all as filterAll } from "@libp2p/websockets/filters";
|
||||
|
||||
import { webRTC } from "@libp2p/webrtc";
|
||||
import { circuitRelayTransport } from "libp2p/circuit-relay";
|
||||
|
||||
const CONTENT_TOPIC = "/toy-chat/2/huilong/proto";
|
||||
|
||||
const ui = initUI();
|
||||
runApp(ui).catch((err) => {
|
||||
console.error(err);
|
||||
ui.setStatus(`error: ${err.message}`, "error");
|
||||
});
|
||||
|
||||
async function runApp(ui) {
|
||||
const {
|
||||
info,
|
||||
sendMessage,
|
||||
unsubscribeFromMessages,
|
||||
dial,
|
||||
dialWebRTCpeer,
|
||||
dropNetworkConnections,
|
||||
ensureWebRTCconnectionInRelayMesh,
|
||||
} = await initWakuContext({
|
||||
ui,
|
||||
contentTopic: CONTENT_TOPIC,
|
||||
});
|
||||
|
||||
ui.setLocalPeer(info.localPeerId);
|
||||
ui.setContentTopic(info.contentTopic);
|
||||
|
||||
ui.onSendMessage(sendMessage);
|
||||
ui.onRemoteNodeConnect(dial);
|
||||
ui.onWebrtcConnect(dialWebRTCpeer);
|
||||
ui.onRelayWebRTC(ensureWebRTCconnectionInRelayMesh);
|
||||
ui.onDropNonWebRTC(dropNetworkConnections);
|
||||
|
||||
ui.onExit(async () => {
|
||||
ui.setStatus("disconnecting...", "progress");
|
||||
await unsubscribeFromMessages();
|
||||
ui.setStatus("disconnected", "terminated");
|
||||
ui.resetMessages();
|
||||
});
|
||||
}
|
||||
|
||||
async function initWakuContext({ ui, contentTopic }) {
|
||||
const Decoder = createDecoder(contentTopic);
|
||||
const Encoder = createEncoder({ contentTopic });
|
||||
|
||||
const ChatMessage = new protobuf.Type("ChatMessage")
|
||||
.add(new protobuf.Field("timestamp", 1, "uint64"))
|
||||
.add(new protobuf.Field("nick", 2, "string"))
|
||||
.add(new protobuf.Field("text", 3, "bytes"));
|
||||
|
||||
ui.setStatus("starting...", "progress");
|
||||
|
||||
const node = await createRelayNode({
|
||||
libp2p: {
|
||||
addresses: {
|
||||
listen: ["/webrtc"],
|
||||
},
|
||||
connectionGater: {
|
||||
denyDialMultiaddr: () => {
|
||||
// refuse to deny localhost addresses
|
||||
return false;
|
||||
},
|
||||
},
|
||||
transports: [
|
||||
webRTC({}),
|
||||
circuitRelayTransport({
|
||||
discoverRelays: 1,
|
||||
}),
|
||||
webSockets({ filter: filterAll }),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await node.start();
|
||||
|
||||
// Set a filter by using Decoder for a given ContentTopic
|
||||
const unsubscribeFromMessages = await node.relay.subscribe(
|
||||
[Decoder],
|
||||
(wakuMessage) => {
|
||||
const messageObj = ChatMessage.decode(wakuMessage.payload);
|
||||
ui.renderMessage({
|
||||
...messageObj,
|
||||
text: bytesToUtf8(messageObj.text),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
ui.setStatus("started", "success");
|
||||
|
||||
const localPeerId = node.libp2p.peerId.toString();
|
||||
|
||||
const remotePeers = await node.libp2p.peerStore.all();
|
||||
const remotePeerIds = new Set(remotePeers.map((peer) => peer.id.toString()));
|
||||
|
||||
ui.setRemotePeer(Array.from(remotePeerIds.keys()));
|
||||
|
||||
node.libp2p.addEventListener("peer:connect", async (event) => {
|
||||
remotePeerIds.add(event.detail.toString());
|
||||
ui.setRemotePeer(Array.from(remotePeerIds.keys()));
|
||||
ui.setRelayMeshInfo(node.relay.gossipSub);
|
||||
});
|
||||
|
||||
node.libp2p.addEventListener("peer:disconnect", (event) => {
|
||||
remotePeerIds.delete(event.detail.toString());
|
||||
ui.setRemotePeer(Array.from(remotePeerIds.keys()));
|
||||
ui.setRelayMeshInfo(node.relay.gossipSub);
|
||||
});
|
||||
|
||||
node.libp2p.addEventListener("peer:identify", (event) => {
|
||||
const peer = event.detail;
|
||||
|
||||
if (!peer.protocols.includes("/webrtc-signaling/0.0.1")) {
|
||||
return;
|
||||
}
|
||||
|
||||
ui.setWebrtcPeer(peer.peerId.toString());
|
||||
ui.setRelayMeshInfo(node.relay.gossipSub);
|
||||
});
|
||||
|
||||
window.node = node;
|
||||
|
||||
return {
|
||||
unsubscribeFromMessages,
|
||||
info: {
|
||||
contentTopic,
|
||||
localPeerId,
|
||||
},
|
||||
sendMessage: async ({ text, nick }) => {
|
||||
if (!text || !nick) {
|
||||
return;
|
||||
}
|
||||
|
||||
const protoMessage = ChatMessage.create({
|
||||
nick,
|
||||
timestamp: Date.now(),
|
||||
text: utf8ToBytes(text),
|
||||
});
|
||||
|
||||
await node.relay.send(Encoder, {
|
||||
payload: ChatMessage.encode(protoMessage).finish(),
|
||||
});
|
||||
},
|
||||
dial: async (multiaddr) => {
|
||||
ui.setStatus("connecting...", "progress");
|
||||
await node.dial(multiaddr);
|
||||
ui.setStatus("connected", "success");
|
||||
},
|
||||
dialWebRTCpeer: async (peerId) => {
|
||||
const peers = await node.libp2p.peerStore.all();
|
||||
const circuitPeer = peers.filter(
|
||||
(p) =>
|
||||
p.protocols.includes("/libp2p/circuit/relay/0.2.0/hop") &&
|
||||
p.protocols.includes("/libp2p/circuit/relay/0.2.0/stop")
|
||||
)[0];
|
||||
|
||||
if (!circuitPeer) {
|
||||
throw Error("No Circuit peer is found");
|
||||
}
|
||||
|
||||
let multiaddr = circuitPeer.addresses.pop().multiaddr;
|
||||
multiaddr = `${multiaddr}/p2p/${circuitPeer.id.toString()}/p2p-circuit/webrtc/p2p/${peerId}`;
|
||||
|
||||
await node.dial(multiaddr);
|
||||
ui.setRelayMeshInfo(node.relay.gossipSub);
|
||||
},
|
||||
ensureWebRTCconnectionInRelayMesh: async () => {
|
||||
const promises = node.libp2p
|
||||
.getConnections()
|
||||
.filter((c) => c.stat.multiplexer === "/webrtc")
|
||||
.map(async (c) => {
|
||||
const outboundStream = node.relay.gossipSub.streamsOutbound.get(
|
||||
c.remotePeer.toString()
|
||||
);
|
||||
const isWebRTCOutbound =
|
||||
outboundStream.rawStream.constructor.name === "WebRTCStream";
|
||||
|
||||
if (isWebRTCOutbound) {
|
||||
return;
|
||||
}
|
||||
|
||||
node.relay.gossipSub.streamsOutbound.delete(c.remotePeer.toString());
|
||||
await node.relay.gossipSub.createOutboundStream(
|
||||
c.remotePeer.toString(),
|
||||
c
|
||||
);
|
||||
});
|
||||
await Promise.all(promises);
|
||||
ui.setRelayMeshInfo(node.relay.gossipSub);
|
||||
},
|
||||
dropNetworkConnections: async () => {
|
||||
const promises = node.libp2p
|
||||
.getConnections()
|
||||
.filter((c) => c.stat.multiplexer !== "/webrtc")
|
||||
.map(async (c) => {
|
||||
const peerId = c.remotePeer.toString();
|
||||
|
||||
node.relay.gossipSub.peers.delete(peerId);
|
||||
node.relay.gossipSub.streamsInbound.delete(peerId);
|
||||
node.relay.gossipSub.streamsOutbound.delete(peerId);
|
||||
|
||||
await node.libp2p.peerStore.delete(c.remotePeer);
|
||||
await c.close();
|
||||
});
|
||||
await Promise.all(promises);
|
||||
ui.setRelayMeshInfo(node.relay.gossipSub);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// UI adapter
|
||||
function initUI() {
|
||||
const exitButton = document.getElementById("exit");
|
||||
const sendButton = document.getElementById("send");
|
||||
|
||||
const statusBlock = document.getElementById("status");
|
||||
const localPeerBlock = document.getElementById("localPeerId");
|
||||
const remotePeerId = document.getElementById("remotePeerId");
|
||||
const contentTopicBlock = document.getElementById("contentTopic");
|
||||
|
||||
const messagesBlock = document.getElementById("messages");
|
||||
|
||||
const nickText = document.getElementById("nickText");
|
||||
const messageText = document.getElementById("messageText");
|
||||
|
||||
const remoteNode = document.getElementById("remoteNode");
|
||||
const connectRemoteNode = document.getElementById("connectRemoteNode");
|
||||
|
||||
const webrtcPeer = document.getElementById("webrtcPeer");
|
||||
const connectWebrtcPeer = document.getElementById("connectWebrtcPeer");
|
||||
|
||||
const relayWebRTCbutton = document.getElementById("relayWebRTC");
|
||||
const dropNonWebRTCbutton = document.getElementById("dropNonWebRTC");
|
||||
|
||||
const relayMeshInfo = document.getElementById("relayMeshInfo");
|
||||
|
||||
return {
|
||||
// UI events
|
||||
onExit: (cb) => {
|
||||
exitButton.addEventListener("click", cb);
|
||||
},
|
||||
onSendMessage: (cb) => {
|
||||
sendButton.addEventListener("click", async () => {
|
||||
await cb({
|
||||
nick: nickText.value,
|
||||
text: messageText.value,
|
||||
});
|
||||
messageText.value = "";
|
||||
});
|
||||
},
|
||||
// UI renderers
|
||||
setStatus: (value, className) => {
|
||||
statusBlock.innerHTML = `<span class=${className || ""}>${value}</span>`;
|
||||
},
|
||||
setLocalPeer: (id) => {
|
||||
localPeerBlock.innerText = id.toString();
|
||||
},
|
||||
setRemotePeer: (ids) => {
|
||||
remotePeerId.innerText = ids.join("\n");
|
||||
},
|
||||
setContentTopic: (topic) => {
|
||||
contentTopicBlock.innerText = topic.toString();
|
||||
},
|
||||
renderMessage: (messageObj) => {
|
||||
const { nick, text, timestamp } = messageObj;
|
||||
const date = new Date(timestamp);
|
||||
|
||||
// WARNING: XSS vulnerable
|
||||
messagesBlock.innerHTML += `
|
||||
<div class="message">
|
||||
<p>${nick} <span>(${date.toDateString()})</span>:</p>
|
||||
<p>${text}</p>
|
||||
<div>
|
||||
`;
|
||||
},
|
||||
resetMessages: () => {
|
||||
messagesBlock.innerHTML = "";
|
||||
},
|
||||
setWebrtcPeer: (peerId) => {
|
||||
webrtcPeer.value = peerId;
|
||||
},
|
||||
onRemoteNodeConnect: (cb) => {
|
||||
connectRemoteNode.addEventListener("click", () => {
|
||||
const multiaddr = remoteNode.value;
|
||||
|
||||
if (!multiaddr) {
|
||||
throw Error("No multiaddr set to dial");
|
||||
}
|
||||
|
||||
cb(multiaddr);
|
||||
});
|
||||
},
|
||||
onWebrtcConnect: (cb) => {
|
||||
connectWebrtcPeer.addEventListener("click", () => {
|
||||
const multiaddr = webrtcPeer.value;
|
||||
|
||||
if (!multiaddr) {
|
||||
throw Error("No multiaddr to dial webrtc");
|
||||
}
|
||||
|
||||
cb(multiaddr);
|
||||
});
|
||||
},
|
||||
onRelayWebRTC: (cb) => {
|
||||
relayWebRTCbutton.addEventListener("click", cb);
|
||||
},
|
||||
onDropNonWebRTC: (cb) => {
|
||||
dropNonWebRTCbutton.addEventListener("click", cb);
|
||||
},
|
||||
setRelayMeshInfo: (gossipSub) => {
|
||||
relayMeshInfo.innerHTML = "";
|
||||
|
||||
Array.from(gossipSub.peers)
|
||||
.map((peerId) => {
|
||||
let inbound = gossipSub.streamsInbound.get(peerId);
|
||||
inbound = inbound ? inbound.rawStream.constructor.name : "none";
|
||||
|
||||
let outbound = gossipSub.streamsOutbound.get(peerId);
|
||||
outbound = outbound ? outbound.rawStream.constructor.name : "none";
|
||||
|
||||
return [peerId, inbound, outbound];
|
||||
})
|
||||
.map(([peerId, inbound, outbound]) => {
|
||||
relayMeshInfo.innerHTML += `${peerId}<br /><b>inbound</b>: ${inbound}\t<b>outbound</b>: ${outbound}`;
|
||||
relayMeshInfo.innerHTML += "<br /><br />";
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "Relay direct chat",
|
||||
"description": "Send messages between several users (or just one) using Relay with direct WebRTC connection.",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "favicon.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
}
|
||||
],
|
||||
"display": "standalone",
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff"
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "light",
|
||||
"version": "1.0.0",
|
||||
"description": "**Demonstrates**:",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "webpack --config webpack.config.js",
|
||||
"start": "webpack-dev-server"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@libp2p/webrtc": "^2.0.11",
|
||||
"@libp2p/websockets": "^6.0.3",
|
||||
"@waku/dns-discovery": "^0.0.15",
|
||||
"@waku/sdk": "^0.0.17",
|
||||
"libp2p": "^0.45.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"webpack": "^5.74.0",
|
||||
"webpack-cli": "^4.10.0",
|
||||
"webpack-dev-server": "^4.11.1"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,185 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
word-wrap: break-word;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
h3 + h4,
|
||||
div + h4,
|
||||
div + details,
|
||||
div + div {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.header div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header div input {
|
||||
min-width: 300px;
|
||||
min-height: 30px;
|
||||
width: 80%;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.header div button {
|
||||
min-width: 50px;
|
||||
min-height: 30px;
|
||||
width: 10%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.header div button + button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
details {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
details p {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
max-width: 100%;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
line-height: 1rem;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 800px;
|
||||
min-width: 300px;
|
||||
max-width: 800px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-content: space-between;
|
||||
}
|
||||
|
||||
#messages {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.message + .message {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.message :first-child {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.message p + p {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.message span {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.inputArea {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
flex-grow: 1;
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#send {
|
||||
background-color: #32d1a0;
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
#send:hover {
|
||||
background-color: #3abd96;
|
||||
}
|
||||
#send:active {
|
||||
background-color: #3ba183;
|
||||
}
|
||||
|
||||
#exit {
|
||||
color: white;
|
||||
border: none;
|
||||
background-color: #ff3a31;
|
||||
}
|
||||
#exit:hover {
|
||||
background-color: #e4423a;
|
||||
}
|
||||
#exit:active {
|
||||
background-color: #c84740;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #3ba183;
|
||||
}
|
||||
|
||||
.progress {
|
||||
color: #9ea13b;
|
||||
}
|
||||
|
||||
.terminated {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #c84740;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-self: flex-end;
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||
const path = require("path");
|
||||
|
||||
module.exports = {
|
||||
entry: "./index.js",
|
||||
output: {
|
||||
path: path.resolve(__dirname, "build"),
|
||||
filename: "index.js",
|
||||
},
|
||||
experiments: {
|
||||
asyncWebAssembly: true,
|
||||
},
|
||||
mode: "development",
|
||||
plugins: [
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
"index.html",
|
||||
"favicon.ico",
|
||||
"favicon.png",
|
||||
"manifest.json",
|
||||
"style.css",
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
Loading…
Reference in New Issue