Using Durable Objects as a WebSocket Session State Machine
How TermOnMac's Cloudflare Durable Object manages Mac and iPhone WebSocket slots, handles quota checks at connection time, and survives hibernation via tagged socket recovery.
Technical notes on building TermOnMac: cryptography, PTY sessions, Cloudflare Durable Objects, and the rest of a zero-knowledge Mac terminal relay.
How TermOnMac's Cloudflare Durable Object manages Mac and iPhone WebSocket slots, handles quota checks at connection time, and survives hibernation via tagged socket recovery.
Cloudflare Durable Objects hibernate when idle, losing in-memory state. How TermOnMac rebuilds WebSocket role mappings from storage-backed UUID tags on every wake.
Why TermOnMac's subscription tier lives in a per-user Durable Object instead of KV — serialized writes, lazy expiry, notification dedup, and stale downgrade protection.
TermOnMac charges 1 credit per relay message and 2 per minute of DO runtime. How the 5-hour window, welcome bonus, and split KV keys protect admin grants from stale writes.
TermOnMac caps concurrent Mac rooms per tier using a Cloudflare KV prefix scan with 15-minute TTLs — no counters, no cleanup job, reconnects always allowed.
Why TermOnMac uses a persistent identity key for TOFU and a per-connection ephemeral X25519 key for ECDH — so a stolen identity key cannot decrypt past recorded traffic.
How TermOnMac spawns a login shell via forkpty() on macOS — argv[0] tricks, controlling TTY setup, and why SHELL_SESSIONS_DISABLE matters for a programmatic PTY.
The four environment variables TermOnMac sets before execvp — TERM, LANG, SHELL_SESSIONS_DISABLE, TERMONMAC_SESSION — and why each matters for a programmatic PTY.
How TermOnMac reads from the PTY master FD without blocking a thread — O_NONBLOCK plus a DispatchSourceRead on the userInteractive queue, with EAGAIN-aware writes.
Why TermOnMac doesn't send PTY output on every read — a dual-trigger buffer plus a local terminal query interceptor that answers vim's cursor queries without a round trip.
Each TermOnMac PTY session keeps a per-session RingBuffer so the iPhone can replay the current terminal state after a network drop, with incremental offset-based delta delivery.
How TermOnMac wraps SwiftTerm's CustomTerminalView in a UIViewRepresentable for SwiftUI — feeding PTY bytes, handling resets on reconnect, and blocking accidental pastes.
How TermOnMac propagates iPhone terminal size changes to the Mac via TIOCSWINSZ — buffer switches, reinit callbacks, Ctrl-L prompt redraws, and debounced resize events.
iOS keyboards don't have Ctrl, Esc, or arrow keys. How TermOnMac builds a custom inputAccessoryView with debounced shortcut search, modifier toggles, and focus handling.
How TermOnMac keeps your reading position when you're scrolled up and new output arrives — saved scroll ratios, reset-before-replay, and local scroll shortcuts.
How TermOnMac's iOS app handles lost WebSocket connections — exponential backoff, NWPathMonitor-driven immediate reconnect, input buffering, and a 5-minute total timeout.
Why TermOnMac's Mac heartbeat flips between 30s and 1s during active use — Cloudflare DO hibernation, not dead-connection detection, is the real driver behind the cadence.
Why TermOnMac coalesces up to 20 encrypted payloads per WebSocket message — not for framing overhead, but because the relay charges 1 credit per batch instead of per payload.
How TermOnMac separates PTY session lifetime from iOS attachment — two session lists, buffer-only mode, draft input preservation, and full-remove destruction via ptyDestroy.
How TermOnMac handles two iOS devices racing to attach to the same PTY — an isTakenOver flag, explicit takeover callbacks, and relay-enforced single-iOS-socket per room.
How TermOnMac's short-lived CLI talks to the long-running helper daemon — a chmod 0600 Unix socket with a length-prefixed JSON protocol and explicit IPC versioning.
How TermOnMac links GitHub, Google, and Apple logins to a single user by email — KV key layout, account linking, pending-delete reversal, and email index backfill.
TermOnMac's auth model uses 128-bit API keys with 30-day sliding TTLs and 512-bit refresh tokens that rotate on use, with a 5-minute grace period for client crash recovery.
How TermOnMac uses a single-use QR pairing token plus a rotated room secret and TOFU identity key — so even a photographed QR code becomes useless after the legitimate pair completes.
StoreKit 2 JWS receipts carry an x5c cert chain, not a JWK. How TermOnMac hand-rolls a DER parser to extract SPKI and verify ECDSA P-256 signatures using only Web Crypto.
Three layers of protection against Apple's retrying, out-of-order App Store Server Notifications — UUID dedup, stale-downgrade rejection, and per-user DO serialization.
A concrete Cloudflare KV read-modify-write race that let stale Room DO flushes silently consume admin-issued quota grants — and the split-key plus version-marker fix.
Ten recurring patterns across the TermOnMac relay, Mac agent, and iOS app — strong consistency in DO, split KV keys, version markers, lazy expiry, and channel binding.
How an iPhone controls a Mac terminal over the internet — iOS app, Mac CLI daemon, Cloudflare Workers relay. End-to-end encrypted; the relay forwards ciphertext it cannot read.
QR pairing, persistent identity keys, and per-connection ephemeral keys. How two devices derive a shared AES-256-GCM session key without a central key server.
Why we don't use the raw X25519 shared secret as an AES key. HKDF-SHA256 with a versioned, nonce-salted construction gives per-connection key separation and protocol versioning.
An HMAC over both sorted ephemeral public keys — keyed by the room secret — prevents a compromised relay from substituting keys during the handshake.
The first identity key registered for a room is trusted. How TermOnMac rotates the room secret and replaces sockets without breaking the active session.