Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# tobby Architecture

## System overview

tobby is a terminal IRC client with a local-first architecture:

- React/OpenTUI render layer for the terminal UI
- Zustand slices for UI, messages, servers, and IRC state
- a custom IRC client wrapper on top of the ObsidianIRC IRC core
- a transport seam that currently defaults to raw TCP/TLS, but can be replaced natively

The important design decision for transport evolution is this:

> the IRC layer should not need to know whether bytes came from plain TCP/TLS, a QUIC session, or a future identity-based native overlay.

## Current layering

```mermaid
graph TD
UI[React / OpenTUI UI]
Store[Zustand Store Slices]
IRCSlice[ircSlice event wiring]
Client[tobby IRCClient wrapper]
TransportFactory[SocketTransportFactory seam]
TcpSocket[NodeTCPSocket net/tls]
Network[IRC server]

UI --> Store
Store --> IRCSlice
IRCSlice --> Client
Client --> TransportFactory
TransportFactory --> TcpSocket
TcpSocket --> Network
```

## Why the transport seam matters

Historically, tobby created `NodeTCPSocket` directly inside `IRCClient.connect()`. That made the transport path effectively hardcoded.

Now the client asks a socket transport factory for an `ISocket` implementation.

That gives three benefits:

1. future native transport work can be integrated without rewriting the IRC client
2. tests can inject a fake transport cleanly
3. a downstream fork can experiment with a QUIC/identity overlay without making the upstream IRC logic depend on an external engine repository

## Native transport direction

The intended native evolution is not “replace IRC semantics”. It is:

- keep IRC framing and event handling where it is
- replace only the byte transport under the socket interface
- preserve a compatibility fallback to plain TCP/TLS

```mermaid
graph LR
Client[tobby IRCClient]
Factory[SocketTransportFactory]
Native[Native Overlay Transport]
Fallback[NodeTCPSocket TCP/TLS]
Peer[Remote endpoint]

Client --> Factory
Factory --> Native
Factory --> Fallback
Native --> Peer
Fallback --> Peer
```

## QUIC-oriented target shape

A future native transport can expose the same `ISocket` shape while internally using:

- QUIC connection setup
- unreliable datagrams for latency-sensitive traffic when appropriate
- bidirectional streams for reliable control data
- identity-bound session authorization before opening the data path

That should remain an internal implementation detail of the transport adapter, not something leaked into the store or UI layers.

## Integration rule

If a future transport cannot satisfy the existing `ISocket` contract cleanly, it should be wrapped in an adapter rather than forcing the IRC/UI layers to learn transport-specific behavior.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ To react, reply hit `Ctrl+Space` to enter message selection mode, navigate to th
- vim-like keybindings for navigation and message selection
- Multi-line support with collapsible messages
- Persistent config and chat history (SQLite)
- Native transport seam under the IRC client so future QUIC/identity overlays can be integrated without rewriting the UI/store layers

## Development

Expand All @@ -101,6 +102,8 @@ bun install
bun run dev
```

Architecture notes live in `ARCHITECTURE.md`.

Run tests and checks:

```sh
Expand Down
12 changes: 1 addition & 11 deletions src/lib/nodeTcpSocket.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,6 @@
import net from 'net'
import tls from 'tls'

interface ISocket {
onopen: (() => void) | null
onmessage: ((event: { data: string }) => void) | null
onerror: ((error: Error) => void) | null
onclose: (() => void) | null

send(data: string): void
close(): void
readyState: number
}
import type { ISocket } from './socketTypes'

export class NodeTCPSocket implements ISocket {
private socket: net.Socket | tls.TLSSocket
Expand Down
20 changes: 20 additions & 0 deletions src/lib/socketTransport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { NodeTCPSocket } from './nodeTcpSocket'
import type { ISocket, SocketTransportContext, SocketTransportFactory } from './socketTypes'

const defaultSocketTransportFactory: SocketTransportFactory = ({ url }) => new NodeTCPSocket(url)

let socketTransportFactory: SocketTransportFactory = defaultSocketTransportFactory

export function createSocketTransport(url: string): ISocket {
return socketTransportFactory({ url })
}

export function setSocketTransportFactory(factory: SocketTransportFactory): void {
socketTransportFactory = factory
}

export function resetSocketTransportFactory(): void {
socketTransportFactory = defaultSocketTransportFactory
}

export type { ISocket, SocketTransportContext, SocketTransportFactory }
16 changes: 16 additions & 0 deletions src/lib/socketTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export interface ISocket {
onopen: (() => void) | null
onmessage: ((event: { data: string }) => void) | null
onerror: ((error: Error) => void) | null
onclose: (() => void) | null

send(data: string): void
close(): void
readyState: number
}

export interface SocketTransportContext {
url: string
}

export type SocketTransportFactory = (context: SocketTransportContext) => ISocket
4 changes: 2 additions & 2 deletions src/utils/ircClient.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { IRCClient as BaseIRCClient, type EventMap } from '@irc/ircClient'
import { NodeTCPSocket } from '../lib/nodeTcpSocket'
import { createSocketTransport } from '../lib/socketTransport'
import { getRestrictions } from './restrictions'

/**
Expand Down Expand Up @@ -80,7 +80,7 @@ export class IRCClient extends BaseIRCClient {

const url = `${port === 6697 || port === 6679 ? 'ircs' : 'irc'}://${host}:${port}`

const nodeSocket = new NodeTCPSocket(url)
const nodeSocket = createSocketTransport(url)

const finalName = name?.trim() || host
const server: any = {
Expand Down
56 changes: 56 additions & 0 deletions tests/unit/socketTransport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, test, vi } from 'vitest'
import net from 'net'
import { NodeTCPSocket } from '../../src/lib/nodeTcpSocket'
import {
createSocketTransport,
resetSocketTransportFactory,
setSocketTransportFactory,
} from '../../src/lib/socketTransport'

describe('socketTransport', () => {
afterEach(() => {
resetSocketTransportFactory()
})

test('allows a custom socket transport to be injected', () => {
const fakeSocket = {
onopen: null,
onmessage: null,
onerror: null,
onclose: null,
send: vi.fn(),
close: vi.fn(),
readyState: 1,
}

const factory = vi.fn(() => fakeSocket)
setSocketTransportFactory(factory)

const socket = createSocketTransport('irc://example.com:6667')

expect(factory).toHaveBeenCalledWith({ url: 'irc://example.com:6667' })
expect(socket).toBe(fakeSocket)
})

test('reset restores the default TCP/TLS transport', () => {
const sentinel = vi.fn(() => {
throw new Error('custom transport should have been reset')
})
const fakeSocket = {
on: vi.fn().mockReturnThis(),
end: vi.fn(),
write: vi.fn(),
} as unknown as net.Socket

const connectSpy = vi.spyOn(net, 'connect').mockReturnValue(fakeSocket)

setSocketTransportFactory(sentinel)
resetSocketTransportFactory()

const socket = createSocketTransport('irc://127.0.0.1:65535')

expect(sentinel).not.toHaveBeenCalled()
expect(connectSpy).toHaveBeenCalledWith({ host: '127.0.0.1', port: 65535 })
expect(socket).toBeInstanceOf(NodeTCPSocket)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})