← BACK TO ENGINEERING
Runtime 12 min read

Porting Go's Wish to TypeScript: SSH Apps and Bubble Tea over SSH

ssh terminal.shop sells you coffee. ssh git.charm.sh shows you a repository browser. Neither runs a shell: the SSH server is the application. You get encryption, a login by public key and a client that is already installed everywhere, and you never touch HTTPS certificates or a browser.

In Go, these servers are built with Charm's wish. It wraps an SSH server in middleware, and its best-known middleware runs a Bubble Tea program for every person who connects.

Now it's available in TypeScript.

A chat room over SSH: alice joins, ana arrives and the member list shows her typing, her message lands; alice answers, ben joins and sends an action, and ana's reply that mentions alice is highlighted


I – What Shipped

@oakoliver/wish 1.0.0, a port of wish v2.0.5, the release for Bubble Tea v2.

npm install @oakoliver/wish
npm install @oakoliver/bubbletea @oakoliver/lipgloss   # for the Bubble Tea middleware

An SSH server with wish's options (host keys, public key, password and keyboard-interactive login, authorized_keys, user certificates, banners, idle and maximum timeouts) and all of its middleware: bubbletea, logging, activeterm, accesscontrol, ratelimiter, recover, elapsed, comment, git and scp, plus the testsession helpers. 4,468 lines of TypeScript, 154 tests, passing on Bun 1.4 and Node.js 26.

This one is not zero-dependency, unlike the other ports in the series. Each of those reimplements a Go library on top of Node's standard library. Wish sits on an SSH implementation (charm.land/ssh, a fork of gliderlabs/ssh, on golang.org/x/crypto/ssh), and writing key exchange, ciphers, MACs and host-key signatures again would be a large, security-sensitive project of its own. The port uses ssh2, the SSH library most Node.js projects already use, as its only runtime dependency. @oakoliver/bubbletea and @oakoliver/lipgloss are optional peer dependencies.


II – From Goroutines to an Event Loop

A Go wish server runs a goroutine per connection, and the handler for each session is a plain blocking function. When it returns, the session ends:

func(next ssh.Handler) ssh.Handler {
	return func(s ssh.Session) {
		wish.Println(s, "Hello, world!")
		next(s)
	}
}

In TypeScript every connection and every session is a set of event handlers on one event loop, and a handler may return a Promise. The session ends when the Promise settles: exit status 0 if it resolves, 1 if it rejects. The same middleware reads almost the same:

(next) => (session) => {
  Println(session, 'Hello, world!');
  return next(session);
};

The Session is the part that changed shape the most. In Go it is an io.ReadWriter over the SSH channel plus accessors. Here it is a Node.js Duplex stream over ssh2's channel, with the same accessors as methods (user(), command(), publicKey(), exit(code)). Being a stream is what lets the rest work unchanged: a Bubble Tea Program takes it as input and output, a child process pipes into it, and for await (const chunk of session) reads what the client sends.

Go's multi-value returns become tuples, as in the Bubble Tea port. pty, winCh, ok := s.Pty() is const [pty, windowChanges, ok] = s.pty(), and the window channel is an object you can subscribe to or iterate with for await. Go's ctx.Done() becomes ctx.signal, an AbortSignal that aborts when the connection closes, which you can hand straight to fetch or child_process.spawn.


III – One Program per Session

The Bubble Tea middleware is the reason most people use wish. In Go it creates a tea.Program per session with tea.WithInput(s), tea.WithOutput(s), the session's environment, the PTY size and a colour profile, and it forwards every window change as a WindowSizeMsg. The TypeScript Bubble Tea port takes the same options, so the core of the middleware is a direct translation. Three things did not carry over unchanged, all because one process now runs many programs.

Process signals. A Bubble Tea program in a terminal listens for SIGINT, SIGTERM and SIGWINCH on the process. On a server that would be wrong twice over: a server's SIGWINCH has nothing to do with any client's window, and a hundred programs would each register three listeners on process. MakeOptions turns the process handlers off with WithoutSignalHandler(); window sizes come from the SSH channel, and ctrl+c arrives as a key.

Suspend. Bubble Tea's Suspend command stops the process with SIGTSTP. Over SSH that would suspend the server and every session on it. Upstream filters SuspendMsg into ResumeMsg, and so does the port; in a test, a model returns Suspend and gets ResumeMsg back while the server keeps running.

Colour. In Go, Bubble Tea v2 downsamples its output to the terminal's colour profile. The TypeScript Bubble Tea port reports the profile to the model but writes colours as they are. The middleware therefore detects the profile from the session's environment (TERM, COLORTERM, NO_COLOR) and, when it is below TrueColor, wraps the output in Lip Gloss's ColorProfileWriter, which rewrites the escape sequences as they stream past. A client with TERM=xterm-256color gets 38;5;n colours; one that also sends COLORTERM=truecolor gets 38;2;r;g;b; NO_COLOR gets none. Tests check all three.

