From a18dc393d6ff64e6ffb6ce9af0870bc80b2303ab Mon Sep 17 00:00:00 2001 From: louzt Date: Thu, 23 Apr 2026 15:07:32 -0600 Subject: [PATCH 1/2] refactor(transport): add native socket transport seam Replace the hardcoded NodeTCPSocket construction with an injectable socket transport factory so future native transport work can be integrated without making the IRC/UI layers depend on an external engine repository.\n\nAlso adds ARCHITECTURE.md with Mermaid diagrams and focused tests covering the new seam. --- ARCHITECTURE.md | 84 ++++++++++++++++++++++++++++++ README.md | 3 ++ src/lib/nodeTcpSocket.ts | 12 +---- src/lib/socketTransport.ts | 20 +++++++ src/lib/socketTypes.ts | 16 ++++++ src/utils/ircClient.ts | 4 +- tests/unit/socketTransport.test.ts | 45 ++++++++++++++++ 7 files changed, 171 insertions(+), 13 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 src/lib/socketTransport.ts create mode 100644 src/lib/socketTypes.ts create mode 100644 tests/unit/socketTransport.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..85a429d --- /dev/null +++ b/ARCHITECTURE.md @@ -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. \ No newline at end of file diff --git a/README.md b/README.md index b37f7ed..fbf38dd 100644 --- a/README.md +++ b/README.md @@ -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 @@ -101,6 +102,8 @@ bun install bun run dev ``` +Architecture notes live in `ARCHITECTURE.md`. + Run tests and checks: ```sh diff --git a/src/lib/nodeTcpSocket.ts b/src/lib/nodeTcpSocket.ts index 56bc801..287c40e 100644 --- a/src/lib/nodeTcpSocket.ts +++ b/src/lib/nodeTcpSocket.ts @@ -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 diff --git a/src/lib/socketTransport.ts b/src/lib/socketTransport.ts new file mode 100644 index 0000000..4a7d13a --- /dev/null +++ b/src/lib/socketTransport.ts @@ -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 } \ No newline at end of file diff --git a/src/lib/socketTypes.ts b/src/lib/socketTypes.ts new file mode 100644 index 0000000..cf556a6 --- /dev/null +++ b/src/lib/socketTypes.ts @@ -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 \ No newline at end of file diff --git a/src/utils/ircClient.ts b/src/utils/ircClient.ts index 760ad78..b9016a8 100644 --- a/src/utils/ircClient.ts +++ b/src/utils/ircClient.ts @@ -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' /** @@ -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 = { diff --git a/tests/unit/socketTransport.test.ts b/tests/unit/socketTransport.test.ts new file mode 100644 index 0000000..29a118c --- /dev/null +++ b/tests/unit/socketTransport.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +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', () => { + setSocketTransportFactory(() => { + throw new Error('custom transport should have been reset') + }) + resetSocketTransportFactory() + + const socket = createSocketTransport('irc://127.0.0.1:65535') + + expect(socket).toBeInstanceOf(NodeTCPSocket) + socket.close() + }) +}) \ No newline at end of file From 6b35f0de76d736800449a42ac0102261d12ba234 Mon Sep 17 00:00:00 2001 From: louzt Date: Thu, 23 Apr 2026 17:25:47 -0600 Subject: [PATCH 2/2] test(transport): avoid real socket dial in seam unit test Stub net.connect in the reset test so the unit test verifies the default factory without performing real loopback network I/O. --- tests/unit/socketTransport.test.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/unit/socketTransport.test.ts b/tests/unit/socketTransport.test.ts index 29a118c..6c9550c 100644 --- a/tests/unit/socketTransport.test.ts +++ b/tests/unit/socketTransport.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test, vi } from 'vitest' +import net from 'net' import { NodeTCPSocket } from '../../src/lib/nodeTcpSocket' import { createSocketTransport, @@ -32,14 +33,24 @@ describe('socketTransport', () => { }) test('reset restores the default TCP/TLS transport', () => { - setSocketTransportFactory(() => { + 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) - socket.close() }) }) \ No newline at end of file