Use Gleam to implement a Socket.IO server on Deno
Every now and then someone approaches you with a simple idea where you wonder how simple it would be to implement, but also lets your creative juices flow. This is what happened to me recently, when a coworker mentioned wanting to implement Excalidraw in an internal application we run. This application is a simple virtual office video platform, and he wanted to include Excalidraw on a per room basis. This would require extending or re-implementing the server side component. We're both the types of developers that like to experiment with new and exciting technologies, and we've both been interested in Gleam. For those that don't know, Gleam is a relatively new statically typed programming language that runs on the Erlang VM called BEAM. From my playing around with it, it hits an incredible sweet spot in the functional programming space with modern tooling, ease of use, and a simple overall language with minimal "magic". The other thing, and more relevant for this specific project, is that it can also compile to JavaScript.
Since the idea was to reimplement the Excalidraw server, we specifically needed something that supports Socket.IO, as that's the realtime communication technology used by the Excalidraw client. There exists a barebones Node.js project that showcases the minimal setup needed on GitHub, but neither one of us are interested in writing JavaScript or using Node.js. However, looking at the list of "official" server implementations, none existed for a language we might want to use, except for Rust maybe. side note: I want to reiterate that we're simply trying to experiment with fun and different technologies. Those are all perectly fine technologies to use for this project. This left me with the idea of utilizing the Gleam's ability to compile to JavaScript, and using the Socket.IO deno server implementation to create our server. I'd like to share my findings as I wrote a dead simple chat application using this stack. We need two main component for this: a web server that runs on Deno, and bindings to the Socket.IO Deno library
TL;DR Here's the code
Glen, the Gleam web framework for the Deno (and others) runtime
Glen is a web framework that is similar to Wisp, but targeting specifically, but not exclusively, the Deno runtime. As I was already familiar with Wisp, getting started with Glen was incredibly simple. The main difference is the use of the Gleam version of JavaScript's promise
. The other thing to keep in mind is the Glen has its own types for an HTTP Request and Response. But Glen also provides convenience functions to convert them into the standard JavaScript HTTP Request and Response as provided by the Conversation package for Gleam. Setting up the rest of the Glen web server was simple as we're not interested in anything like proper validation, nor do we have any business logic. The only other thing we need here is a dead simple HTML response to render a simple form that allows the user to enter and send their chat message. I implemented this simply with a string as, once again, we don't need anything fancy.
There was need to write a FFI function in Gleam to specifically wrap the Deno.serve
function, as I needed to bind the Socket.IO handler into the Deno handler. More on this later.
Socket.IO-deno, and writing bindings to make it useable from Gleam
Socket.IO provides an official Deno library. This is what we'll use and write some minimal bindings for our usecase. There are only two main components that we need. If we take a look at the official getting started guide, we'll find this code snippet:
const io = new Server();
io.on("connection", (socket) => {
console.log(`socket ${socket.id} connected`);
socket.emit("hello", "world");
socket.on("disconnect", (reason) => {
console.log(`socket ${socket.id} disconnected due to ${reason}`);
});
});
There we can already see the two types. the main Server
, as well as the Socket
types. We can define these in Gleam as follows:
pub type Socket {
Socket(
id: Int,
emit: fn(String, String) -> Nil,
on: fn(String, fn(String) -> Nil) -> Nil,
)
}
pub type Server
That's really it. For e.g. the Server, the binding looks like the following:
@external(javascript, "./socketIo.ffi.mjs", "socketIoServer")
pub fn socket_io_server() -> Server
with the javascript side of things looking like:
// socketIo.ffi.mjs
import { Server } from "https://deno.land/x/socket_io@0.2.1/mod.ts";
export function socketIoServer() {
return new Server();
}
It's similary simple with the Socket
type. An interesting thing to keep in mind is how we expose these types to the consuming Gleam modules. Where things in the JavaScript world are mostly object oriented, Gleam is almost exclusivley functional in its preferred programming paradigm. This mean in the Gleam module where we expose the Server
type, any other functions that use are methods on the Server
object in JavaScript must also accept a reference to the server
object. Something along the lines of this:
@external(javascript, "./socketIo.ffi.mjs", "on")
fn on_ffi(server: Server, message: String, cb: fn(Socket) -> Nil) -> Nil
pub fn on(server: Server, message: String, cb: fn(Socket) -> Nil) -> Server {
on_ffi(server, message, cb)
server
}
export function on(server, msg, cb) {
server.on(msg, cb);
}
You'll notice that the public function returns the server
so that we can continue to pipe it to additional operations. But in the JavaScript side of things, we take the passed in server
argument, and then call the on
method on it with the provided message and callback. It's all really easier than I originally thought. Using this pattern, we can continue to provide bindings to the various features we need for the Socket.IO-deno library. You can check the file here
Putting it together
Once we have enough bindings to cover the functionality we need, putting it together is as simple as:
pub fn handler(handler: fn(JsRequest) -> Promise(JsResponse)) {
let server = interop.socket_io_server()
server
|> interop.on("connection", fn(socket: Socket) {
socket
|> interop.socket_on("newMessageFromClient", fn(msg) {
let msg = socket.id |> int.to_string() <> " sent: " <> msg
server |> interop.emit("newMessageFromServer", msg)
Nil
})
Nil
})
|> interop.handler_with_additional(handler)
}
This sets up the most basic Socket.IO server to relay messages from one connected client to all other connected clients. Any functionality such as rooms would have to be built on top of this, but that's nothing that Socket.IO has ever been about. That stuff can be implemented all exclusively within Gleam. The most interesting bit here is that we need to use the Socket.IO override that allows it to also handle the "usual" Deno requests, since it's also supposed to act as the actual web server. This is done with another dead simple FFI call that wraps the Deno.serve
function. Ultimately, we also need to convert the generic JavaScript request into one that Glen understands. This is the Request and Response conversion as mentione in the beginning. This is also the main
function that serve as the entrypoint for the entire application.
pub fn main() {
let socket_io_handler = socket_io.handler(glen_handler)
socket_io_handler
|> deno_serve.serve(port: 8181)
}
fn glen_handler(req: JsRequest) -> Promise(JsResponse) {
web.handle_req(glen.convert_request(req))
|> promise.await(fn(f) { glen.convert_response(f) |> promise.resolve() })
}
Once again, check out the source repository here: https://codeberg.org/CubeOfShame/glen_socket for the full project. This served as a proof of concept for our silly idea of using fun technology to create a Socket.IO server for Excalidraw. We would have much preferred to be able to use Gleam on the actual BEAM instead of transpiling to JavaScript, but that's for another day.