# JBot WebSocket Protocol – 06: Node.js-Referenzimplementierung

## Überblick

Dieses Dokument enthält eine vollständige Referenzimplementierung eines JBot WebSocket-Clients in Node.js. Der Client verwendet die [`ws`](https://github.com/websockets/ws)-Bibliothek und deckt alle wesentlichen Protokollfunktionen ab.

## Abhängigkeiten

```bash
npm install ws
```

`ws` ist die einzige externe Abhängigkeit. Alternativ kann die in Node.js 22+ eingebaute `WebSocket`-API verwendet werden (API ist nahezu identisch).

---

## 1. Basis-Client: Verbinden und einfache Nachricht senden

```javascript
// client-basic.js
import WebSocket from 'ws';

const HOST = '127.0.0.1';
const PORT = 8765;
const TOKEN = 'mein-token'; // leer lassen wenn nicht benötigt

const url = `ws://${HOST}:${PORT}/?client_id=nodejs-app&token=${TOKEN}`;

const ws = new WebSocket(url);

ws.on('open', () => {
  console.log('✅ Verbunden mit JBot');
});

ws.on('message', (data) => {
  const msg = JSON.parse(data.toString());
  console.log(`📩 [${msg.event}]`, JSON.stringify(msg, null, 2));
});

ws.on('close', (code, reason) => {
  console.log(`🔌 Verbindung geschlossen: ${code} ${reason}`);
});

ws.on('error', (err) => {
  console.error('❌ Fehler:', err.message);
});

// Nachricht senden (5 Sekunden nach Verbindung)
setTimeout(() => {
  ws.send('Hallo JBot, was kannst du?');
}, 5000);
```

**Ausgabe (typisch):**
```
✅ Verbunden mit JBot
📩 [ready] { event: "ready", chat_id: "abc123...", client_id: "nodejs-app" }
📩 [goal_status] { event: "goal_status", chat_id: "abc123...", status: "running", started_at: 1712000000.123 }
📩 [delta] { event: "delta", chat_id: "abc123...", text: "Hallo" }
📩 [delta] { event: "delta", chat_id: "abc123...", text: "!" }
📩 [stream_end] { event: "stream_end", chat_id: "abc123...", text: "Hallo! Ich bin JBot..." }
📩 [message] { event: "message", chat_id: "abc123...", text: "Hallo! Ich bin JBot...", kind: "answer", latency_ms: 1234 }
📩 [turn_end] { event: "turn_end", chat_id: "abc123...", latency_ms: 1234, goal_state: {...} }
📩 [session_updated] { event: "session_updated", chat_id: "abc123...", scope: "thread" }
📩 [goal_status] { event: "goal_status", chat_id: "abc123...", status: "idle" }
```

---

## 2. Vollständiger Client mit Event-Demultiplexing

```javascript
// client-full.js
import WebSocket from 'ws';

class JBotClient {
  constructor(options = {}) {
    this.host = options.host || '127.0.0.1';
    this.port = options.port || 8765;
    this.path = options.path || '/';
    this.token = options.token || '';
    this.clientId = options.clientId || `node-${Date.now()}`;
    this.ws = null;
    this.chatId = null;
    this.connected = false;

    // Event-Handler – überschreibbar
    this.onReady = options.onReady || (() => {});
    this.onMessage = options.onMessage || (() => {});
    this.onDelta = options.onDelta || (() => {});
    this.onStreamEnd = options.onStreamEnd || (() => {});
    this.onReasoning = options.onReasoning || (() => {});
    this.onToolHint = options.onToolHint || (() => {});
    this.onProgress = options.onProgress || (() => {});
    this.onFileEdit = options.onFileEdit || (() => {});
    this.onTurnEnd = options.onTurnEnd || (() => {});
    this.onGoalState = options.onGoalState || (() => {});
    this.onGoalStatus = options.onGoalStatus || (() => {});
    this.onSessionUpdated = options.onSessionUpdated || (() => {});
    this.onRuntimeModelUpdated = options.onRuntimeModelUpdated || (() => {});
    this.onError = options.onError || (() => {});
    this.onClose = options.onClose || (() => {});
  }

  connect() {
    const params = new URLSearchParams();
    params.set('client_id', this.clientId);
    if (this.token) params.set('token', this.token);

    const url = `ws://${this.host}:${this.port}${this.path}?${params}`;

    this.ws = new WebSocket(url);

    this.ws.on('open', () => {
      // Verbindung hergestellt – warten auf ready-Event
    });

    this.ws.on('message', (data) => {
      try {
        const msg = JSON.parse(data.toString());
        this._dispatch(msg);
      } catch {
        console.warn('⚠️ Nicht-JSON Nachricht empfangen:', data.toString().slice(0, 100));
      }
    });

    this.ws.on('close', (code, reason) => {
      this.connected = false;
      this.onClose(code, reason.toString());
    });

    this.ws.on('error', (err) => {
      console.error('❌ WebSocket-Fehler:', err.message);
    });

    return new Promise((resolve) => {
      const originalReady = this.onReady;
      this.onReady = (chatId, clientId) => {
        this.connected = true;
        this.chatId = chatId;
        originalReady(chatId, clientId);
        resolve({ chatId, clientId });
      };
    });
  }

  _dispatch(msg) {
    switch (msg.event) {
      case 'ready':
        this.onReady(msg.chat_id, msg.client_id);
        break;
      case 'message':
        if (msg.kind === 'tool_hint') {
          this.onToolHint(msg);
        } else if (msg.kind === 'progress') {
          this.onProgress(msg);
        } else {
          this.onMessage(msg);
        }
        break;
      case 'delta':
        this.onDelta(msg);
        break;
      case 'stream_end':
        this.onStreamEnd(msg);
        break;
      case 'reasoning_delta':
        this.onReasoning('delta', msg);
        break;
      case 'reasoning_end':
        this.onReasoning('end', msg);
        break;
      case 'file_edit':
        this.onFileEdit(msg);
        break;
      case 'turn_end':
        this.onTurnEnd(msg);
        break;
      case 'goal_state':
        this.onGoalState(msg);
        break;
      case 'goal_status':
        this.onGoalStatus(msg);
        break;
      case 'session_updated':
        this.onSessionUpdated(msg);
        break;
      case 'runtime_model_updated':
        this.onRuntimeModelUpdated(msg);
        break;
      case 'error':
        this.onError(msg);
        break;
      default:
        console.log('❓ Unbekanntes Event:', msg.event, msg);
    }
  }

  // -- Senden ----------------------------------------------------------------

  /**
   * Sende eine Chat-Nachricht (einfach/legacy).
   * @param {string} text - Nachrichtentext.
   * @param {string} [chatId] - Ziel-Session (default: own chatId).
   */
  sendMessage(text, chatId) {
    if (!this.connected) throw new Error('Nicht verbunden');
    const cid = chatId || this.chatId;
    const msg = JSON.stringify({
      type: 'message',
      chat_id: cid,
      content: text,
    });
    this.ws.send(msg);
  }

  /**
   * Sende eine Chat-Nachricht mit Medien-Anhang.
   * @param {string} text
   * @param {Array<{data_url: string, name?: string}>} media
   * @param {string} [chatId]
   */
  sendMessageWithMedia(text, media, chatId) {
    if (!this.connected) throw new Error('Nicht verbunden');
    const cid = chatId || this.chatId;
    const msg = JSON.stringify({
      type: 'message',
      chat_id: cid,
      content: text,
      media,
    });
    this.ws.send(msg);
  }

  /**
   * Einfacher Text (Legacy-Modus, wird an Default-Chat-ID gesendet).
   */
  sendText(text) {
    if (!this.connected) throw new Error('Nicht verbunden');
    this.ws.send(text);
  }

  /**
   * Neue Chat-Session erstellen.
   */
  createChat() {
    if (!this.connected) throw new Error('Nicht verbunden');
    this.ws.send(JSON.stringify({ type: 'new_chat' }));
  }

  /**
   * Einer bestehenden Session beitreten/subscriben.
   */
  attachChat(chatId) {
    if (!this.connected) throw new Error('Nicht verbunden');
    this.ws.send(JSON.stringify({ type: 'attach', chat_id: chatId }));
  }

  /**
   * Workspace-Pfad ändern.
   */
  setWorkspace(chatId, path) {
    if (!this.connected) throw new Error('Nicht verbunden');
    this.ws.send(JSON.stringify({
      type: 'set_workspace_scope',
      chat_id: chatId,
      path,
    }));
  }

  close() {
    this.ws?.close();
  }
}

export default JBotClient;
```

---

## 3. Verwendungsbeispiel: CLI-Chat

```javascript
// cli-chat.js
import readline from 'readline';
import JBotClient from './client-full.js';

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});

