433 lines
16 KiB
HTML
433 lines
16 KiB
HTML
<!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> – 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> – 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 user’s
|
||
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 Clack’s 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. Here’s 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 (&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*
|
||
"<!doctype html>
|
||
|
||
<html lang=\"en\">
|
||
<head>
|
||
<meta charset=\"utf-8\">
|
||
<title>LISP-CHAT</title>
|
||
</head>
|
||
|
||
<body>
|
||
<ul id=\"chat-echo-area\">
|
||
</ul>
|
||
<div style=\"position:fixed; bottom:0;\">
|
||
<input id=\"chat-input\" placeholder=\"say something\" >
|
||
</div>
|
||
<script>
|
||
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) => {
|
||
if (evt.key === \"Enter\") {
|
||
ws.send(evt.target.value);
|
||
evt.target.value = \"\";
|
||
}
|
||
});
|
||
};
|
||
|
||
</script>
|
||
</body>
|
||
</html>
|
||
")
|
||
|
||
|
||
(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 (&key code reason)
|
||
(declare (ignore code reason))
|
||
(handle-close-connection ws)))
|
||
(lambda (responder)
|
||
(declare (ignore responder))
|
||
(websocket-driver:start-connection ws))))
|
||
|
||
(defvar *html*
|
||
"<!doctype html>
|
||
|
||
<html lang=\"en\">
|
||
<head>
|
||
<meta charset=\"utf-8\">
|
||
<title>LISP-CHAT</title>
|
||
</head>
|
||
|
||
<body>
|
||
<ul id=\"chat-echo-area\">
|
||
</ul>
|
||
<div style=\"position:fixed; bottom:0;\">
|
||
<input id=\"chat-input\" placeholder=\"say something\" >
|
||
</div>
|
||
<script>
|
||
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) => {
|
||
if (evt.key === \"Enter\") {
|
||
ws.send(evt.target.value);
|
||
evt.target.value = \"\";
|
||
}
|
||
});
|
||
};
|
||
|
||
</script>
|
||
</body>
|
||
</html>
|
||
")
|
||
|
||
(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/>
|
||
© 2002–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>
|