Creating a BBS in 2015
Although it fooled nobody, yesterday for April Fools' Day, Lobsters users that normally saw a boring list of story titles and links were greeted with a BBS-style interface to the site complete with story and comment browsing, private message reading and sending, and a multi-user chat area.
The BBS remains active at
https://lobste.rs/bbs (you can login as
"guest
").
The chat area was fairly active yesterday with Lobsters users and anonymous guests coming and going throughout the day, reminiscing about using BBSes some 20-30 years ago.
Quite a few people asked me how the whole system worked, and since I am not currently planning on releasing the source code (as it was largely a creative effort), I thought I would write up the technical details of the system.
Background
Most of the BBSes that I dialed into in the 90s were DOS-based systems with ASCII or 16-color ANSI text interfaces, which is what I was trying to replicate in my implementation. True to form, the backend of this BBS only sends out ASCII text and ANSI escape sequences for all screen drawing, coloring, and cursor positioning and each client terminal maintains its own screen buffer. The output from the server will look the same communicating with a telnet client, a dialup user calling in with RIPterm (if I had actually hooked a modem up to it), or my custom JavaScript terminal for this BBS.
While the Lobsters BBS had to be created in 2 weeks to be ready by
April 1st, much of the heavy lifting was done by things I had
already created years ago: a JavaScript ANSI code
interpreter and terminal, a
TrueType font version
of the
437 Codepage
used by PCs (and DOS in particular), a Markdown-to-ANSI-Code module
for Redcarpet, and an ANSI code utility library to simplify printing
things like output ANSI[:clear_line, :fg_red, :bold]
and
word-wrapping text while taking into account embedded ANSI escape
sequences.
I had written many of these things years ago for a terminal version of my personal website, which normally looks like this:
The terminal version of my site looked like this (the background image over which the terminal element was displayed is an old laptop):
However, since most of the people that read my website are just
family and friends that didn't appreciate
understand the keyboard-driven site, they kept telling me my site
was unusable. I even implemented wsmoused
-style mouse tracking
to be able to click on things instead of having to use a keyboard
to navigate over and hit enter. Eventually I just switched back to
the HTML version.
Since April Fools day 2015 was coming up, I resurrected those libraries and started on a new backend design in order to build the Lobsters BBS, which would hopefully have a much more technical audience to appreciate it.
Terminal Frontend
While BBSes in the 80s and 90s communicated over modems and serial ports, the Lobsters BBS would be accessed by a web browser and communicate over a WebSocket connection.
When the page is loaded, an 80x25 <pre>
element is present on
the page to act as the terminal, and my TrueType 437 font is loaded
to accurately display each 8x16 character like DOS would. Early
on I decided to go this route rather than doing all processing to a
<canvas>
element with a perfect-pixel font so that users would be
able to highlight/copy text in the browser and use things like the
browser's native "find in page" functionality.
The JavaScript terminal establishes a secure WebSocket connection
back to the server and receives each WebSocket frame containing raw
binary/ASCII input including ANSI codes for coloring text and
positioning the cursor.
The JavaScript then interprets each character and manipulates its
internal 80x25 array accordingly, builds a big HTML buffer of
<span>
s with certain classes for text colors, underline, etc. and
atomically swaps them into the <pre>
terminal on screen by
setting its innerHTML
.
It does this screen redrawing for each chunk of input from the
server, which can be a single cursor position request or an entire
screen of text.
To faithfully reproduce the slow modem speeds of yore, rather than
just do all of that processing as fast as the client can, each line
of input is stored in a buffer and a JavaScript timer processes one
line at a time with a configurable delay in between (I settled on
a 20ms delay).
The slow screen redrawing seen on the BBS is there on purpose, and
when the delay is set to zero, it will rebuild the <pre>
buffer
nearly instantly.
(This delay is especially noticeable on complex ANSI art, which can
be seen by pressing "a
" at the main menu of the BBS.)
Keyboard input from the user is sent back to the server
character-by-character through the WebSocket connection by
JavaScript binding to particular keydown/keypress
events.
The server maintains all input buffers for each connection,
allowing it to do things like handle text input longer than a
field's size and scroll the text side to side as the left and right
arrow keys are pressed. There are specific keyboard bindings in
the JavaScript to send keys like PageUp (^[[5~
),
Control+A (ASCII 1
), etc.
Since the Lobsters website sees a lot of traffic from users on
mobile devices, I wanted to make sure the new BBS was at least
usable for them as well.
While most of the JavaScript, CSS fonts, and WebSockets were also
usable on iOS/Android web browsers, there was the problem of
keyboard input.
Since there would be no input field present to cause the mobile
browser's on-screen keyboard to appear, a hidden <textarea>
is
placed at the top of the terminal, and focus is locked to it by
re-focusing it on its blur
event.
Since Mobile Safari won't automatically focus an input field on
page load and show the keyboard, a "Plug-In Keyboard" button is
shown to mobile devices using a CSS media query, which provides the
necessary touch event that allows the programmatic focus of the
<textarea>
.
Using a <textarea>
for receiving keyboard input also allowed me
to receive a paste
event, so users could just hit Control+V or
Command+V on the page and it could paste in a password when logging
in.
Now I had an ANSI-capable JavaScript terminal, a secure WebSocket connection established to the server which can receive ASCII/ANSI, and keyboard input from the user is being sent to the server.
Backend
The BBS server backend is a single Ruby process that uses EventMachine to handle input and output among all of the connected user sockets.
nginx, which normally proxies connections to the Lobsters Rails processes, was setup to proxy requests to the particular WebSocket path (requested via the frontend JavaScript terminal) to the EventMachine app. EventMachine handles the negotiation and upgrading which then presents the BBS server with plaintext input from the user.
Normally in an EventMachine app, as each message comes in from a different socket, one would just pass that message to the associated socket/user/session object and have it perform some action. With the BBS, there would be a ton of back-and-forth between the user and server, as each keystroke of input would require action on the server to manipulate the current input buffer in memory, redraw the text field, and output it all to the user's connection with proper ANSI codes to manipulate the cursor.
This model of entering the session object at a particular function was not suited well for this style of back-and-forth, since it would require re-building the state every time (or enter callback hell) and make it difficult for me to write. I wanted to be able to write code as if it was a single-threaded, single-user script running at a terminal blocking while reading from the TTY:
def field_input
buf = ""
while char = read_input
break if char == "\n"
buf << char
...
end
buf
end
def read_input
STDIN.read(1)
end
def login
render "welcome.ans"
output ANSI[:reset, :cursor_15_61]
username = field_input :length => 12
if username.strip == ""
return
end
output ANSI[:cursor_17_61]
password = field_input :length => 12, :password => true
...
To accomplish this in EventMachine, I turned to Ruby's Fibers. By creating a Fiber for each EventMachine connection/session, the session can pause in a blocking fashion (only to the session, not the EventMachine loop) until the main EventMachine loop wakes it up with new input which gets returned right where the input was requested rather than using callbacks.
The guts of the EventMachine manager look like this (with extraneous stuff removed):
EM.start_server(127.0.0.1, 8080, EM::WebSocket::Connection, {}) do |conn|
conn.onopen do |handshake|
...
n = BBSNodeManager.new_node
n.session = WebsocketSession.new(n)
n.fiber = Fiber.new
n.session.post_init
# we are now in the main loop of the bbs session,
# so we will never get here until it returns which
# only happens when the session has broken out of its
# main menu loop
end
n.fiber.resume
end
conn.onmessage do |msg|
n = BBSNodeManager.find_node(conn)
if n.alive?
n.fiber.resume(msg)
end
end
end
The WebSocket version of the BBS session class has a read_input
method which just looks like this:
def read_input
# this will block until EM wakes us up with Fiber.resume,
# passing the keyboard input to us as the result of Fiber.yield
return Fiber.yield
end
Now with an efficient way to get input from the user, the rest of the system can be written in a very top-down fashion.
def post_init
reset
splash
if !user
return close
end
welcome
while true
menu_loop
end
close
end
When the BBS server starts up, it loads in all of the ActiveRecord models from the Rails framework that existed for the normal Lobsters website to get easy access to user authentication, story lists, comments, etc., as well as share the same logic for external user notifications of new private messages sent through the BBS. Since we are basically single-threaded and only acting on one key input or screen output at a time, we only need one database connection (of course, if any queries are running slow, they will slow down everything).
There are actually two EventMachine modules loaded, one for
WebSockets (which all of the web-based users use) and another for
telnet. Since all of the output from the server is done using ANSI
codes, the same code that works in our JavaScript terminal should
work in an ANSI-capable terminal like xterm
or Terminal.app
which would be talking to the server over a direct TCP connection.
However, because the telnet interface still had some
negotiation-related bugs on April 1st, it was not enabled.
Chat Server
With all connections being handled by a single process, implementing
the chat server was fairly simple since it was just copying input
from one connection and storing it in each other chatting user's
session buffer to eventually be sent to their terminal (like an
old-school ircd
).
While I was able to get away with directly sending text to a user's
connection for things like BBSNodeManager.wall
(which spammed a
message to every logged-in user such as "The server is shutting down
for maintenance, please call back later."), the chat component
needed a bit more finesse. Because the main chat routine would be
blocking in read_input
waiting for a user to type something, new
chat events from other users would not be processed until the Fiber
woke up from input and was able to process its local buffer of new
chat text and precisely print each to the screen at the proper
location.
To solve this, when new chat input is available, the EventMachine manager just wakes up each Fiber that is chatting and sends it a particular type of message.
class BBSNodeManager
...
def self.chat_input(msg)
nodes.select{|n| n.chatting? }.each do |n|
n.session.chat_input.push(msg)
n.fiber.resume(ChatNotification.new)
end
end
Since all of those Fibers were each
blocking in read_input
, they will get that chat-indicator message
back as the result of Fiber.yield
, which it can then be passed
back up to field_input
instead of a keystroke. field_input
then
looks for that type of message, saves its input state, and returns
early to the chat routine which is looking for input. Then new
chat text is processed and it again runs field_input
which
restores its previously saved state until a keystroke or more
chat input.
def chat
node.chatting = true
chat_input.push({ :msg => "*** Welcome to Multi-User Chat" })
BBSNodeManager.chat_input({
:msg => "*** " << ANSI.escape(user.username) <<
" has joined chat" })
while true
if chat_input.any?
# output the new line of text in a particular place on the
# screen, moving all other chat text up the screen
...
end
output ANSI[:cursor_23_1, :reset, :bg_red] <<
(status bar ansi code here)
if chat_input.any?
next
end
input = field_input(:length => 77)
if input.is_a?(ChatNotification)
# chat_input has new stuff in it
next
else
# operate on input, parsing commands like /whois or
# sending it as chatter to BBSNodeManager.chat_input
# which will put it in each other chatting user's chat_input
# buffer and wake up those fibers for processing
...
end
end
I was pleasantly surprised at the lack of latency in this entire system even with dozens of users logged in and doing things like reading stories with pages full of text and chatting. Typed keys showed up nearly instantly (aside from the intentional JavaScript output buffering) and there was very little CPU usage in the single Ruby process.
Outro
So far there have been over 15,000 "calls" to the BBS in the past two days, although from the console it seemed like a lot of anonymous visitors could not figure out to try logging in as "guest" (though many tried to login as "jcs" for some reason).
Since April 1st is over, the BBS has moved off of the Lobsters
home page but remains accessible at
https://lobste.rs/bbs. I'm not sure what
to do with it now, especially since a lot of the excitement from
users died off within the first day. I suppose it is easier to
thumb through a webpage on your phone while doing a dozen other
things than to login to a system by hand and have to poke around
at every menu option each time.
Update: I've since expanded on this code and created a full-featured BBS that I am continuing to operate.