async function main() {
  const client = new JBotClient({
    host: '127.0.0.1',
    port: 8765,
    token: 'mein-token', // ggf. anpassen
    clientId: 'cli-chat',

    onReady: (chatId) => {
      console.log(`✅ Verbunden! Chat-ID: ${chatId}`);
      promptUser();
    },

    onDelta: (msg) => {
      process.stdout.write(msg.text);
    },

    onMessage: (msg) => {
      console.log(`\n🤖 ${msg.text}`);
      console.log(`   (Latenz: ${msg.latency_ms}ms)`);
      promptUser();
    },

    onReasoning: (type, msg) => {
      if (type === 'delta') {
        process.stdout.write(`\x1b[90m${msg.text}\x1b[0m`);
      } else {
        console.log('\n---'); // Thinking abgeschlossen
      }
    },

    onToolHint: (msg) => {
      console.log(`\n🔧 ${msg.text}`);
    },

    onProgress: (msg) => {
      console.log(`⏳ ${msg.text}`);
    },

    onTurnEnd: (msg) => {
      console.log(`✅ Turn abgeschlossen (${msg.latency_ms}ms)`);
    },

    onError: (msg) => {
      console.error(`❌ Fehler: ${msg.detail}${msg.reason ? ' – ' + msg.reason : ''}`);
    },

    onClose: () => {
      console.log('\n🔌 Verbindung getrennt');
      process.exit(0);
    },
  });

  function promptUser() {
    rl.question('\n👤 Du: ', (input) => {
      if (input === '/quit') {
        client.close();
        rl.close();
        return;
      }
      if (input === '/new') {
        client.createChat();
        promptUser();
        return;
      }
      client.sendText(input);
    });
  }

  try {
    await client.connect();
  } catch (err) {
    console.error('Verbindungsfehler:', err.message);
    process.exit(1);
  }
}

