feat(client): show local “[order] …” entry after successful submit; introduce server line msg type; WIP reader state in broken snapshot

- Add serverLineMsg type for future server line handling
- On successful orderSubmittedMsg with total:
  - Derive item name from cached menu and append a formatted “[order] Name ordered N × Item ($Total)” to broadcasts
  - Keep broadcasts capped at 10 entries
- Maintain existing ack handling when no total provided

Also add a WIP snapshot (main.go.broken):
- Replace broadcastListening flag with waitingForMenu and waitingForOrderResp
- On connect, start read loop via readServerLineCmd(reader)
- When submitting an order, set waitingForOrderResp and call submitOrderCmd without passing reader
- After orderSubmittedMsg (success or error), resume listenForBroadcastsCmd if conn/reader present

Note: main.go.broken reflects an in-progress refactor toward a single read loop and explicit waiting states.
This commit is contained in:
Syahdan 2025-10-15 23:14:03 +07:00
parent e523297d5f
commit d026a30927
4 changed files with 712 additions and 624 deletions

664
README.md Normal file
View File

@ -0,0 +1,664 @@
# Clink - TCP Socket-Based Order System
## Overview
Clink is a real-time order management system demonstrating TCP socket programming concepts in Go. The application consists of a TCP server that manages client connections and broadcasts order updates, and a Terminal User Interface (TUI) client that connects to the server to place orders and receive real-time updates from all connected clients.
**Main Topic:** Socket Programming with TCP in Go
---
## Architecture
### Components
1. **TCP Server** (`server.go`) - Multi-client socket server with broadcast capability
2. **TUI Client** (`main.go`) - Interactive terminal client using TCP sockets
3. **Protocol** - Custom text-based protocol over TCP
---
## Socket Programming Concepts Demonstrated
### 1. TCP Server Socket Creation
**Location:** `server.go:252-256`
```go
func startTCPServer(addr string) error {
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
```
- Uses `net.Listen()` to create a TCP listener socket
- Binds to the specified address (default: `localhost:9000`)
- Returns a `net.Listener` that can accept incoming connections
---
### 2. Accepting Client Connections
**Location:** `server.go:261-268`
```go
for {
c, err := ln.Accept()
if err != nil {
log.Printf("accept error: %v", err)
continue
}
go handleConn(hub, c)
}
```
- `Accept()` blocks until a client connects
- Each connection is handled in a separate goroutine
- This enables concurrent handling of multiple clients
---
### 3. Client Socket Connection
**Location:** `main.go:484-493`
```go
func connectCmd(addr string) tea.Cmd {
return func() tea.Msg {
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
return statusMsg(fmt.Sprintf("Connect failed: %v", err))
}
return connectedMsg{conn: conn}
}
}
```
- Uses `net.DialTimeout()` to establish TCP connection
- Includes 3-second timeout to prevent indefinite blocking
- Returns `net.Conn` interface for bidirectional communication
---
### 4. Buffered Reading from Socket
**Location:** `main.go:148-150`
```go
case connectedMsg:
m.conn = msg.conn
m.reader = bufio.NewReader(m.conn)
```
**Why Buffered Reading?**
- Raw socket reads are byte-level and inefficient
- `bufio.Reader` provides buffering and line-reading capabilities
- **Critical:** Single `bufio.Reader` instance per connection prevents data corruption
- Multiple readers on the same socket would compete for bytes
---
### 5. Reading Greeting Messages
**Location:** `main.go:153-159`
```go
_ = m.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
for i := 0; i < 2; i++ {
if _, err := m.reader.ReadString('\n'); err != nil {
break
}
}
_ = m.conn.SetReadDeadline(time.Time{})
```
- Server sends 2 greeting lines upon connection (lines 131-132 in `server.go`)
- Client consumes these to prevent interference with protocol messages
- **Socket Deadline:** Temporary 500ms timeout prevents indefinite blocking
- Deadline is reset to zero (no timeout) after consuming greetings
---
### 6. Writing to Socket
**Location:** `main.go:506`, `main.go:544`
```go
// Sending MENU request
fmt.Fprintln(conn, "MENU")
// Sending ORDER request
fmt.Fprintf(conn, "ORDER %s\n", string(b))
```
**Location:** `server.go:155`, `server.go:211`
```go
// Server responses
fmt.Fprintln(c, string(b)) // MENU response
fmt.Fprintf(c, "OK|%.2f\n", total) // ORDER response
```
- `fmt.Fprintln()` and `fmt.Fprintf()` write to any `io.Writer`, including sockets
- Newline-delimited protocol (each message ends with `\n`)
- Text-based protocol for simplicity and debuggability
---
### 7. Socket Read Deadlines (Timeouts)
**Location:** `main.go:510-511`, `main.go:550-551`, `main.go:581-583`
```go
// Set 3-second timeout for MENU
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
defer func() { _ = conn.SetReadDeadline(time.Time{}) }()
// Set 5-second timeout for ORDER
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
// Set 100ms timeout for broadcasts (polling)
_ = conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
```
**Why Different Timeouts?**
- **MENU/ORDER (3-5s):** Synchronous request-response, expect immediate reply
- **Broadcasts (100ms):** Asynchronous polling loop, short timeout prevents blocking
- Deadlines prevent reads from blocking forever if server stops responding
**Deadline vs Timeout:**
- `SetReadDeadline()` sets an absolute time
- After deadline expires, read returns `net.Error` with `Timeout() == true`
---
### 8. Handling Timeout Errors
**Location:** `main.go:585-589`
```go
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
return broadcastMsg("") // Timeout is expected, continue polling
}
return statusMsg(fmt.Sprintf("Connection closed: %v", err))
}
```
- Type assertion to check if error is `net.Error`
- Distinguish between timeout (expected) and connection failure
- Timeouts in broadcast loop are normal behavior (polling mechanism)
---
### 9. Concurrent Socket Communication Challenge
**The Problem:** Race condition between synchronous requests and asynchronous broadcasts
**Location:** `main.go:130`, `main.go:548`, `main.go:213-215`
```go
// Before ORDER: Pause broadcast listener
m.pauseBroadcast = true
// Wait for broadcast listener's current read to timeout
time.Sleep(150 * time.Millisecond)
// Broadcast handler: Slow down polling when paused
if m.pauseBroadcast {
time.Sleep(50 * time.Millisecond)
}
```
**Why This Is Needed:**
- Single TCP socket shared between request-response and broadcast listening
- Only one goroutine can read from socket at a time
- Without coordination, broadcast listener might consume ORDER response
- Solution: Coordinate reads using pause flag and timeouts
**Timeline Without Coordination:**
```
Time Action
t=0 ORDER sent
t=1 Broadcast listener reads "OK|9.00" (WRONG!)
t=3000 submitOrderCmd times out (never got response)
```
**Timeline With Coordination:**
```
Time Action
t=0 pauseBroadcast = true
t=0 ORDER sent
t=150 Broadcast listener's 100ms timeout expires
t=150 Broadcast listener sees pause, sleeps 50ms
t=150 submitOrderCmd reads "OK|9.00" (SUCCESS!)
t=200 Broadcast listener resumes with pause = false
```
---
### 10. Hub Pattern for Broadcasting
**Location:** `server.go:36-80`
```go
type Hub struct {
mu sync.Mutex
conns map[net.Conn]struct{}
joinCh chan net.Conn
leaveCh chan net.Conn
msgCh chan broadcast
}
func (h *Hub) Run() {
for {
select {
case c := <-h.joinCh:
h.mu.Lock()
h.conns[c] = struct{}{}
h.mu.Unlock()
case c := <-h.leaveCh:
h.mu.Lock()
if _, ok := h.conns[c]; ok {
delete(h.conns, c)
_ = c.Close()
}
h.mu.Unlock()
case msg := <-h.msgCh:
h.mu.Lock()
for c := range h.conns {
if msg.exclude != nil && c == msg.exclude {
continue
}
fmt.Fprintln(c, msg.text) // Write to each socket
}
h.mu.Unlock()
}
}
}
```
**Pattern Benefits:**
- Centralized connection management
- Thread-safe access to connection map using mutex
- Fan-out broadcast to all connected sockets
- Optional exclusion (don't echo back to sender)
**Broadcasting an Order:** `server.go:207-209`
```go
h.msgCh <- broadcast{
text: fmt.Sprintf("[order] %s ordered %d × %s ($%.2f)",
ord.Name, ord.Quantity, chosen.Name, total),
}
```
---
### 11. Per-Connection Handler
**Location:** `server.go:115-248`
```go
func handleConn(h *Hub, c net.Conn) {
defer func() { h.leaveCh <- c }()
h.joinCh <- c
scanner := bufio.NewScanner(c)
scanner.Buffer(make([]byte, 0, 1024), 64*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Protocol handling...
}
}
```
**Key Points:**
- Runs in separate goroutine per connection
- `defer` ensures cleanup when connection closes
- Uses `bufio.Scanner` for line-by-line reading
- Handles multiple protocol commands: `MENU`, `ORDER`, `/name`, `/quit`
---
### 12. Connection Lifecycle
**Server Side:**
1. `ln.Accept()` - Accept incoming connection (`server.go:262`)
2. `h.joinCh <- c` - Register connection in hub (`server.go:117`)
3. Send greeting messages (`server.go:131-132`)
4. Process commands in loop (`server.go:141-240`)
5. `h.leaveCh <- c` - Unregister and close (`server.go:116`)
**Client Side:**
1. `net.DialTimeout()` - Establish connection (`main.go:487`)
2. Create `bufio.Reader` (`main.go:150`)
3. Consume greeting messages (`main.go:153-159`)
4. Send protocol commands (`MENU`, `ORDER`)
5. Listen for broadcasts (`main.go:570-593`)
6. `conn.Close()` - Close on quit (`main.go:234`)
---
## Protocol Specification
### Text-Based Protocol over TCP
All messages are newline-delimited (`\n`).
#### Client → Server
**1. MENU Request**
- Format: `MENU\n`
- Location: `main.go:506`
- Server handler: `server.go:149-157`
- Response: JSON array of menu items
**Example:**
```
Client: MENU
Server: [{"id":"latte","name":"Caffè Latte","price":4.5},...]
```
**2. ORDER Request**
- Format: `ORDER <json>\n`
- Location: `main.go:544`
- Server handler: `server.go:160-213`
- Response: `OK|<total>\n`
**Example:**
```
Client: ORDER {"name":"Alice","itemId":"latte","quantity":2}
Server: OK|9.00
```
#### Server → Client (Broadcasts)
**1. Order Broadcast**
- Format: `[order] <name> ordered <qty> × <item> ($<total>)\n`
- Location: `server.go:207-209`
- Client handler: `main.go:205-216`
**Example:**
```
[order] Alice ordered 2 × Caffè Latte ($9.00)
```
**2. Join/Leave Broadcasts**
- Format: `[join] <username> (<id>)\n` or `[leave] <username> (<id>)\n`
- Location: `server.go:135`, `server.go:247`
---
## Application Flow
### Server Startup Flow
```
1. main() [server.go:595]
└─ Parse flags: --server
2. startTCPServer() [server.go:251]
└─ net.Listen("tcp", addr)
3. hub.Run() [server.go:54]
└─ Goroutine: Handle join/leave/broadcast channels
4. Accept loop [server.go:261-268]
└─ For each connection:
└─ go handleConn(hub, conn)
├─ Register: hub.joinCh <- conn
├─ Send greeting [131-132]
├─ Process commands [141-240]
│ ├─ MENU → JSON response [149-157]
│ ├─ ORDER → Validate, broadcast, ack [160-213]
│ └─ /name, /quit [216-236]
└─ Cleanup: hub.leaveCh <- conn [116]
```
---
### Client Startup Flow
```
1. main() [main.go:595]
└─ tea.NewProgram(initialModel(host))
2. Init() [main.go:88-91]
└─ Return connectCmd(host)
3. connectCmd() [main.go:484-493]
└─ net.DialTimeout("tcp", addr, 3s)
└─ Return connectedMsg{conn}
4. Update(connectedMsg) [main.go:148-161]
├─ Create bufio.Reader(conn)
├─ Consume 2 greeting lines
└─ Status: "Connected"
```
---
### Order Submission Flow (Client)
```
1. User presses 'n' [main.go:247]
└─ If menu cached: Show form
└─ Else: fetchMenuCmd()
2. fetchMenuCmd() [main.go:500-527]
├─ Write: "MENU\n"
├─ SetReadDeadline(3s)
└─ Read JSON response
3. Update(menuLoadedMsg) [main.go:163-175]
└─ Build and show order form
4. User fills form [name, item, quantity, confirm]
└─ Form completed [main.go:107-135]
5. If confirmed:
├─ Set pauseBroadcast = true [130]
└─ submitOrderCmd() [534-567]
├─ Marshal order to JSON
├─ Write: "ORDER <json>\n"
├─ Sleep 150ms (coordination)
├─ SetReadDeadline(5s)
└─ Read: "OK|<total>\n"
6. Update(orderSubmittedMsg) [main.go:177-203]
├─ Set pauseBroadcast = false
├─ Display success status
├─ If first order:
│ └─ Start broadcast listener
└─ Else:
└─ Resume broadcast listener
```
---
### Broadcast Listening Flow (Client)
```
1. Start after first order [main.go:192-194]
└─ Set broadcastListening = true
2. listenForBroadcastsCmd() [main.go:570-593]
├─ SetReadDeadline(100ms) # Poll with timeout
├─ line, err := reader.ReadString('\n')
└─ Three outcomes:
├─ Timeout → Return broadcastMsg("")
├─ Error → Connection closed
└─ Success → Return broadcastMsg(line)
3. Update(broadcastMsg) [main.go:205-216]
├─ If "[order]" prefix:
│ └─ Append to broadcasts list (max 10)
├─ If pauseBroadcast:
│ └─ Sleep 50ms (slow down polling)
└─ Restart: listenForBroadcastsCmd()
```
**Polling Loop Visualization:**
```
Time (ms) Action
0 SetReadDeadline(+100ms)
0-100 Blocking read...
100 Timeout → broadcastMsg("")
100 Handle message, restart
100 SetReadDeadline(+100ms)
100-200 Blocking read...
150 [Data arrives: "[order] Alice..."]
150 Success → broadcastMsg("[order]...")
150 Handle message, append to list
150 Restart loop
```
---
### Order Processing Flow (Server)
```
1. Receive: "ORDER <json>\n" [server.go:160]
2. Parse and validate [server.go:161-203]
├─ json.Unmarshal(raw, &ord)
├─ Check: ord.Name != ""
├─ Check: ord.Quantity > 0
└─ Lookup: Find menuItem by ord.ItemID
3. Calculate total [server.go:205]
└─ total = quantity × price
4. Broadcast to all clients [server.go:207-209]
└─ hub.msgCh <- broadcast{...}
└─ Hub writes to ALL sockets [server.go:68-77]
└─ fmt.Fprintln(c, msg.text)
5. Acknowledge to sender [server.go:211]
└─ Write: "OK|<total>\n"
```
---
## Key Socket Programming Challenges & Solutions
### Challenge 1: Single Socket, Dual Purpose
**Problem:** Same socket used for:
- Synchronous request-response (MENU, ORDER)
- Asynchronous broadcast receiving
**Solution:**
- Broadcast listener uses short (100ms) timeouts
- Before synchronous requests: pause broadcast polling
- Coordination via `pauseBroadcast` flag and sleep delays
- Locations: `main.go:130`, `main.go:548`, `main.go:213-215`
---
### Challenge 2: Buffered Reader Data Corruption
**Problem:** Multiple `bufio.Reader` instances on same socket would:
- Compete for bytes from socket buffer
- Corrupt message boundaries
- Cause incomplete/split messages
**Solution:**
- Single `bufio.Reader` created once at connection (`main.go:150`)
- Shared across all read operations
- Passed to all command functions
---
### Challenge 3: Greeting Message Interference
**Problem:** Server sends 2 greeting lines on connect
- Would be parsed as protocol responses
- MENU/ORDER reads would get greeting instead of response
**Solution:**
- Consume greetings immediately after connect (`main.go:153-159`)
- Use short deadline (500ms) to prevent blocking
- Clear socket buffer before protocol starts
---
### Challenge 4: Connection Cleanup
**Problem:** Connections must be cleaned up on:
- Client disconnect
- Server shutdown
- Network errors
**Solution (Server):**
- `defer func() { h.leaveCh <- c }()` guarantees cleanup (`server.go:116`)
- Hub centralizes close logic (`server.go:61-67`)
**Solution (Client):**
- Detect "Connection closed" in status messages (`main.go:220-227`)
- Reset all socket-related state: `conn`, `reader`, `broadcastListening`
---
## Summary
This application demonstrates:
1. **TCP Client-Server Architecture**
- Server: `net.Listen()`, `Accept()`, per-connection goroutines
- Client: `net.DialTimeout()`, shared connection
2. **Custom Text Protocol**
- Newline-delimited messages
- Request-response (MENU, ORDER)
- Server-initiated broadcasts
3. **Concurrent Socket Communication**
- Multiple clients handled concurrently (server)
- Synchronous + asynchronous reads on single socket (client)
4. **Socket I/O Techniques**
- Buffered reading with `bufio.Reader`
- Read deadlines for timeouts
- Formatted writing with `fmt.Fprintf()`
5. **Broadcast Pattern**
- Hub with channels for thread-safe fan-out
- Write to multiple sockets from single goroutine
6. **Real-World Challenges**
- Read coordination between sync/async operations
- Connection lifecycle management
- Error handling and recovery
---
## Running the Application
**Start Server:**
```bash
go run . -server -host localhost:9000
```
**Start Client(s):**
```bash
go run . -host localhost:9000
```
**Client Controls:**
- `n` - New order (loads menu if needed)
- `r` - Reconnect
- `q` - Quit
---
## File Structure
```
clink/
├── main.go # TUI client with socket operations
├── server.go # TCP server with hub pattern
└── README.md # This documentation
```

78
main.go
View File

@ -74,6 +74,7 @@ type model struct {
reader *bufio.Reader reader *bufio.Reader
broadcastListening bool broadcastListening bool
pauseBroadcast bool
} }
// initialModel creates a base model. // initialModel creates a base model.
@ -126,6 +127,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
m.err = nil m.err = nil
m.loading = true m.loading = true
m.pauseBroadcast = true
m.status = "Submitting order..." m.status = "Submitting order..."
return m, submitOrderCmd(m.conn, *ord, m.reader) return m, submitOrderCmd(m.conn, *ord, m.reader)
} }
@ -169,62 +171,60 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.menu = msg.items m.menu = msg.items
m.status = "Menu loaded." m.status = "Menu loaded."
var cmds []tea.Cmd
m.form = m.buildForm() m.form = m.buildForm()
cmds = append(cmds, m.form.Init()) return m, m.form.Init()
if !m.broadcastListening {
m.broadcastListening = true
cmds = append(cmds, listenForBroadcastsCmd(m.conn, m.reader))
}
return m, tea.Batch(cmds...)
case orderSubmittedMsg: case orderSubmittedMsg:
m.loading = false m.loading = false
m.pauseBroadcast = false
if msg.err != nil { if msg.err != nil {
m.err = msg.err m.err = msg.err
m.status = "Order submission failed." m.status = "Order submission failed."
if m.broadcastListening {
return m, listenForBroadcastsCmd(m.conn, m.reader)
}
return m, nil return m, nil
} }
m.err = nil m.err = nil
if msg.total > 0 { if msg.total > 0 {
m.status = fmt.Sprintf("Order submitted. Total: $%.2f", msg.total) m.status = fmt.Sprintf("Order submitted. Total: $%.2f", msg.total)
if m.lastOrder != nil { if !m.broadcastListening {
var itemName string m.broadcastListening = true
for _, it := range m.menu { return m, listenForBroadcastsCmd(m.conn, m.reader)
if it.ID == m.lastOrder.ItemID {
itemName = it.Name
break
}
}
if itemName != "" {
broadcastText := fmt.Sprintf("[order] %s ordered %d × %s ($%.2f)",
m.lastOrder.Name, m.lastOrder.Quantity, itemName, msg.total)
m.broadcasts = append(m.broadcasts, broadcastText)
if len(m.broadcasts) > 10 {
m.broadcasts = m.broadcasts[1:]
}
}
} }
return m, listenForBroadcastsCmd(m.conn, m.reader)
} else if msg.ack != "" { } else if msg.ack != "" {
m.status = fmt.Sprintf("Order submitted. Server says: %s", msg.ack) m.status = fmt.Sprintf("Order submitted. Server says: %s", msg.ack)
} }
if m.broadcastListening {
return m, listenForBroadcastsCmd(m.conn, m.reader)
}
return m, nil return m, nil
case broadcastMsg: case broadcastMsg:
msgText := string(msg) msgText := string(msg)
if strings.HasPrefix(msgText, "[order]") { if msgText != "" && strings.HasPrefix(msgText, "[order]") {
m.broadcasts = append(m.broadcasts, msgText) m.broadcasts = append(m.broadcasts, msgText)
if len(m.broadcasts) > 10 { if len(m.broadcasts) > 10 {
m.broadcasts = m.broadcasts[1:] m.broadcasts = m.broadcasts[1:]
} }
} }
if m.pauseBroadcast {
time.Sleep(50 * time.Millisecond)
}
return m, listenForBroadcastsCmd(m.conn, m.reader) return m, listenForBroadcastsCmd(m.conn, m.reader)
case statusMsg: case statusMsg:
m.status = string(msg) msgStr := string(msg)
m.status = msgStr
if strings.Contains(msgStr, "Connection closed") {
if m.conn != nil {
_ = m.conn.Close()
m.conn = nil
}
m.broadcastListening = false
m.reader = nil
}
return m, nil return m, nil
case tea.KeyMsg: case tea.KeyMsg:
@ -240,6 +240,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
_ = m.conn.Close() _ = m.conn.Close()
m.conn = nil m.conn = nil
} }
m.broadcastListening = false
m.reader = nil
m.status = "Reconnecting..." m.status = "Reconnecting..."
return m, connectCmd(m.host) return m, connectCmd(m.host)
case "n": case "n":
@ -497,7 +499,7 @@ func connectCmd(addr string) tea.Cmd {
// - server: single line JSON array: [{"id":"x","name":"..."}]\n // - server: single line JSON array: [{"id":"x","name":"..."}]\n
func fetchMenuCmd(conn net.Conn, reader *bufio.Reader) tea.Cmd { func fetchMenuCmd(conn net.Conn, reader *bufio.Reader) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if conn == nil { if conn == nil || reader == nil {
return menuLoadedMsg{err: errors.New("not connected")} return menuLoadedMsg{err: errors.New("not connected")}
} }
@ -531,7 +533,7 @@ func fetchMenuCmd(conn net.Conn, reader *bufio.Reader) tea.Cmd {
// - server: a single line acknowledgement (freeform), e.g. "OK\n" // - server: a single line acknowledgement (freeform), e.g. "OK\n"
func submitOrderCmd(conn net.Conn, ord order, reader *bufio.Reader) tea.Cmd { func submitOrderCmd(conn net.Conn, ord order, reader *bufio.Reader) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if conn == nil { if conn == nil || reader == nil {
return orderSubmittedMsg{err: errors.New("not connected")} return orderSubmittedMsg{err: errors.New("not connected")}
} }
b, err := json.Marshal(ord) b, err := json.Marshal(ord)
@ -543,7 +545,9 @@ func submitOrderCmd(conn net.Conn, ord order, reader *bufio.Reader) tea.Cmd {
return orderSubmittedMsg{err: fmt.Errorf("send ORDER: %w", err)} return orderSubmittedMsg{err: fmt.Errorf("send ORDER: %w", err)}
} }
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) time.Sleep(150 * time.Millisecond)
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
defer func() { _ = conn.SetReadDeadline(time.Time{}) }() defer func() { _ = conn.SetReadDeadline(time.Time{}) }()
line, err := reader.ReadString('\n') line, err := reader.ReadString('\n')
@ -565,11 +569,23 @@ func submitOrderCmd(conn net.Conn, ord order, reader *bufio.Reader) tea.Cmd {
func listenForBroadcastsCmd(conn net.Conn, reader *bufio.Reader) tea.Cmd { func listenForBroadcastsCmd(conn net.Conn, reader *bufio.Reader) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
defer func() {
if r := recover(); r != nil {
return
}
}()
if conn == nil || reader == nil { if conn == nil || reader == nil {
return nil return nil
} }
_ = conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
line, err := reader.ReadString('\n') line, err := reader.ReadString('\n')
_ = conn.SetReadDeadline(time.Time{})
if err != nil { if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
return broadcastMsg("")
}
return statusMsg(fmt.Sprintf("Connection closed: %v", err)) return statusMsg(fmt.Sprintf("Connection closed: %v", err))
} }
return broadcastMsg(strings.TrimRight(line, "\r\n")) return broadcastMsg(strings.TrimRight(line, "\r\n"))

View File

@ -1,592 +0,0 @@
package main
import (
"bufio"
"encoding/json"
"errors"
"flag"
"fmt"
"net"
"strconv"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
type menuItem struct {
ID string `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
// order represents the payload we submit back to the server.
// messages used by Bubble Tea
type (
connectedMsg struct{ conn net.Conn }
menuLoadedMsg struct {
items []menuItem
err error
}
orderSubmittedMsg struct {
ack string
total float64
err error
}
broadcastMsg string
statusMsg string
serverLineMsg string
)
type FormFields struct {
name string
itemID string
quantityStr string
confirm bool
}
// model holds the TUI state.
type model struct {
host string
conn net.Conn
title string
status string
loading bool
err error
lastOrder *order
broadcasts []string
form *huh.Form
formFields *FormFields
menu []menuItem
name string
itemID string
quantityStr string
confirm bool
width int
height int
reader *bufio.Reader
waitingForMenu bool
waitingForOrderResp bool
}
// initialModel creates a base model.
func initialModel(host string) model {
return model{
host: host,
title: "Order Console",
formFields: &FormFields{},
}
}
func (m model) Init() tea.Cmd {
// Connect on startup
return connectCmd(m.host)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
// If a form is active, delegate to it first.
if m.form != nil {
var cmd tea.Cmd
form, cmd := m.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
m.form = f
cmds = append(cmds, cmd)
}
if m.form.State == huh.StateCompleted {
// Parse and submit order if confirmed.
qty, err := strconv.Atoi(strings.TrimSpace(m.formFields.quantityStr))
if err != nil || qty <= 0 {
m.err = fmt.Errorf("invalid quantity: %v", m.formFields.quantityStr)
m.form = nil
return m, nil
}
ord := &order{
Name: strings.TrimSpace(m.formFields.name),
ItemID: m.formFields.itemID,
Quantity: qty,
}
m.lastOrder = ord
m.form = nil
if m.formFields.confirm {
if m.conn == nil {
m.status = "Not connected. Unable to submit order."
return m, nil
}
m.err = nil
m.loading = true
m.status = "Submitting order..."
m.waitingForOrderResp = true
return m, submitOrderCmd(m.conn, *ord)
}
m.status = "Order canceled."
return m, cmd
}
if m.form.State == huh.StateAborted {
m.status = "Order form aborted."
m.form = nil
return m, cmd
}
return m, tea.Batch(cmds...)
}
switch msg := msg.(type) {
case connectedMsg:
m.conn = msg.conn
m.reader = bufio.NewReader(m.conn)
m.status = fmt.Sprintf("Connected to %s", m.host)
_ = m.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
for i := 0; i < 2; i++ {
if _, err := m.reader.ReadString('\n'); err != nil {
break
}
}
_ = m.conn.SetReadDeadline(time.Time{})
return m, readServerLineCmd(m.reader)
case menuLoadedMsg:
m.loading = false
if msg.err != nil {
m.err = msg.err
m.status = "Failed to load menu."
return m, nil
}
m.err = nil
m.menu = msg.items
m.status = "Menu loaded."
var cmds []tea.Cmd
m.form = m.buildForm()
cmds = append(cmds, m.form.Init())
if !m.broadcastListening {
m.broadcastListening = true
cmds = append(cmds, listenForBroadcastsCmd(m.conn, m.reader))
}
return m, tea.Batch(cmds...)
case orderSubmittedMsg:
m.loading = false
if msg.err != nil {
m.err = msg.err
m.status = "Order submission failed."
if m.conn != nil && m.reader != nil {
return m, listenForBroadcastsCmd(m.conn, m.reader)
}
return m, nil
}
m.err = nil
if msg.total > 0 {
m.status = fmt.Sprintf("Order submitted. Total: $%.2f", msg.total)
} else if msg.ack != "" {
m.status = fmt.Sprintf("Order submitted. Server says: %s", msg.ack)
}
if m.conn != nil && m.reader != nil {
return m, listenForBroadcastsCmd(m.conn, m.reader)
}
return m, nil
case broadcastMsg:
msgText := string(msg)
if strings.HasPrefix(msgText, "[order]") {
m.broadcasts = append(m.broadcasts, msgText)
if len(m.broadcasts) > 10 {
m.broadcasts = m.broadcasts[1:]
}
}
return m, listenForBroadcastsCmd(m.conn, m.reader)
case statusMsg:
m.status = string(msg)
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c", "esc":
if m.conn != nil {
_ = m.conn.Close()
}
return m, tea.Quit
case "r":
// Reconnect
if m.conn != nil {
_ = m.conn.Close()
m.conn = nil
}
m.status = "Reconnecting..."
return m, connectCmd(m.host)
case "n":
if m.loading || m.form != nil {
return m, nil
}
if m.conn == nil {
m.status = "Not connected. Press 'r' to reconnect."
return m, nil
}
m.err = nil
if len(m.menu) > 0 {
m.form = m.buildForm()
return m, m.form.Init()
}
m.loading = true
m.status = "Loading menu..."
return m, fetchMenuCmd(m.conn, m.reader)
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
return m, nil
}
func (m model) renderHeader() string {
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212"))
hostStyle := lipgloss.NewStyle().Faint(true)
title := titleStyle.Render(m.title)
host := hostStyle.Render(m.host)
header := lipgloss.JoinVertical(lipgloss.Center, title, host)
return lipgloss.NewStyle().Width(m.width).Align(lipgloss.Center).Render(header)
}
func (m model) renderLeftColumn() string {
lines := []string{}
if m.loading {
lines = append(lines, "Status: "+lipgloss.NewStyle().Foreground(lipgloss.Color("178")).Render("Loading..."))
} else if m.status != "" {
lines = append(lines, "Status: "+m.status)
}
if m.err != nil {
lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(fmt.Sprintf("Error: %v", m.err)))
}
if m.lastOrder != nil {
lines = append(lines, "", lipgloss.NewStyle().Bold(true).Render("Last Order:"))
lines = append(lines, fmt.Sprintf(" Name: %s", m.lastOrder.Name))
var label string
for _, it := range m.menu {
if it.ID == m.lastOrder.ItemID {
label = it.Name
break
}
}
if label != "" {
lines = append(lines, fmt.Sprintf(" Item: %s", label))
} else {
lines = append(lines, fmt.Sprintf(" Item: %s", m.lastOrder.ItemID))
}
lines = append(lines, fmt.Sprintf(" Quantity: %d", m.lastOrder.Quantity))
}
content := lipgloss.JoinVertical(lipgloss.Left, lines...)
return lipgloss.NewStyle().
Width(m.width/2 - 2).
Height(m.height - 6).
Padding(1).
Render(content)
}
func (m model) renderRightColumn() string {
lines := []string{}
headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212"))
lines = append(lines, headerStyle.Render("Recent Orders:"))
lines = append(lines, "")
if len(m.broadcasts) == 0 {
lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No orders yet..."))
} else {
bulletStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("141"))
nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("86")).Bold(true)
itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("117"))
priceStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Bold(true)
for _, b := range m.broadcasts {
msg := strings.TrimPrefix(b, "[order] ")
parts := strings.SplitN(msg, " ordered ", 2)
if len(parts) == 2 {
customer := parts[0]
orderDetails := parts[1]
line := fmt.Sprintf("%s %s ordered %s",
bulletStyle.Render("•"),
nameStyle.Render(customer),
itemStyle.Render(orderDetails))
if idx := strings.Index(orderDetails, "($"); idx != -1 {
priceStart := idx
priceEnd := strings.Index(orderDetails[priceStart:], ")")
if priceEnd != -1 {
priceEnd += priceStart + 1
beforePrice := orderDetails[:priceStart]
priceText := orderDetails[priceStart:priceEnd]
line = fmt.Sprintf("%s %s ordered %s %s",
bulletStyle.Render("•"),
nameStyle.Render(customer),
itemStyle.Render(beforePrice),
priceStyle.Render(priceText))
}
}
lines = append(lines, line)
}
}
}
content := lipgloss.JoinVertical(lipgloss.Left, lines...)
return lipgloss.NewStyle().
Width(m.width/2 - 2).
Height(m.height - 6).
Padding(1).
Render(content)
}
func (m model) renderFooter() string {
connStatus := ""
if m.conn != nil {
connStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("● Connected")
} else {
connStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render("● Disconnected")
}
controls := lipgloss.NewStyle().Faint(true).Render("n: New Order r: Reconnect q: Quit")
leftSide := connStatus
rightSide := controls
footer := lipgloss.JoinHorizontal(lipgloss.Top,
lipgloss.NewStyle().Width(m.width/2).Render(leftSide),
lipgloss.NewStyle().Width(m.width/2).Align(lipgloss.Right).Render(rightSide),
)
return lipgloss.NewStyle().Width(m.width).Render(footer)
}
func (m model) View() string {
if m.form != nil {
return m.form.View()
}
if m.width == 0 || m.height == 0 {
return "Loading..."
}
header := m.renderHeader()
leftCol := m.renderLeftColumn()
rightCol := m.renderRightColumn()
body := lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol)
footer := m.renderFooter()
return lipgloss.JoinVertical(lipgloss.Left,
header,
"",
body,
"",
footer,
)
}
// buildForm constructs the order form: Input (name) -> Select (menu) -> Input (qty) -> Confirm.
func (m *model) buildForm() *huh.Form {
opts := make([]huh.Option[string], 0, len(m.menu))
for _, it := range m.menu {
opts = append(opts, huh.NewOption(fmt.Sprintf("%s - $%.2f", it.Name, it.Price), it.ID))
}
// Reset bound fields for a fresh form
m.formFields.name = ""
m.formFields.itemID = ""
m.formFields.quantityStr = ""
m.formFields.confirm = false
f := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Your name").
Prompt("> ").
Placeholder("Jane Doe").
Value(&m.formFields.name).
Validate(func(s string) error {
if strings.TrimSpace(s) == "" {
return errors.New("name is required")
}
return nil
}),
huh.NewSelect[string]().
Title("Menu item").
Options(opts...).
Value(&m.formFields.itemID).
Validate(func(v string) error {
if v == "" {
return errors.New("please select a menu item")
}
return nil
}),
huh.NewInput().
Title("Quantity").
Prompt("> ").
Placeholder("1").
Value(&m.formFields.quantityStr).
Validate(func(s string) error {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n <= 0 {
return errors.New("enter a positive integer")
}
return nil
}),
huh.NewConfirm().
Title("Place order?").
Affirmative("Yes").
Negative("No").
Value(&m.formFields.confirm),
),
)
return f
}
// connectCmd connects to the TCP server.
func connectCmd(addr string) tea.Cmd {
return func() tea.Msg {
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
return statusMsg(fmt.Sprintf("Connect failed: %v", err))
}
return connectedMsg{conn: conn}
}
}
// fetchMenuCmd asks the server for a menu via the TCP connection.
// Protocol (proposed):
// - client: "MENU\n"
// - server: single line JSON array: [{"id":"x","name":"..."}]\n
func fetchMenuCmd(conn net.Conn, reader *bufio.Reader) tea.Cmd {
return func() tea.Msg {
if conn == nil {
return menuLoadedMsg{err: errors.New("not connected")}
}
if _, err := fmt.Fprintln(conn, "MENU"); err != nil {
return menuLoadedMsg{err: fmt.Errorf("send MENU: %w", err)}
}
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
defer func() { _ = conn.SetReadDeadline(time.Time{}) }()
line, err := reader.ReadString('\n')
if err != nil {
return menuLoadedMsg{err: fmt.Errorf("read MENU: %w", err)}
}
line = strings.TrimRight(line, "\r\n")
if strings.HasPrefix(line, "[error]") {
return menuLoadedMsg{err: fmt.Errorf("server: %s", line)}
}
var items []menuItem
if err := json.Unmarshal([]byte(line), &items); err != nil {
return menuLoadedMsg{err: fmt.Errorf("invalid menu JSON: %w", err)}
}
return menuLoadedMsg{items: items}
}
}
// submitOrderCmd sends the order over TCP.
// Protocol (proposed):
// - client: "ORDER <json>\n"
// - server: a single line acknowledgement (freeform), e.g. "OK\n"
func submitOrderCmd(conn net.Conn, ord order, reader *bufio.Reader) tea.Cmd {
return func() tea.Msg {
if conn == nil {
return orderSubmittedMsg{err: errors.New("not connected")}
}
b, err := json.Marshal(ord)
if err != nil {
return orderSubmittedMsg{err: fmt.Errorf("marshal order: %w", err)}
}
if _, err := fmt.Fprintf(conn, "ORDER %s\n", string(b)); err != nil {
return orderSubmittedMsg{err: fmt.Errorf("send ORDER: %w", err)}
}
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
defer func() { _ = conn.SetReadDeadline(time.Time{}) }()
line, err := reader.ReadString('\n')
if err != nil {
return orderSubmittedMsg{err: fmt.Errorf("read ORDER ack: %w", err)}
}
line = strings.TrimRight(line, "\r\n")
parts := strings.Split(line, "|")
ack := parts[0]
var total float64
if len(parts) > 1 {
if t, err := strconv.ParseFloat(parts[1], 64); err == nil {
total = t
}
}
return orderSubmittedMsg{ack: ack, total: total}
}
}
func listenForBroadcastsCmd(conn net.Conn, reader *bufio.Reader) tea.Cmd {
return func() tea.Msg {
if conn == nil || reader == nil {
return nil
}
line, err := reader.ReadString('\n')
if err != nil {
return statusMsg(fmt.Sprintf("Connection closed: %v", err))
}
return broadcastMsg(strings.TrimRight(line, "\r\n"))
}
}
func main() {
var (
host string
serverOnly bool
)
flag.StringVar(&host, "host", "localhost:9000", "host:port to connect to or bind the server on")
flag.BoolVar(&serverOnly, "server", false, "run only the server")
flag.Parse()
// If requested, start the TCP server (chat server as-is).
if serverOnly {
if err := startTCPServer(host); err != nil {
fmt.Println("Server error:", err)
}
return
}
// Client TUI
m := initialModel(host)
p := tea.NewProgram(m, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Println("error:", err)
}
}

View File

@ -205,7 +205,7 @@ func handleConn(h *Hub, c net.Conn) {
total := float64(ord.Quantity) * chosen.Price total := float64(ord.Quantity) * chosen.Price
h.msgCh <- broadcast{ h.msgCh <- broadcast{
text: fmt.Sprintf("[order] %s (%s) ordered %d × %s ($%.2f)", username, id, ord.Quantity, chosen.Name, total), text: fmt.Sprintf("[order] %s ordered %d × %s ($%.2f)", ord.Name, ord.Quantity, chosen.Name, total),
} }
fmt.Fprintf(c, "OK|%.2f\n", total) fmt.Fprintf(c, "OK|%.2f\n", total)