Programs are separate: a model whose update throws ends its own session (Bubble Tea catches the error), and a test checks that another session started before the crash keeps working and that a new one can connect afterwards.

A falling-blocks game over SSH; the panel on the right lists the AI player and alice with live scores, and alice's score enters the top ten when her game ends

The repository has two larger examples built on this: the chat room at the top of this article, and the falling-blocks game from the Bubble Tea examples served over SSH, with a panel of who is playing now and a shared leaderboard. Both use MiddlewareWithProgramHandler, which hands you each Program so that one session can send messages to the others.


IV – What ssh2 Leaves to You

ssh2 implements the protocol. Policy is the server's job, and some of what Go's x/crypto/ssh does for you, ssh2 hands to your code.

Checking the signature

Public key login has two steps. The client first asks whether a key would be accepted, then sends a signature made with it. x/crypto/ssh verifies that signature itself before your callback's answer counts. ssh2 passes the request to an authentication event with the signature and the signed data, and ctx.accept() lets the client in whether or not you checked anything. The port verifies every signature before accepting, and a test proves it: a client presents the victim's public key and signs with the attacker's private key, and the handler that would accept the victim's key never runs.

The same code path counts failed attempts. x/crypto/ssh allows six failed attempts per connection; ssh2 has no limit, so the port adds one (WithMaxAuthTries, default 6), with a test that makes a client keep guessing passwords and counts four attempts before the connection closes at a limit of three.

Certificates through a gap

WithTrustedUserCAKeys accepts OpenSSH user certificates signed by a known CA. ssh2 does not support certificates, but it does pass their bytes through: for an algorithm name it does not know, the request arrives with the certificate as the key and the signature still in its SSH framing. The port parses the certificate (PROTOCOL.certkeys), checks the CA's signature over it with node:crypto, checks the principals and the validity window, and then verifies the login signature against the key inside the certificate. Upstream's test certificates (valid, expired, wrong principal, another CA, no certificate) are reused, and real ssh with CertificateFile logs in with them.

Two checks are stricter than upstream: the certificate must be a user certificate, and its source-address critical option is enforced. Upstream's checker looks at neither.

A banner that knows your name

WithBannerHandler builds the pre-login banner from the user name: upstream's test expects "banner for fulano". ssh2 sends its banner when the client starts authentication, before any user name exists, and it has no other way to send one. The port writes the SSH_MSG_USERAUTH_BANNER packet itself on the first authentication request, through ssh2's packet writer. It is the one place where the port reaches into ssh2's internals.

Timeouts that hold

Idle and maximum timeouts are socket timers. One catch: ssh2 servers send a keepalive every 15 seconds by default, and that traffic would reset any idle timer longer than that. Go's server sends none. The port turns ssh2's keepalive off (it has no switch, so it sets the interval to the largest value setInterval accepts) and offers WithKeepAlive for people who want it. A handshake timeout closes connections that never log in, which nothing else does: a test opens a TCP connection and sends nothing: without the option it is still open 600 ms later, and with a 300 ms timeout it closes after about 300 ms.


V – The Flaky Test

Every port has a bug that teaches something. This one looked like a runtime bug and was not.

About one test server in 200 to 400 accepted TCP connections and then did nothing: the client connected, waited, and gave up with "Timed out while waiting for handshake". Under Bun the test simply failed; under Node's test runner a 400-iteration stress file hung. Because I first saw it under Bun, I spent a while suspecting Bun's sockets. The instruments said otherwise: the server object had never registered its listener at all.

Test servers get a throwaway host key, generated when the server starts. Once in a while ssh2 refused that key with "Malformed OpenSSH private key", so serve() rejected before it attached its connection handler. The test helper started serve() without waiting for it, as upstream's does, but unlike upstream's it ignored the error. The listening socket stayed open and accepted connections that nobody handled.

The key came from ssh2's own utils.generateKeyPairSync('ed25519'). Over 5,000 keys, ssh2 failed to parse 18 of its own on Node and 20 on Bun. One in 256 is 19.5 in 5,000. Every bad key had a 31-byte public key: when the first byte of an ed25519 public key is zero, the generator drops it, as if it were a big integer instead of 32 raw bytes.

WithHostKeyPath is how most people get their host key, so outside the tests this would have been a server that failed to start on one install in 256. The port now writes ed25519 keys with node:crypto and its own OpenSSH encoder (0 bad keys in 5,000, and ssh-keygen -y derives the same public key from them), checks the host keys before it binds the port, and the test helper reports a server that cannot start instead of hiding it. A test covers that last part.

The second runtime difference was real. Reading a session with for await and then writing a reply worked on Node and dropped the reply on Bun: on Bun, the end of the loop destroyed the stream, which closed the SSH channel before the exit status. Session now has its own async iterator that never destroys it, with a test on both runtimes.