main();
```

---

## 4. Verwendungsbeispiel: Streaming-Text sammeln

```javascript
// streaming-example.js
import JBotClient from './client-full.js';

async function main() {
  let fullResponse = '';
  let fullReasoning = '';

  const client = new JBotClient({
    host: '127.0.0.1',
    port: 8765,

    onReady: async (chatId) => {
      console.log('Sende Anfrage...');
      client.sendMessage('Erkläre die Relativitätstheorie in 3 Sätzen');
    },

    onReasoning: (type, msg) => {
      if (type === 'delta') {
        fullReasoning += msg.text;
      } else {
        console.log('\n--- Thinking: ---');
        console.log(fullReasoning);
        console.log('--- Ende Thinking ---');
      }
    },

    onDelta: (msg) => {
      fullResponse += msg.text;
    },

    onStreamEnd: (msg) => {
      // Das stream_end-Event enthält den vollständigen Text inkl. umgeschriebener Media-Links
      fullResponse = msg.text;
    },

    onMessage: (msg) => {
      console.log('--- Finale Antwort ---');
      console.log(msg.text);
      console.log(`Latenz: ${msg.latency_ms}ms`);
      if (msg.tool_events?.length) {
        console.log(`Tools verwendet: ${msg.tool_events.map(e => e.tool).join(', ')}`);
      }
      client.close();
    },

    onTurnEnd: (msg) => {
      console.log(`Turn beendet. Latenz: ${msg.latency_ms}ms`);
    },

    onError: (msg) => {
      console.error('Fehler:', msg.detail, msg.reason);
      client.close();
    },
  });

  await client.connect();
}

