Skip to content
File

Blob: samples/durable-objects-chat/chat.html

xml487 lines
1<!DOCTYPE html>
2<!--
3 THIS IS NOT THE INTERESTING FILE
4
5 This is just some UI code. There's nothing interesting and unique in this file. The interesting
6 thing about this demo is the server side, which is in chat.mjs.
7
8 WARNING: This was written by a systems engineer, not a web developer. It's probably bad.
9-->
10 
11<html>
12 <head>
13 <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
14<!--===================================================================================-->
15<!-- Inline style to avoid an extra round trip before the page can render. -->
16<style type="text/css">
17* {
18 box-sizing: border-box;
19}
20body {
21 font-family: Arial, Helvetica, sans-serif;
22}
23 
24#chatlog {
25 position: fixed;
26 top: 0;
27 bottom: 32px;
28 left: 0;
29 right: 200px;
30 overflow-y: auto;
31 padding: 8px;
32 overflow-wrap: break-word;
33}
34#chatlog span.username {
35 font-weight: bold;
36}
37#spacer {
38 height: calc(100vh - 32px - 5em);
39}
40 
41#roster {
42 font-weight: bold;
43 padding: 8px;
44}
45 
46p {
47 margin-top: 0;
48 margin-bottom: 8px;
49}
50p:last-of-type {
51 margin: 0;
52}
53 
54#roster {
55 position: fixed;
56 right: 0;
57 top: 0;
58 bottom: 32px;
59 width: 200px;
60 border-left: none;
61}
62 
63::-webkit-scrollbar {
64 display: none;
65}
66 
67@media(max-width:600px) {
68 #roster { display: none; }
69 #chatlog { right: 0; }
70}
71 
72#chat-input {
73 position: fixed;
74 width: 100%;
75 height: 32px;
76 bottom: 0;
77 left: 0;
78 border: none;
79 border-top: none;
80 padding-left: 32px;
81 outline: none;
82}
83#chatroom::before {
84 z-index: 1;
85 display: block;
86 content: ">";
87 position: fixed;
88 bottom: 0;
89 left: 0;
90 width: 32px;
91 height: 32px;
92 line-height: 32px;
93 text-align: center;
94 font-weight: bold;
95 color: #888;
96 -webkit-text-stroke-width: 2px;
97}
98 
99#name-form {
100 position: fixed;
101 z-index: 3;
102 top: 0;
103 bottom: 0;
104 left: 0;
105 right: 0;
106 background-color: white;
107}
108 
109#name-input {
110 position: fixed;
111 font-size: 200%;
112 top: calc(50% - 1em);
113 left: calc(50% - 8em);
114 width: 16em;
115 height: 2em;
116 margin: 0;
117 text-align: center;
118 border: 1px solid #bbb;
119}
120 
121#name-form p {
122 position: fixed;
123 top: calc(50% + 3em);
124 width: 100%;
125 text-align: center;
126}
127 
128#room-form {
129 position: fixed;
130 z-index: 2;
131 top: 0;
132 bottom: 0;
133 left: 0;
134 right: 0;
135 background-color: white;
136 font-size: 200%;
137 margin-top: calc(50vh - 3em);
138 text-align: center;
139}
140 
141#room-name {
142 font-size: inherit;
143 border: 1px solid #bbb;
144 height: 2em;
145 width: 16em;
146 padding-left: 1em;
147}
148 
149#room-form button {
150 font-size: inherit;
151 border: 1px solid #bbb;
152 background-color: #eee;
153 height: 2em;
154}
155 
156@media(max-width:660px) {
157 #name-input, #room-form { font-size: 150%; }
158 #name-form p { font-size: 75%; }
159}
160@media(max-width:500px) {
161 #name-input, #room-form { font-size: 100%; }
162 #name-form p { font-size: 50%; }
163}
164 
165#go-public {
166 width: 4em;
167}
168#go-private {
169 width: 20em;
170}
171 
172</style>
173 
174<!--===================================================================================-->
175<!-- The actual HTML. There is not much of it. -->
176 
177 </head>
178 <body>
179 <form id="name-form" action="/fake-form-action">
180 <input id="name-input" placeholder="your name">
181 <p>This chat runs entirely on the edge, powered by<br>
182 <a href="https://blog.cloudflare.com/introducing-workers-durable-objects" target="_blank">Cloudflare Workers Durable Objects</a></p>
183 </form>
184 <form id="room-form" action="/fake-form-action">
185 <p>Enter a public room:</p>
186 <input id="room-name" placeholder="room name"><button id="go-public">Go &raquo;</button>
187 <p>OR</p>
188 <button id="go-private">Create a Private Room &raquo;</button>
189 </form>
190 <form id="chatroom" action="/fake-form-action">
191 <div id="chatlog">
192 <div id="spacer"></div>
193 </div>
194 <div id="roster"></div>
195 <input id="chat-input">
196 </form>
197 </body>
198 
199<!--===================================================================================-->
200<!-- Client-side JavaScript code for the app. -->
201 
202<script type="text/javascript">
203let currentWebSocket = null;
204
205let nameForm = document.querySelector("#name-form");
206let nameInput = document.querySelector("#name-input");
207let roomForm = document.querySelector("#room-form");
208let roomNameInput = document.querySelector("#room-name");
209let goPublicButton = document.querySelector("#go-public");
210let goPrivateButton = document.querySelector("#go-private");
211let chatroom = document.querySelector("#chatroom");
212let chatlog = document.querySelector("#chatlog");
213let chatInput = document.querySelector("#chat-input");
214let roster = document.querySelector("#roster");
215
216// Is the chatlog scrolled to the bottom?
217let isAtBottom = true;
218
219let username;
220let roomname;
221
222let hostname = window.location.host;
223if (hostname == "") {
224 // Probably testing the HTML locally.
225 hostname = "edge-chat-demo.cloudflareworkers.com";
226}
227
228function startNameChooser() {
229 nameForm.addEventListener("submit", event => {
230 event.preventDefault();
231 username = nameInput.value;
232 if (username.length > 0) {
233 startRoomChooser();
234 }
235 });
236
237 nameInput.addEventListener("input", event => {
238 if (event.currentTarget.value.length > 32) {
239 event.currentTarget.value = event.currentTarget.value.slice(0, 32);
240 }
241 });
242
243 nameInput.focus();
244}
245
246function startRoomChooser() {
247 nameForm.remove();
248
249 if (document.location.hash.length > 1) {
250 roomname = document.location.hash.slice(1);
251 startChat();
252 return;
253 }
254
255 roomForm.addEventListener("submit", event => {
256 event.preventDefault();
257 roomname = roomNameInput.value;
258 if (roomname.length > 0) {
259 startChat();
260 }
261 });
262
263 roomNameInput.addEventListener("input", event => {
264 if (event.currentTarget.value.length > 32) {
265 event.currentTarget.value = event.currentTarget.value.slice(0, 32);
266 }
267 });
268
269 goPublicButton.addEventListener("click", event => {
270 roomname = roomNameInput.value;
271 if (roomname.length > 0) {
272 startChat();
273 }
274 });
275
276 goPrivateButton.addEventListener("click", async event => {
277 roomNameInput.disabled = true;
278 goPublicButton.disabled = true;
279 event.currentTarget.disabled = true;
280
281 let response = await fetch("https://" + hostname + "/api/room", {method: "POST"});
282 if (!response.ok) {
283 alert("something went wrong");
284 document.location.reload();
285 return;
286 }
287
288 roomname = await response.text();
289 startChat();
290 });
291
292 roomNameInput.focus();
293}
294
295function startChat() {
296 roomForm.remove();
297
298 // Normalize the room name a bit.
299 roomname = roomname.replace(/[^a-zA-Z0-9_-]/g, "").replace(/_/g, "-").toLowerCase();
300
301 if (roomname.length > 32 && !roomname.match(/^[0-9a-f]{64}$/)) {
302 addChatMessage("ERROR", "Invalid room name.");
303 return;
304 }
305
306 document.location.hash = "#" + roomname;
307
308 chatInput.addEventListener("keydown", event => {
309 if (event.keyCode == 38) {
310 // up arrow
311 chatlog.scrollBy(0, -50);
312 } else if (event.keyCode == 40) {
313 // down arrow
314 chatlog.scrollBy(0, 50);
315 } else if (event.keyCode == 33) {
316 // page up
317 chatlog.scrollBy(0, -chatlog.clientHeight + 50);
318 } else if (event.keyCode == 34) {
319 // page down
320 chatlog.scrollBy(0, chatlog.clientHeight - 50);
321 }
322 });
323
324 chatroom.addEventListener("submit", event => {
325 event.preventDefault();
326
327 if (currentWebSocket) {
328 currentWebSocket.send(JSON.stringify({message: chatInput.value}));
329 chatInput.value = "";
330
331 // Scroll to bottom whenever sending a message.
332 chatlog.scrollBy(0, 1e8);
333 }
334 });
335
336 chatInput.addEventListener("input", event => {
337 if (event.currentTarget.value.length > 256) {
338 event.currentTarget.value = event.currentTarget.value.slice(0, 256);
339 }
340 });
341
342 chatlog.addEventListener("scroll", event => {
343 isAtBottom = chatlog.scrollTop + chatlog.clientHeight >= chatlog.scrollHeight;
344 });
345
346 chatInput.focus();
347 document.body.addEventListener("click", event => {
348 // If the user clicked somewhere in the window without selecting any text, focus the chat
349 // input.
350 if (window.getSelection().toString() == "") {
351 chatInput.focus();
352 }
353 });
354
355 // Detect mobile keyboard appearing and disappearing, and adjust the scroll as appropriate.
356 if('visualViewport' in window) {
357 window.visualViewport.addEventListener('resize', function(event) {
358 if (isAtBottom) {
359 chatlog.scrollBy(0, 1e8);
360 }
361 });
362 }
363
364 join();
365}
366
367let lastSeenTimestamp = 0;
368let wroteWelcomeMessages = false;
369
370function join() {
371 // If we are running via wrangler dev, use ws:
372 const wss = document.location.protocol === "http:" ? "ws://" : "wss://";
373 let ws = new WebSocket(wss + hostname + "/api/room/" + roomname + "/websocket");
374 let rejoined = false;
375 let startTime = Date.now();
376
377 let rejoin = async () => {
378 if (!rejoined) {
379 rejoined = true;
380 currentWebSocket = null;
381
382 // Clear the roster.
383 while (roster.firstChild) {
384 roster.removeChild(roster.firstChild);
385 }
386
387 // Don't try to reconnect too rapidly.
388 let timeSinceLastJoin = Date.now() - startTime;
389 if (timeSinceLastJoin < 10000) {
390 // Less than 10 seconds elapsed since last join. Pause a bit.
391 await new Promise(resolve => setTimeout(resolve, 10000 - timeSinceLastJoin));
392 }
393
394 // OK, reconnect now!
395 join();
396 }
397 }
398
399 ws.addEventListener("open", event => {
400 currentWebSocket = ws;
401
402 // Send user info message.
403 ws.send(JSON.stringify({name: username}));
404 });
405
406 ws.addEventListener("message", event => {
407 let data = JSON.parse(event.data);
408
409 if (data.error) {
410 addChatMessage(null, "* Error: " + data.error);
411 } else if (data.joined) {
412 let p = document.createElement("p");
413 p.innerText = data.joined;
414 roster.appendChild(p);
415 } else if (data.quit) {
416 for (let child of roster.childNodes) {
417 if (child.innerText == data.quit) {
418 roster.removeChild(child);
419 break;
420 }
421 }
422 } else if (data.ready) {
423 // All pre-join messages have been delivered.
424 if (!wroteWelcomeMessages) {
425 wroteWelcomeMessages = true;
426 addChatMessage(null,
427 "* This is a demo app built with Cloudflare Workers Durable Objects. The source code " +
428 "can be found at: https://github.com/cloudflare/workers-chat-demo");
429 addChatMessage(null,
430 "* WARNING: Participants in this chat are random people on the internet. " +
431 "Names are not authenticated; anyone can pretend to be anyone. The people " +
432 "you are chatting with are NOT Cloudflare employees. Chat history is saved.");
433 if (roomname.length == 64) {
434 addChatMessage(null,
435 "* This is a private room. You can invite someone to the room by sending them the URL.");
436 } else {
437 addChatMessage(null,
438 "* Welcome to #" + roomname + ". Say hi!");
439 }
440 }
441 } else {
442 // A regular chat message.
443 if (data.timestamp > lastSeenTimestamp) {
444 addChatMessage(data.name, data.message);
445 lastSeenTimestamp = data.timestamp;
446 }
447 }
448 });
449
450 ws.addEventListener("close", event => {
451 console.log("WebSocket closed, reconnecting:", event.code, event.reason);
452 rejoin();
453 });
454 ws.addEventListener("error", event => {
455 console.log("WebSocket error, reconnecting:", event);
456 rejoin();
457 });
458}
459
460function addChatMessage(name, text) {
461 let p = document.createElement("p");
462 if (name) {
463 let tag = document.createElement("span");
464 tag.className = "username";
465 tag.innerText = name + ": ";
466 p.appendChild(tag);
467 }
468 p.appendChild(document.createTextNode(text));
469
470 // Append the new chat line, making sure that if the chatlog was scrolled to the bottom
471 // before, it remains scrolled to the bottom, and otherwise the scroll position doesn't
472 // change.
473 //
474 // TODO(bug): At some point this scrolling logic bitrotted and `isAtBottom` is apparently not
475 // always true when it should be. No time to investigate now, so I'm hard-coding this to always
476 // scroll to bottom on a new message so it doesn't look broken.
477 chatlog.appendChild(p);
478 if (isAtBottom || true) {
479 chatlog.scrollBy(0, 1e8);
480 }
481}
482
483startNameChooser();
484</script>
485<!--===================================================================================-->
486</html>