VI – Test Parity

Upstream's tests start real servers and connect with Go's SSH client, or with git and ssh. The port does the same with ssh2's client, and with OpenSSH and Git where upstream does:

File Tests Ports
wish.test.ts 23 wish_test.go, options_test.go (banners, authorized_keys, certificates over real ssh)
cmd.test.ts 8 cmd_test.go
middleware.test.ts 22 accesscontrol, activeterm, comment, elapsed, logging, ratelimiter, recover, testsession
scp.test.ts 56 scp, including upstream's golden files, path traversal and symlink escapes
git.test.ts 12 git_test.go: real git push and git clone over OpenSSH
server.test.ts 21 new: timeouts, signature forgery, auth edge cases, isolation, shutdown
bubbletea.test.ts 12 new: window sizes, keys, crashes, colour profiles, suspend
Total 154

Under Bun they run with bun test in about 6.5 seconds. For Node, a small shim maps bun:test onto node:test and redirects the imports from src/ to the built dist/, so the same files test the package as it will be published, in about 7.3 seconds.


VII – What Is Missing

  • Real pseudo-terminals. Go's ssh.AllocatePty() gives a session a kernel PTY, so wish.Command(s, "vim") runs a full-screen program for the client. Node.js has no PTYs without a native add-on, so every PTY here is emulated, as with Go's ssh.EmulatePty(): the size, TERM and resizes are known and \n becomes \r\n. That is all Bubble Tea needs. Command runs programs on pipes, which suits line-oriented tools but not vim or a shell.
  • SFTP, port forwarding, agent forwarding, X11, the PROXY protocol and break requests. Forwarding requests are refused, which is also Go's default.
  • On Bun, chacha20-poly1305. Bun's node:crypto has no raw ChaCha20 cipher, so ssh2 does not offer it; OpenSSH clients pick AES-GCM instead. Asking for ChaCha20 explicitly (ssh -c chacha20-poly1305@openssh.com) fails against a Bun server and works against a Node one.

VIII – Some Numbers

Measured on an Apple Silicon Mac, with the run queued alone and the machine quiet:

  • Memory. A server running a clock model (one tick a second) grew from 105.6 MB to 122.0 MB of RSS on Node with 100 open Bubble Tea sessions, about 164 KB each; on Bun, from 52.2 MB to 72.7 MB, about 205 KB each.
  • Time to first frame. From connect() to the first byte of the first frame, 100 sequential sessions from an ssh2 client in another process: median 0.86 ms against the Node server, 0.52 ms against Bun.
  • A command with OpenSSH. ssh host cmd against a server that prints one line, wall time including starting ssh: median 14.7 ms (Node) and 13.9 ms (Bun) over 20 runs.

IX – Get It

npm install @oakoliver/wish

New to it? Start with Getting Started with Wish, which builds six small SSH apps and ends with running one safely.

Source code: github.com/oakoliver/wish. MIT licensed with attribution to Charmbracelet, Inc. Original Go library: wish by Charm. The SSH protocol comes from ssh2 by Brian White.


FAQ

Can I serve a Bubble Tea app over SSH from Node.js?

Yes. bubbletea.Middleware((session) => [model, []]) from @oakoliver/wish/bubbletea runs one program per SSH session, sized to the client's terminal, with the client's keys as input and a WindowSizeMsg on every resize.

Does it need OpenSSH on the server?

No. The server is your Node.js or Bun process, with ssh2 doing the protocol. Nobody gets a shell unless you write a handler that starts one. Only the Git middleware needs a binary on the server: git.

Is a wish server safe to put on the internet?

As safe as its configuration. A server without an authentication handler lets everyone in, as upstream does, and logs a warning when it listens beyond loopback that way. Use WithAuthorizedKeys, set the timeouts, add the rate limiter and run it as an unprivileged user; the getting-started guide has a section on exactly that.


The TypeScript Charm Series

Each package is a TypeScript port of one of Charm's Go tools, published on npm and running on Node.js and Bun.

Package What it does Start here How it was ported Source
@oakoliver/lipgloss Terminal styling Getting started Porting story GitHub
@oakoliver/glamour Markdown rendering Getting started Porting story GitHub
@oakoliver/bubbletea TUI framework Getting started Porting story GitHub
@oakoliver/bubbles TUI components Getting started Porting story GitHub
@oakoliver/huh Terminal forms Getting started Porting story GitHub
@oakoliver/glow Markdown reader Getting started Porting story GitHub
@oakoliver/gum Shell script prompts Getting started — GitHub
@oakoliver/vhs Terminal GIFs Getting started — GitHub
@oakoliver/wish SSH apps Getting started this article GitHub

– Antonio

"Simplicity is the ultimate sophistication."