main();
```

---

## 5. Verwendungsbeispiel: Bild senden

```javascript
// image-upload.js
import { readFileSync } from 'fs';
import { basename } from 'path';
import JBotClient from './client-full.js';

function fileToDataURL(filePath) {
  const buffer = readFileSync(filePath);
  const base64 = buffer.toString('base64');
  
  // MIME-Type anhand der Dateierweiterung bestimmen
  const ext = basename(filePath).split('.').pop().toLowerCase();
  const mimeMap = {
    png: 'image/png',
    jpg: 'image/jpeg',
    jpeg: 'image/jpeg',
    webp: 'image/webp',
    gif: 'image/gif',
  };
  const mime = mimeMap[ext] || 'application/octet-stream';
  
  return {
    data_url: `data:${mime};base64,${base64}`,
    name: basename(filePath),
  };
}

async function main() {
  const media = [fileToDataURL('./screenshot.png')];

  const client = new JBotClient({
    host: '127.0.0.1',
    port: 8765,

    onReady: (chatId) => {
      client.sendMessageWithMedia('Analysiere dieses Bild', media, chatId);
    },

    onDelta: (msg) => process.stdout.write(msg.text),
    
    onMessage: (msg) => {
      console.log('\n--- Antwort ---');
      console.log(msg.text);
      if (msg.media_urls?.length) {
        console.log('Generierte Medien:', msg.media_urls);
      }
      client.close();
    },

    onError: (msg) => {
      console.error(`Fehler: ${msg.detail}${msg.reason ? ' – ' + msg.reason : ''}`);
      client.close();
    },
  });

  await client.connect();
}

main();
```

---

## 6. Multi-Session-Management

```javascript
// multi-session.js
import JBotClient from './client-full.js';

/**
 * Verwaltet mehrere Sessions über eine WebSocket-Verbindung.
 */
class JBotMultiClient extends JBotClient {
  constructor(options = {}) {
    super(options);
    /** @type {Map<string, { title?: string }>} */
    this.sessions = new Map();
  }

  _dispatch(msg) {
    super._dispatch(msg);

    // Sessions tracken
    if (msg.event === 'attached') {
      this.sessions.set(msg.chat_id, {});
    }

    // Goal-Informationen speichern
    if (msg.event === 'goal_state' && msg.goal_state?.active) {
      const session = this.sessions.get(msg.chat_id);
      if (session) {
        session.title = msg.goal_state.title;
      }
    }
  }

  listSessions() {
    return [...this.sessions.entries()].map(([id, data]) => ({
      id,
      title: data.title || '(unbenannt)',
    }));
  }
}

async function main() {
  const client = new JBotMultiClient({
    host: '127.0.0.1',
    port: 8765,
  });

  await client.connect();
  
  // 3 Sessions erstellen
  client.createChat();
  client.createChat();
  client.createChat();

  // Warte auf attached-Events...
  await new Promise(resolve => setTimeout(resolve, 1000));

  console.log('Aktive Sessions:');
  console.log(client.listSessions());

  // Nachricht an erste Session (Default-Chat)
  client.sendText('Hallo!');

  client.close();
}

main();
```

---

## 7. Reconnect-Handler

```javascript
// reconnect.js
import JBotClient from './client-full.js';

class JBotReconnectingClient extends JBotClient {
  constructor(options = {}) {
    super(options);
    this.reconnectDelay = options.reconnectDelay || 1000;
    this.maxReconnectDelay = options.maxReconnectDelay || 30000;
    this.knownChatIds = new Set();
  }

  async connect() {
    const result = await super.connect();
    // Standard-Chat-ID speichern
    this.knownChatIds.add(result.chatId);
    return result;
  }

  async _handleDisconnect(code, reason) {
    this.connected = false;
    console.log(`🔌 Getrennt (${code}), reconnect in ${this.reconnectDelay}ms...`);

    await new Promise(resolve => setTimeout(resolve, this.reconnectDelay));
    this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);

