1
0
Fork 0
cl-sites/lispcookbook.github.io/cl-cookbook/websockets.html

433 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta name="generator" content=
"HTML Tidy for HTML5 for Linux version 5.2.0">
<title>WebSockets</title>
<meta charset="utf-8">
<meta name="description" content="A collection of examples of using Common Lisp">
<meta name="viewport" content=
"width=device-width, initial-scale=1">
<link rel="icon" href=
"assets/cl-logo-blue.png"/>
<link rel="stylesheet" href=
"assets/style.css">
<script type="text/javascript" src=
"assets/highlight-lisp.js">
</script>
<script type="text/javascript" src=
"assets/jquery-3.2.1.min.js">
</script>
<script type="text/javascript" src=
"assets/jquery.toc/jquery.toc.min.js">
</script>
<script type="text/javascript" src=
"assets/toggle-toc.js">
</script>
<link rel="stylesheet" href=
"assets/github.css">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
</head>
<body>
<h1 id="title-xs"><a href="index.html">The Common Lisp Cookbook</a> &ndash; WebSockets</h1>
<div id="logo-container">
<a href="index.html">
<img id="logo" src="assets/cl-logo-blue.png"/>
</a>
<div id="searchform-container">
<form onsubmit="duckSearch()" action="javascript:void(0)">
<input id="searchField" type="text" value="" placeholder="Search...">
</form>
</div>
<div id="toc-container" class="toc-close">
<div id="toc-title">Table of Contents</div>
<ul id="toc" class="list-unstyled"></ul>
</div>
</div>
<div id="content-container">
<h1 id="title-non-xs"><a href="index.html">The Common Lisp Cookbook</a> &ndash; WebSockets</h1>
<!-- Announcement we can keep for 1 month or more. I remove it and re-add it from time to time. -->
<!-- <p class="announce"> -->
<!-- 📢 🤶 ⭐ -->
<!-- <a style="font-size: 120%" href="https://www.udemy.com/course/common-lisp-programming/?couponCode=LISPY-XMAS2023" title="This course is under a paywall on the Udemy platform. Several videos are freely available so you can judge before diving in. vindarel is (I am) the main contributor to this Cookbook."> Discover our contributor's Lisp course with this Christmas coupon.</a> -->
<!-- <strong> -->
<!-- Recently added: 18 videos on MACROS. -->
<!-- </strong> -->
<!-- <a style="font-size: 90%" href="https://github.com/vindarel/common-lisp-course-in-videos/">Learn more</a>. -->
<!-- </p> -->
<p class="announce">
📢 New videos: <a href="https://www.youtube.com/watch?v=h_noB1sI_e8">web dev demo part 1</a>, <a href="https://www.youtube.com/watch?v=xnwc7irnc8k">dynamic page with HTMX</a>, <a href="https://www.youtube.com/watch?v=Zpn86AQRVN8">Weblocks demo</a>
</p>
<p class="announce-neutral">
📕 <a href="index.html#download-in-epub">Get the EPUB and PDF</a>
</p>
<div id="content"
<p>The Common Lisp ecosystem boasts a few approaches to building WebSocket servers.
First, there is the excellent
<a href="https://github.com/joaotavora/hunchensocket">Hunchensocket</a> that is written as
an extension to <a href="https://edicl.github.io/hunchentoot/">Hunchentoot</a>, the classic
web server for Common Lisp. I have used both and I find them to be wonderful.</p>
<p>Today, however, you will be using the equally excellent
<a href="https://github.com/fukamachi/websocket-driver">websocket-driver</a> to build a WebSocket server with
<a href="https://github.com/fukamachi/clack">Clack</a>. The Common Lisp web development community has expressed a
slight preference for the Clack ecosystem because Clack provides a uniform interface to
a variety of backends, including Hunchentoot. That is, with Clack, you can pick and choose the
backend you prefer.</p>
<p>In what follows, you will build a simple chat server and connect to it from a
web browser. The tutorial is written so that you can enter the code into your
REPL as you go, but in case you miss something, the full code listing can be found at the end.</p>
<p>As a first step, you should load the needed libraries via quicklisp:</p>
<pre><code class="language-lisp">
(ql:quickload '(clack websocket-driver alexandria))
</code></pre>
<h2 id="the-websocket-driver-concept">The websocket-driver Concept</h2>
<p>In websocket-driver, a WebSocket connection is an instance of the <code>ws</code> class,
which exposes an event-driven API. You register event handlers by passing your
WebSocket instance as the second argument to a method called <code>on</code>. For example,
calling <code>(on :message my-websocket #'some-message-handler)</code> would invoke
<code>some-message-handler</code> whenever a new message arrives.</p>
<p>The <code>websocket-driver</code> API provides handlers for the following events:</p>
<ul>
<li><code>:open</code>: When a connection is opened. Expects a handler with zero arguments.</li>
<li><code>:message</code> When a message arrives. Expects a handler with one argument, the message received.</li>
<li><code>:close</code> When a connection closes. Expects a handler with two keyword args, a
“code” and a “reason” for the dropped connection.</li>
<li><code>:error</code> When some kind of protocol level error occurs. Expects a handler with
one argument, the error message.</li>
</ul>
<p>For the purposes of your chat server, you will want to handle three cases: when
a new user arrives to the channel, when a user sends a message to the channel,
and when a user leaves.</p>
<h2 id="defining-handlers-for-chat-server-logic">Defining Handlers for Chat Server Logic</h2>
<p>In this section you will define the functions that your event handlers will
eventually call. These are helper functions that manage the chat server logic.
You will define the WebSocket server in the next section.</p>
<p>First, when a user connects to the server, you need to give that user a nickname
so that other users know whose chats belong to whom. You will also need a data
structure to map individual WebSocket connections to nicknames:</p>
<pre><code class="language-lisp">
;; make a hash table to map connections to nicknames
(defvar *connections* (make-hash-table))
;; and assign a random nickname to a user upon connection
(defun handle-new-connection (con)
(setf (gethash con *connections*)
(format nil "user-~a" (random 100000))))
</code></pre>
<p>Next, when a user sends a chat to the room, the rest of the room should be
notified. The message that the server receives is prepended with the nickname of
the user who sent it.</p>
<pre><code class="language-lisp">
(defun broadcast-to-room (connection message)
(let ((message (format nil "~a: ~a"
(gethash connection *connections*)
message)))
(loop :for con :being :the :hash-key :of *connections* :do
(websocket-driver:send con message))))
</code></pre>
<p>Finally, when a user leaves the channel, by closing the browser tab or
navigating away, the room should be notified of that change, and the users
connection should be dropped from the <code>*connections*</code> table.</p>
<pre><code class="language-lisp">(defun handle-close-connection (connection)
(let ((message (format nil " .... ~a has left."
(gethash connection *connections*))))
(remhash connection *connections*)
(loop :for con :being :the :hash-key :of *connections* :do
(websocket-driver:send con message))))
</code></pre>
<h2 id="defining-a-server">Defining A Server</h2>
<p>Using Clack, a server is started by passing a function to <code>clack:clackup</code>. You
will define a function called <code>chat-server</code> that you will start by
calling <code>(clack:clackup #'chat-server :port 12345)</code>.</p>
<p>A Clack server function accepts a single plist as its argument. That plist
contains environment information about a request and is provided by the system.
Your chat server will not make use of that environment, but if you want to learn
more you can check out Clacks documentation.</p>
<p>When a browser connects to your server, a websocket will be instantiated and
handlers will be defined on it for each of the the events you want to support.
A WebSocket “handshake” will then be sent back to the browser, indicating
that the connection has been made. Heres how it works:</p>
<pre><code class="language-lisp">(defun chat-server (env)
(let ((ws (websocket-driver:make-server env)))
(websocket-driver:on :open ws
(lambda () (handle-new-connection ws)))
(websocket-driver:on :message ws
(lambda (msg)
(broadcast-to-room ws msg)))
(websocket-driver:on :close ws
(lambda (&amp;key code reason)
(declare (ignore code reason))
(handle-close-connection ws)))
(lambda (responder)
(declare (ignore responder))
;; Send the handshake:
(websocket-driver:start-connection ws))))
</code></pre>
<p>You may now start your server, running on port <code>12345</code>:</p>
<pre><code class="language-lisp">;; Keep the handler around so that
;; you can stop your server later on:
(defvar *chat-handler* (clack:clackup #'chat-server :port 12345))
</code></pre>
<h2 id="a-quick-html-chat-client">A Quick HTML Chat Client</h2>
<p>So now you need a way to talk to your server. Using Clack, define a simple
application that serves a web page to display and send chats. First the web page:</p>
<pre><code class="language-lisp">
(defvar *html*
"&lt;!doctype html&gt;
&lt;html lang=\"en\"&gt;
&lt;head&gt;
&lt;meta charset=\"utf-8\"&gt;
&lt;title&gt;LISP-CHAT&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;ul id=\"chat-echo-area\"&gt;
&lt;/ul&gt;
&lt;div style=\"position:fixed; bottom:0;\"&gt;
&lt;input id=\"chat-input\" placeholder=\"say something\" &gt;
&lt;/div&gt;
&lt;script&gt;
window.onload = function () {
const inputField = document.getElementById(\"chat-input\");
function receivedMessage(msg) {
let li = document.createElement(\"li\");
li.textContent = msg.data;
document.getElementById(\"chat-echo-area\").appendChild(li);
}
const ws = new WebSocket(\"ws://localhost:12345/chat\");
ws.addEventListener('message', receivedMessage);
inputField.addEventListener(\"keyup\", (evt) =&gt; {
if (evt.key === \"Enter\") {
ws.send(evt.target.value);
evt.target.value = \"\";
}
});
};
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
")
(defun client-server (env)
(declare (ignore env))
`(200 (:content-type "text/html")
(,*html*)))
</code></pre>
<p>You might prefer to put the HTML into a file, as escaping quotes is kind of annoying.
Keeping the page data in a <code>defvar</code> was simpler for the purposes of this
tutorial.</p>
<p>You can see that the <code>client-server</code> function just serves the HTML content. Go
ahead and start it, this time on port <code>8080</code>:</p>
<pre><code class="language-lisp">(defvar *client-handler* (clack:clackup #'client-server :port 8080))
</code></pre>
<h2 id="check-it-out">Check it out!</h2>
<p>Now open up two browser tabs and point them to <code>http://localhost:8080</code> and you
should see your chat app!</p>
<p><img src="assets/sockets-lisp-chat.gif" width="470" height="247" alt="Chat app demo between two browser windows" /></p>
<h2 id="all-the-code">All The Code</h2>
<pre><code class="language-lisp">(ql:quickload '(clack websocket-driver alexandria))
(defvar *connections* (make-hash-table))
(defun handle-new-connection (con)
(setf (gethash con *connections*)
(format nil "user-~a" (random 100000))))
(defun broadcast-to-room (connection message)
(let ((message (format nil "~a: ~a"
(gethash connection *connections*)
message)))
(loop :for con :being :the :hash-key :of *connections* :do
(websocket-driver:send con message))))
(defun handle-close-connection (connection)
(let ((message (format nil " .... ~a has left."
(gethash connection *connections*))))
(remhash connection *connections*)
(loop :for con :being :the :hash-key :of *connections* :do
(websocket-driver:send con message))))
(defun chat-server (env)
(let ((ws (websocket-driver:make-server env)))
(websocket-driver:on :open ws
(lambda () (handle-new-connection ws)))
(websocket-driver:on :message ws
(lambda (msg)
(broadcast-to-room ws msg)))
(websocket-driver:on :close ws
(lambda (&amp;key code reason)
(declare (ignore code reason))
(handle-close-connection ws)))
(lambda (responder)
(declare (ignore responder))
(websocket-driver:start-connection ws))))
(defvar *html*
"&lt;!doctype html&gt;
&lt;html lang=\"en\"&gt;
&lt;head&gt;
&lt;meta charset=\"utf-8\"&gt;
&lt;title&gt;LISP-CHAT&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;ul id=\"chat-echo-area\"&gt;
&lt;/ul&gt;
&lt;div style=\"position:fixed; bottom:0;\"&gt;
&lt;input id=\"chat-input\" placeholder=\"say something\" &gt;
&lt;/div&gt;
&lt;script&gt;
window.onload = function () {
const inputField = document.getElementById(\"chat-input\");
function receivedMessage(msg) {
let li = document.createElement(\"li\");
li.textContent = msg.data;
document.getElementById(\"chat-echo-area\").appendChild(li);
}
const ws = new WebSocket(\"ws://localhost:12345/\");
ws.addEventListener('message', receivedMessage);
inputField.addEventListener(\"keyup\", (evt) =&gt; {
if (evt.key === \"Enter\") {
ws.send(evt.target.value);
evt.target.value = \"\";
}
});
};
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
")
(defun client-server (env)
(declare (ignore env))
`(200 (:content-type "text/html")
(,*html*)))
(defvar *chat-handler* (clack:clackup #'chat-server :port 12345))
(defvar *client-handler* (clack:clackup #'client-server :port 8080))
</code></pre>
<p class="page-source">
Page source: <a href="https://github.com/LispCookbook/cl-cookbook/blob/master/websockets.md">websockets.md</a>
</p>
</div>
<script type="text/javascript">
// Don't write the TOC on the index.
if (window.location.pathname != "/cl-cookbook/") {
$("#toc").toc({
content: "#content", // will ignore the first h1 with the site+page title.
headings: "h1,h2,h3,h4"});
}
$("#two-cols + ul").css({
"column-count": "2",
});
$("#contributors + ul").css({
"column-count": "4",
});
</script>
<div>
<footer class="footer">
<hr/>
&copy; 2002&ndash;2023 the Common Lisp Cookbook Project
<div>
📹 Discover <a style="color: darkgrey; text-decoration: underline", href="https://www.udemy.com/course/common-lisp-programming/?referralCode=2F3D698BBC4326F94358">our contributor's Common Lisp video course on Udemy</a>
</div>
</footer>
</div>
<div id="toc-btn">T<br>O<br>C</div>
</div>
<script text="javascript">
HighlightLisp.highlight_auto({className: null});
</script>
<script type="text/javascript">
function duckSearch() {
var searchField = document.getElementById("searchField");
if (searchField && searchField.value) {
var query = escape("site:lispcookbook.github.io/cl-cookbook/ " + searchField.value);
window.location.href = "https://duckduckgo.com/?kj=b2&kf=-1&ko=1&q=" + query;
// https://duckduckgo.com/params
// kj=b2: blue header in results page
// kf=-1: no favicons
}
}
</script>
<script async defer data-domain="lispcookbook.github.io/cl-cookbook" src="https://plausible.io/js/plausible.js"></script>
</body>
</html>