Subway
Overview
Throughout college my friends and I lived on IRC. Getting a desktop client running on every machine was a rite of passage, but it also meant slow UIs, no history when you switched devices, and zero mobile access. Subway started as my answer to that friction: keep the raw IRC protocol, but deliver it through the web.
I was just discovering the possibilities of
The goals were ambitious for 2012: persistent message history across devices, a plugin system that could inject new behaviors on the fly, fine-grained notification rules, and a UI that felt closer to HipChat or Campfire than irssi. Two years of on-and-off nights and weekends later, I had a fully working browser client that introduced a bunch of patterns we take for granted now.
The original hosted version is archived, so I rebuilt the experience below as a faithful simulation. It streams a real capture from the production server, restores history like the 2012 build, and spotlights the features that made Subway feel ahead of its time.
Details
Session Replay
Kick off the replay to see Subway’s socket negotiation, topic updates,
plugin actions, and reconnect logic play out in real time. Feel free to
type, switch channels, or restart with /replay
to see it all
again.
Tap through the reconstructed client to see how Subway handled socket negotiation, plugin actions, notifications, and connection drops—exactly as the original Node.js + Socket.io stack behaved.
From First Principles
Subway was never a skin on top of IRC. I treated the classic protocol as a transport, then rebuilt the surrounding ergonomics from the ground up. The replay above mirrors the exact flow the live system executed:
- Establish a socket, sync historical highlights, and promote the status badge only after both tasks finish.
- Project raw IRC traffic through a message bus that normalizes `PRIVMSG`, `JOIN`, `PART`, and `TOPIC` into structured events.
- Run the event through a plugin pipeline so features like `/me` or graphite alerts render inline without rewriting the core client.
- Persist the event, fan it out to every subscribed channel, and update unread counts atomically. Only then does the UI paint.
System Architecture
1. Node.js Gateway
A thin Node/Socket.io layer terminates WebSockets, authenticates users, and bridges the legacy IRC server. Every inbound IRC frame is translated into a typed event (`workflow.message`, `workflow.join`, …) before it ever touches the client.
2. Event Ledger
Events are appended to Redis + disk, tagged by channel and user pattern. Reconnections replay straight from this log, which is why the replay can hydrate instantly and still feel real.
3. Plugin Sandbox
Plugins run in a VM context with a very small API: inspect payloads, return transformed text, optionally register follow-up listeners. It let us bolt on graphite alerts, diffs, and custom highlights without redeploying the core client.
4. Web Client
The browser keeps nothing global. Each channel owns its unread count, presence roster, and message buffer. When the socket drops, the client queues outbound messages and replays missed highlights as soon as the gateway tells it we’re back online.
Event Envelope
Everything Subway did sits on a single data contract. The client never consumes raw IRC—only envelopes shaped like this:
1 | type SubwayEvent = { |
2 | id: string; |
3 | channel: string; // e.g. "#subway" |
4 | kind: "message" | "join" | "part" | "topic" | "notice"; |
5 | payload: Record<string, unknown>; |
6 | ts: number; // server timestamp |
7 | }; |
8 | |
9 | function toSubwayEvent(ircFrame) { |
10 | const { command, args, prefix } = ircFrame; |
11 | |
12 | switch (command) { |
13 | case "PRIVMSG": |
14 | return { |
15 | id: nanoid(), |
16 | channel: args[0], |
17 | kind: "message", |
18 | payload: { |
19 | author: prefix.nick, |
20 | text: args[1], |
21 | }, |
22 | ts: Date.now(), |
23 | }; |
24 | |
25 | case "TOPIC": |
26 | return { |
27 | id: nanoid(), |
28 | channel: args[0], |
29 | kind: "topic", |
30 | payload: { topic: args[1] }, |
31 | ts: Date.now(), |
32 | }; |
33 | |
34 | // …JOIN, PART, NOTICE map the same way |
35 | } |
36 | } |
Plugin Hooks
Plugins are tiny pure functions that receive the envelope, return the text to render, and optionally schedule side effects. That discipline kept the system reliable even when third-party code ran alongside it.
1 | export default function graphiteAlert(event, ctx) { |
2 | if (event.kind !== "notice") return event; |
3 | |
4 | const text = String(event.payload.text || ""); |
5 | if (!text.includes("graphite")) return event; |
6 | |
7 | ctx.desktop.notify({ |
8 | title: "Graphite", |
9 | body: text, |
10 | }); |
11 | |
12 | return { |
13 | ...event, |
14 | payload: { |
15 | ...event.payload, |
16 | text: '\u26a0\uFE0F ' + text, |
17 | }, |
18 | }; |
19 | } |