    try {
      const { chatId } = await super.connect();
      this.reconnectDelay = 1000; // Reset bei Erfolg

      // Bekannte Sessions wieder abonnieren
      for (const id of this.knownChatIds) {
        if (id !== chatId) {
          this.attachChat(id);
        }
      }
    } catch (err) {
      console.error('Reconnect fehlgeschlagen:', err.message);
      await this._handleDisconnect(1006, 'reconnect failed');
    }
  }
}

async function main() {
  const client = new JBotReconnectingClient({ host: '127.0.0.1', port: 8765 });
  
  const originalClose = client.onClose;
  client.onClose = (code, reason) => {
    originalClose?.(code, reason);
    client._handleDisconnect(code, reason);
  };

  await client.connect();
  console.log('Verbunden – Verbindung wird automatisch wiederhergestellt.');
}

main();
```

---

## 8. TypeScript-Typdefinitionen

```typescript
// jbot-websocket-types.ts

/** Alle möglichen Server→Client Event-Typen */
export type JBotEventType =
  | 'ready'
  | 'attached'
  | 'message'
  | 'delta'
  | 'stream_end'
  | 'reasoning_delta'
  | 'reasoning_end'
  | 'file_edit'
  | 'turn_end'
  | 'goal_state'
  | 'goal_status'
  | 'session_updated'
  | 'runtime_model_updated'
  | 'error';

/** Basis-Typ für alle Events */
export interface JBotEvent {
  event: JBotEventType;
  chat_id?: string;
}

export interface ReadyEvent extends JBotEvent {
  event: 'ready';
  chat_id: string;
  client_id: string;
}

export interface AttachedEvent extends JBotEvent {
  event: 'attached';
  chat_id: string;
}

export interface MessageEvent extends JBotEvent {
  event: 'message';
  chat_id: string;
  text: string;
  media?: string[];
  media_urls?: Array<{ url: string; filename?: string }>;
  reply_to?: string;
  latency_ms?: number;
  tool_events?: ToolEvent[];
  agent_ui?: Record<string, unknown>;
  kind?: 'answer' | 'tool_hint' | 'progress';
}

export interface DeltaEvent extends JBotEvent {
  event: 'delta';
  chat_id: string;
  text: string;
  stream_id?: string;
}

export interface StreamEndEvent extends JBotEvent {
  event: 'stream_end';
  chat_id: string;
  text?: string;
  stream_id?: string;
}

export interface ReasoningDeltaEvent extends JBotEvent {
  event: 'reasoning_delta';
  chat_id: string;
  text: string;
  stream_id?: string;
}

export interface ReasoningEndEvent extends JBotEvent {
  event: 'reasoning_end';
  chat_id: string;
  stream_id?: string;
}

export interface FileEditEvent extends JBotEvent {
  event: 'file_edit';
  chat_id: string;
  edits: Array<{
    path: string;
    action: string;
    diff?: string;
  }>;
}

export interface TurnEndEvent extends JBotEvent {
  event: 'turn_end';
  chat_id: string;
  latency_ms?: number;
  goal_state?: GoalState;
}

export interface GoalStateEvent extends JBotEvent {
  event: 'goal_state';
  chat_id: string;
  goal_state: GoalState;
}

export interface GoalStatusEvent extends JBotEvent {
  event: 'goal_status';
  chat_id: string;
  status: 'running' | 'idle';
  started_at?: number;
}

export interface SessionUpdatedEvent extends JBotEvent {
  event: 'session_updated';
  chat_id: string;
  scope?: 'thread' | 'metadata';
  workspace_scope?: WorkspaceScope;
}

export interface RuntimeModelUpdatedEvent extends JBotEvent {
  event: 'runtime_model_updated';
  model_name: string;
  model_preset?: string;
}

export interface ErrorEvent extends JBotEvent {
  event: 'error';
  detail: string;
  reason?: string;
}

export interface ToolEvent {
  type: 'tool_start' | 'tool_end';
  tool: string;
  args?: Record<string, unknown>;
  result?: string;
}

export interface GoalState {
  active: boolean;
  title?: string;
  progress?: string;
}

export interface WorkspaceScope {
  project_path: string;
  restrict_to_workspace: boolean;
}

/** Client→Server Envelope-Typen */
export type EnvelopeType = 'message' | 'new_chat' | 'attach' | 'fork_chat' | 'set_workspace_scope' | 'transcribe_audio';

export interface MessageEnvelope {
  type: 'message';
  chat_id: string;
  content?: string;
  media?: MediaItem[];
  webui?: boolean;
  turn_id?: string;
  cli_apps?: string;
  mcp_presets?: string;
  image_generation?: { enabled: boolean; aspect_ratio?: string };
}

export interface MediaItem {
  data_url: string;
  name?: string;
}

export interface NewChatEnvelope {
  type: 'new_chat';
}

export interface AttachEnvelope {
  type: 'attach';
  chat_id: string;
}

export interface ForkChatEnvelope {
  type: 'fork_chat';
  chat_id: string;
}

export interface SetWorkspaceScopeEnvelope {
  type: 'set_workspace_scope';
  chat_id: string;
  path: string;
}

export type ClientEnvelope = MessageEnvelope | NewChatEnvelope | AttachEnvelope | ForkChatEnvelope | SetWorkspaceScopeEnvelope;
```

---

## 9. Test-Suite (Minimal)

```javascript
// client.test.js
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import JBotClient from './client-full.js';

const CONFIG = {
  host: process.env.JBOT_HOST || '127.0.0.1',
  port: parseInt(process.env.JBOT_PORT || '8765'),
  token: process.env.JBOT_TOKEN || '',
};

describe('JBotClient', () => {
  let client;

  after(() => client?.close());

  it('sollte sich verbinden und ready empfangen', async () => {
    client = new JBotClient(CONFIG);
    const { chatId, clientId } = await client.connect();
    assert.ok(chatId);
    assert.ok(clientId);
    assert.equal(client.connected, true);
  });

  it('sollte auf eine Nachricht antworten', async () => {
    let receivedMessage = false;
    let receivedTurnEnd = false;

    client.onMessage = () => { receivedMessage = true; };
    client.onTurnEnd = () => { receivedTurnEnd = true; };

    client.sendText('Sag nur "OK"');

    await new Promise(resolve => {
      const check = setInterval(() => {
        if (receivedMessage && receivedTurnEnd) {
          clearInterval(check);
          resolve();
        }
      }, 100);
    });

    assert.ok(receivedMessage);
    assert.ok(receivedTurnEnd);
  });

  it('sollte Goal-Status-Events empfangen', async () => {
    let receivedRunning = false;
    let receivedIdle = false;

    client.onGoalStatus = (msg) => {
      if (msg.status === 'running') receivedRunning = true;
      if (msg.status === 'idle') receivedIdle = true;
    };

    client.sendText('Ping');

    await new Promise(resolve => setTimeout(resolve, 5000));

    assert.ok(receivedRunning);
    assert.ok(receivedIdle);
  });
});
```

Tests ausführen:
```bash
node --test client.test.js
```

---

## Zusammenfassung: Wichtige Implementierungshinweise

1. **JSON-Envelopes bevorzugen** – Legacy Plain-Text nur für einfache Demos
2. **Auf `ready` warten** – Keine Nachrichten vor dem ready-Event senden
3. **Streaming-Puffer verwalten** – `delta`-Events akkumulieren; `stream_end` enthält den finalen Text
4. **Reasoning abgrenzen** – `reasoning_delta`/`reasoning_end` vor dem normalen Antwort-Stream rendern
5. **Turn-Lebenszyklus** – Ein Turn beginnt mit `goal_status: running` und endet mit `goal_status: idle`
6. **Fehlerbehandlung** – Auf `error`-Events lauschen und ggf. Recovery-Logik implementieren
7. **Ping/Pong** – `ws`-Bibliothek antwortet automatisch auf Server-Pings
8. **Reconnect** – `chat_id` speichern und nach Reconnect mit `attach` wieder subscriben