Compare commits
No commits in common. "e998fbd230a40a6f98fd6e64ed48dcccc6757734" and "d8f5c27bd44166ecf3307d01ea99ec0df2169f06" have entirely different histories.
e998fbd230
...
d8f5c27bd4
19
AGENTS.md
19
AGENTS.md
@ -1,19 +0,0 @@
|
|||||||
# Agent Guidelines for clink
|
|
||||||
|
|
||||||
## Build & Test Commands
|
|
||||||
- Build: `go build -o clink .`
|
|
||||||
- Cross-compile: `./build.sh` (creates binaries in `dist/` for all platforms)
|
|
||||||
- Run client: `go run . -host localhost:9000`
|
|
||||||
- Run server: `go run . -server -host localhost:9000`
|
|
||||||
- Test: `go test ./...` (currently no tests)
|
|
||||||
- Format: `gofmt -w .`
|
|
||||||
- Lint: `go vet ./...`
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
- **Imports**: Standard library first, then third-party (blank line between), use named imports for clarity (e.g., `tea "github.com/charmbracelet/bubbletea"`)
|
|
||||||
- **Formatting**: Use `gofmt`, tabs for indentation
|
|
||||||
- **Types**: Explicit types, struct fields exported when needed for JSON/external use
|
|
||||||
- **Naming**: CamelCase for exports, camelCase for private, descriptive names (e.g., `connectedMsg`, `fetchMenuCmd`)
|
|
||||||
- **Error handling**: Check all errors explicitly, wrap with `fmt.Errorf("context: %w", err)` for context
|
|
||||||
- **Comments**: Minimal, only for public APIs or complex logic
|
|
||||||
- **Concurrency**: Use channels and goroutines for I/O operations (see `connectCmd`, `Hub.Run`)
|
|
||||||
664
README.md
664
README.md
@ -1,664 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
789
main.go
789
main.go
@ -2,675 +2,282 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"regexp"
|
||||||
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textarea"
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
type menuItem struct {
|
// colorizeLine applies ANSI styling to usernames based on 6-hex-digit id from the server.
|
||||||
ID string `json:"id"`
|
func colorizeLine(s string) string {
|
||||||
Name string `json:"name"`
|
reChat := regexp.MustCompile(`^(.+?) \(([0-9a-fA-F]{6})\):[ \t]*(.*)$`)
|
||||||
Price float64 `json:"price"`
|
if m := reChat.FindStringSubmatch(s); m != nil {
|
||||||
|
id := strings.ToLower(m[2])
|
||||||
|
nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#" + id)).Bold(true)
|
||||||
|
name := nameStyle.Render(m[1])
|
||||||
|
rest := strings.TrimSpace(m[3])
|
||||||
|
return fmt.Sprintf("%s: %s", name, rest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// order represents the payload we submit back to the server.
|
reJoinLeave := regexp.MustCompile(`^\[(join|leave)\] (.+?) \(([0-9a-fA-F]{6})\)$`)
|
||||||
|
if m := reJoinLeave.FindStringSubmatch(s); m != nil {
|
||||||
// messages used by Bubble Tea
|
id := strings.ToLower(m[3])
|
||||||
type (
|
nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#" + id)).Bold(true)
|
||||||
connectedMsg struct{ conn net.Conn }
|
uname := nameStyle.Render(m[2])
|
||||||
menuLoadedMsg struct {
|
return fmt.Sprintf("[%s] %s", m[1], uname)
|
||||||
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.
|
reRename := regexp.MustCompile(`^\[rename\] (.+?) \(([0-9a-fA-F]{6})\) -> (.+)$`)
|
||||||
|
if m := reRename.FindStringSubmatch(s); m != nil {
|
||||||
|
id := strings.ToLower(m[2])
|
||||||
|
nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#" + id)).Bold(true)
|
||||||
|
oldN := nameStyle.Render(m[1])
|
||||||
|
newN := nameStyle.Render(m[3])
|
||||||
|
return fmt.Sprintf("[rename] %s -> %s", oldN, newN)
|
||||||
|
}
|
||||||
|
|
||||||
|
reWelcome := regexp.MustCompile(`^Welcome (.+?) \(([0-9a-fA-F]{6})\)$`)
|
||||||
|
if m := reWelcome.FindStringSubmatch(s); m != nil {
|
||||||
|
id := strings.ToLower(m[2])
|
||||||
|
nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#" + id)).Bold(true)
|
||||||
|
uname := nameStyle.Render(m[1])
|
||||||
|
return fmt.Sprintf("Welcome %s", uname)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
type netMsg string
|
||||||
|
type connectedMsg struct{ conn net.Conn }
|
||||||
|
type disconnectedMsg struct{}
|
||||||
|
type errorMsg struct{ err error }
|
||||||
|
|
||||||
type model struct {
|
type model struct {
|
||||||
host string
|
vp viewport.Model
|
||||||
|
input textarea.Model
|
||||||
|
messages []string
|
||||||
|
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
|
server string
|
||||||
title string
|
|
||||||
status string
|
|
||||||
loading bool
|
|
||||||
err error
|
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
|
|
||||||
broadcastListening bool
|
|
||||||
pauseBroadcast bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// initialModel creates a base model.
|
func initialModel(serverAddr string) model {
|
||||||
func initialModel(host string) model {
|
vp := viewport.New(80, 20)
|
||||||
|
vp.Style = lipgloss.NewStyle().Border(lipgloss.NormalBorder()).Padding(0, 1).BorderForeground(lipgloss.Color("#bada55"))
|
||||||
|
|
||||||
|
ta := textarea.New()
|
||||||
|
ta.FocusedStyle.CursorLine = lipgloss.NewStyle()
|
||||||
|
ta.Placeholder = "Type message and press Enter..."
|
||||||
|
ta.Focus()
|
||||||
|
ta.Prompt = "┃ "
|
||||||
|
ta.CharLimit = 0
|
||||||
|
ta.SetHeight(2)
|
||||||
|
ta.ShowLineNumbers = false
|
||||||
|
|
||||||
return model{
|
return model{
|
||||||
host: host,
|
vp: vp,
|
||||||
title: "Order Console",
|
input: ta,
|
||||||
formFields: &FormFields{},
|
messages: []string{},
|
||||||
|
server: serverAddr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd {
|
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.pauseBroadcast = true
|
|
||||||
m.status = "Submitting order..."
|
|
||||||
return m, submitOrderCmd(m.conn, *ord, m.reader)
|
|
||||||
}
|
|
||||||
m.status = "Order canceled."
|
|
||||||
if m.broadcastListening {
|
|
||||||
return m, listenForBroadcastsCmd(m.conn, m.reader)
|
|
||||||
}
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.form.State == huh.StateAborted {
|
|
||||||
m.status = "Order form aborted."
|
|
||||||
m.form = nil
|
|
||||||
if m.broadcastListening {
|
|
||||||
return m, listenForBroadcastsCmd(m.conn, m.reader)
|
|
||||||
}
|
|
||||||
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{})
|
|
||||||
|
|
||||||
m.broadcastListening = true
|
|
||||||
return m, listenForBroadcastsCmd(m.conn, m.reader)
|
|
||||||
|
|
||||||
case menuLoadedMsg:
|
|
||||||
m.loading = false
|
|
||||||
m.pauseBroadcast = false
|
|
||||||
if msg.err != nil {
|
|
||||||
m.err = msg.err
|
|
||||||
m.status = "Failed to load menu."
|
|
||||||
if m.broadcastListening {
|
|
||||||
return m, listenForBroadcastsCmd(m.conn, m.reader)
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.err = nil
|
|
||||||
m.menu = msg.items
|
|
||||||
m.status = "Menu loaded."
|
|
||||||
|
|
||||||
m.form = m.buildForm()
|
|
||||||
if m.broadcastListening {
|
|
||||||
return m, tea.Batch(m.form.Init(), listenForBroadcastsCmd(m.conn, m.reader))
|
|
||||||
}
|
|
||||||
return m, m.form.Init()
|
|
||||||
|
|
||||||
case orderSubmittedMsg:
|
|
||||||
m.loading = false
|
|
||||||
m.pauseBroadcast = false
|
|
||||||
if msg.err != nil {
|
|
||||||
m.err = msg.err
|
|
||||||
m.status = "Order submission failed."
|
|
||||||
if m.broadcastListening {
|
|
||||||
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)
|
|
||||||
|
|
||||||
if !m.broadcastListening {
|
|
||||||
m.broadcastListening = true
|
|
||||||
return m, listenForBroadcastsCmd(m.conn, m.reader)
|
|
||||||
}
|
|
||||||
return m, listenForBroadcastsCmd(m.conn, m.reader)
|
|
||||||
} else if 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
|
|
||||||
|
|
||||||
case broadcastMsg:
|
|
||||||
msgText := string(msg)
|
|
||||||
if msgText != "" && strings.HasPrefix(msgText, "[order]") {
|
|
||||||
m.broadcasts = append(m.broadcasts, msgText)
|
|
||||||
if len(m.broadcasts) > 10 {
|
|
||||||
m.broadcasts = m.broadcasts[1:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if m.pauseBroadcast {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
return m, listenForBroadcastsCmd(m.conn, m.reader)
|
|
||||||
case statusMsg:
|
|
||||||
msgStr := string(msg)
|
|
||||||
m.status = msgStr
|
|
||||||
if strings.Contains(msgStr, "Connection closed") {
|
|
||||||
if m.conn != nil {
|
if m.conn != nil {
|
||||||
_ = m.conn.Close()
|
return tea.Batch(textarea.Blink, readLineCmd(m.conn))
|
||||||
m.conn = nil
|
|
||||||
}
|
}
|
||||||
m.broadcastListening = false
|
return tea.Batch(textarea.Blink, connectCmd(m.server))
|
||||||
m.reader = nil
|
|
||||||
}
|
|
||||||
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.broadcastListening = false
|
|
||||||
m.reader = 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.pauseBroadcast = 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 {
|
|
||||||
loadingText := "Loading..."
|
|
||||||
if m.status != "" {
|
|
||||||
loadingText = m.status
|
|
||||||
}
|
|
||||||
lines = append(lines, "Status: "+lipgloss.NewStyle().Foreground(lipgloss.Color("178")).Render(loadingText))
|
|
||||||
} 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).
|
|
||||||
Border(lipgloss.RoundedBorder()).
|
|
||||||
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).
|
|
||||||
Border(lipgloss.RoundedBorder()).
|
|
||||||
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.width == 0 || m.height == 0 {
|
|
||||||
return "Loading..."
|
|
||||||
}
|
|
||||||
|
|
||||||
header := m.renderHeader()
|
|
||||||
|
|
||||||
var leftCol string
|
|
||||||
if m.form != nil {
|
|
||||||
formView := m.form.WithHeight(m.height - 10).View()
|
|
||||||
leftCol = lipgloss.NewStyle().
|
|
||||||
Width(m.width/2 - 2).
|
|
||||||
Height(m.height - 6).
|
|
||||||
Padding(1).
|
|
||||||
Border(lipgloss.RoundedBorder()).
|
|
||||||
Render(formView)
|
|
||||||
} else {
|
|
||||||
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.NewGroup(
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
).WithTheme(huh.ThemeBase())
|
|
||||||
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
// connectCmd connects to the TCP server.
|
|
||||||
func connectCmd(addr string) tea.Cmd {
|
func connectCmd(addr string) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return statusMsg(fmt.Sprintf("Connect failed: %v", err))
|
return netMsg(fmt.Sprintf("[error] connect: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return connectedMsg{conn: conn}
|
return connectedMsg{conn: conn}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchMenuCmd asks the server for a menu via the TCP connection.
|
func readLineCmd(conn net.Conn) tea.Cmd {
|
||||||
// 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 {
|
return func() tea.Msg {
|
||||||
if conn == nil || reader == nil {
|
if conn == nil {
|
||||||
return menuLoadedMsg{err: errors.New("not connected")}
|
return disconnectedMsg{}
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(150 * time.Millisecond)
|
reader := bufio.NewReader(conn)
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
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{}) }()
|
|
||||||
|
|
||||||
var line string
|
|
||||||
for {
|
|
||||||
l, err := reader.ReadString('\n')
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return menuLoadedMsg{err: fmt.Errorf("read MENU: %w", err)}
|
if errors.Is(err, io.EOF) {
|
||||||
|
return disconnectedMsg{}
|
||||||
}
|
}
|
||||||
l = strings.TrimRight(l, "\r\n")
|
return netMsg(fmt.Sprintf("[error] read: %v", err))
|
||||||
if strings.HasPrefix(l, "[join]") || strings.HasPrefix(l, "[leave]") || strings.HasPrefix(l, "[rename]") || strings.HasPrefix(l, "[order]") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
line = l
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(line, "[error]") {
|
return netMsg(strings.TrimRight(line, "\r\n"))
|
||||||
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.
|
func sendCmd(conn net.Conn, text string) tea.Cmd {
|
||||||
// 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 {
|
return func() tea.Msg {
|
||||||
if conn == nil || reader == nil {
|
if conn == nil {
|
||||||
return orderSubmittedMsg{err: errors.New("not connected")}
|
return netMsg("[error] not connected")
|
||||||
}
|
}
|
||||||
b, err := json.Marshal(ord)
|
|
||||||
|
_, err := fmt.Fprintln(conn, text)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return orderSubmittedMsg{err: fmt.Errorf("marshal order: %w", err)}
|
return netMsg(fmt.Sprintf("[error] send: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := fmt.Fprintf(conn, "ORDER %s\n", string(b)); err != nil {
|
|
||||||
return orderSubmittedMsg{err: fmt.Errorf("send ORDER: %w", err)}
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(150 * time.Millisecond)
|
|
||||||
|
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
|
||||||
defer func() { _ = conn.SetReadDeadline(time.Time{}) }()
|
|
||||||
|
|
||||||
var line string
|
|
||||||
for {
|
|
||||||
l, err := reader.ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
return orderSubmittedMsg{err: fmt.Errorf("read ORDER ack: %w", err)}
|
|
||||||
}
|
|
||||||
l = strings.TrimRight(l, "\r\n")
|
|
||||||
if strings.HasPrefix(l, "[join]") || strings.HasPrefix(l, "[leave]") || strings.HasPrefix(l, "[rename]") || strings.HasPrefix(l, "[order]") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
line = l
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if conn == nil || reader == nil {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
line, err := reader.ReadString('\n')
|
var cmds []tea.Cmd
|
||||||
_ = conn.SetReadDeadline(time.Time{})
|
|
||||||
|
|
||||||
if err != nil {
|
switch msg := msg.(type) {
|
||||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
|
||||||
return broadcastMsg("")
|
case connectedMsg:
|
||||||
|
m.conn = msg.conn
|
||||||
|
m.messages = append(m.messages, "[connected]")
|
||||||
|
m.refreshViewport()
|
||||||
|
|
||||||
|
return m, readLineCmd(m.conn)
|
||||||
|
|
||||||
|
case disconnectedMsg:
|
||||||
|
m.messages = append(m.messages, "[disconnected]")
|
||||||
|
if m.conn != nil {
|
||||||
|
_ = m.conn.Close()
|
||||||
|
m.conn = nil
|
||||||
}
|
}
|
||||||
return statusMsg(fmt.Sprintf("Connection closed: %v", err))
|
m.refreshViewport()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case netMsg:
|
||||||
|
m.messages = append(m.messages, colorizeLine(string(msg)))
|
||||||
|
m.refreshViewport()
|
||||||
|
|
||||||
|
if m.conn != nil && !strings.HasPrefix(string(msg), "[error] read") {
|
||||||
|
cmds = append(cmds, readLineCmd(m.conn))
|
||||||
}
|
}
|
||||||
return broadcastMsg(strings.TrimRight(line, "\r\n"))
|
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.Type {
|
||||||
|
case tea.KeyCtrlC, tea.KeyEsc:
|
||||||
|
if m.conn != nil {
|
||||||
|
_ = m.conn.Close()
|
||||||
}
|
}
|
||||||
|
return m, tea.Quit
|
||||||
|
case tea.KeyEnter:
|
||||||
|
text := m.input.Value()
|
||||||
|
if text != "" {
|
||||||
|
cmds = append(cmds, sendCmd(m.conn, text))
|
||||||
|
m.input.SetValue("")
|
||||||
|
m.refreshViewport()
|
||||||
|
}
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
// Allocate space: viewport above, input below
|
||||||
|
m.vp.Width = msg.Width
|
||||||
|
m.vp.Height = msg.Height - 4
|
||||||
|
m.input.SetWidth(msg.Width - 2)
|
||||||
|
m.refreshViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let textarea handle remaining keys
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.input, cmd = m.input.Update(msg)
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *model) refreshViewport() {
|
||||||
|
contentWidth := m.vp.Width - m.vp.Style.GetHorizontalFrameSize()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for _, line := range m.messages {
|
||||||
|
wrapped := lipgloss.NewStyle().Width(contentWidth).Render(line)
|
||||||
|
b.WriteString(wrapped)
|
||||||
|
if !strings.HasSuffix(wrapped, "\n") {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.vp.SetContent(b.String())
|
||||||
|
m.vp.GotoBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) View() string {
|
||||||
|
header := lipgloss.NewStyle().Width(m.vp.Width).Padding(0, 1).Align(lipgloss.Center).Foreground(lipgloss.Color("241")).Bold(true).Render(m.server)
|
||||||
|
return lipgloss.JoinVertical(
|
||||||
|
lipgloss.Left,
|
||||||
|
header,
|
||||||
|
m.vp.View(),
|
||||||
|
m.input.View(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var (
|
var (
|
||||||
host string
|
host string
|
||||||
serverOnly bool
|
serverOnly bool
|
||||||
menuJSON string
|
|
||||||
)
|
)
|
||||||
|
|
||||||
flag.StringVar(&host, "host", "localhost:9000", "host:port to connect to or bind the server on")
|
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.BoolVar(&serverOnly, "server", false, "run only the server")
|
||||||
flag.StringVar(&menuJSON, "menu", "", "JSON array of menu items (server mode only), e.g. '[{\"id\":\"tea\",\"name\":\"Green Tea\",\"price\":2.5}]'")
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if serverOnly {
|
if serverOnly {
|
||||||
var menu []menuItem
|
if err := startTCPServer(host); err != nil {
|
||||||
if menuJSON != "" {
|
|
||||||
if err := json.Unmarshal([]byte(menuJSON), &menu); err != nil {
|
|
||||||
fmt.Printf("Invalid menu JSON: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := startTCPServer(host, menu); err != nil {
|
|
||||||
fmt.Println("Server error:", err)
|
fmt.Println("Server error:", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
// Pre-connect and read initial welcome/instruction before starting UI
|
||||||
|
var preConn net.Conn
|
||||||
|
var preMsgs []string
|
||||||
|
if conn, err := net.DialTimeout("tcp", host, 3*time.Second); err == nil {
|
||||||
|
preConn = conn
|
||||||
|
// Read up to two initial lines with a short deadline
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(1 * time.Second))
|
||||||
|
r := bufio.NewReader(conn)
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
line, err := r.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
preMsgs = append(preMsgs, strings.TrimRight(line, "\r\n"))
|
||||||
|
}
|
||||||
|
_ = conn.SetReadDeadline(time.Time{})
|
||||||
|
}
|
||||||
|
|
||||||
m := initialModel(host)
|
m := initialModel(host)
|
||||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
if preConn != nil {
|
||||||
|
m.conn = preConn
|
||||||
|
m.messages = append(m.messages, preMsgs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||||
if _, err := p.Run(); err != nil {
|
if _, err := p.Run(); err != nil {
|
||||||
fmt.Println("error:", err)
|
fmt.Println("Error:", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
95
server.go
95
server.go
@ -2,32 +2,15 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
var defaultMenu = []menuItem{
|
|
||||||
{ID: "latte", Name: "Caffè Latte", Price: 4.50},
|
|
||||||
{ID: "cap", Name: "Cappuccino", Price: 4.00},
|
|
||||||
{ID: "esp", Name: "Espresso", Price: 3.00},
|
|
||||||
}
|
|
||||||
|
|
||||||
var serverMenu []menuItem
|
|
||||||
|
|
||||||
// order is the structure the server expects for ORDER.
|
|
||||||
type order struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
ItemID string `json:"itemId"`
|
|
||||||
Quantity int `json:"quantity"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// broadcast represents a line to send to all connections with the ability
|
// broadcast represents a line to send to all connections with the ability
|
||||||
// to exclude a single connection (e.g., exclude self on join).
|
// to exclude a single connection (e.g., exclude self on join).
|
||||||
type broadcast struct {
|
type broadcast struct {
|
||||||
@ -146,75 +129,7 @@ func handleConn(h *Hub, c net.Conn) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// New protocol commands:
|
// Commands
|
||||||
// MENU -> server returns single-line JSON array of menuItem
|
|
||||||
if strings.EqualFold(line, "MENU") {
|
|
||||||
b, err := json.Marshal(serverMenu)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(c, `[error] failed to encode menu`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Fprintln(c, string(b))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// ORDER <json> -> server validates and replies with a single-line ack
|
|
||||||
if strings.HasPrefix(line, "ORDER") {
|
|
||||||
raw := strings.TrimSpace(line[len("ORDER"):])
|
|
||||||
var ord order
|
|
||||||
if err := json.Unmarshal([]byte(raw), &ord); err != nil {
|
|
||||||
fmt.Fprintln(c, "[error] invalid order json")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ord.Name = strings.TrimSpace(ord.Name)
|
|
||||||
log.Printf("ORDER parsed: name=%q itemId=%q qty=%d", ord.Name, ord.ItemID, ord.Quantity)
|
|
||||||
if ord.Name == "" {
|
|
||||||
fmt.Fprintln(c, "[error] missing name")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Fallback handling: accept numeric strings or floats for quantity
|
|
||||||
if ord.Quantity <= 0 {
|
|
||||||
var generic map[string]any
|
|
||||||
if err := json.Unmarshal([]byte(raw), &generic); err == nil {
|
|
||||||
if v, ok := generic["quantity"]; ok {
|
|
||||||
switch t := v.(type) {
|
|
||||||
case string:
|
|
||||||
if n, err := strconv.Atoi(strings.TrimSpace(t)); err == nil {
|
|
||||||
ord.Quantity = n
|
|
||||||
}
|
|
||||||
case float64:
|
|
||||||
ord.Quantity = int(t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ord.Quantity <= 0 {
|
|
||||||
fmt.Fprintln(c, "[error] invalid quantity")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var chosen *menuItem
|
|
||||||
for i := range serverMenu {
|
|
||||||
if serverMenu[i].ID == ord.ItemID {
|
|
||||||
chosen = &serverMenu[i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if chosen == nil {
|
|
||||||
fmt.Fprintln(c, "[error] unknown item")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
total := float64(ord.Quantity) * chosen.Price
|
|
||||||
|
|
||||||
h.msgCh <- broadcast{
|
|
||||||
text: fmt.Sprintf("[order] %s ordered %d × %s ($%.2f)", ord.Name, ord.Quantity, chosen.Name, total),
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(c, "OK|%.2f\n", total)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chat commands
|
|
||||||
if line == "/quit" {
|
if line == "/quit" {
|
||||||
break // unified leave handling below
|
break // unified leave handling below
|
||||||
}
|
}
|
||||||
@ -250,18 +165,12 @@ func handleConn(h *Hub, c net.Conn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// startTCPServer starts a TCP chat server and never returns unless an error occurs.
|
// startTCPServer starts a TCP chat server and never returns unless an error occurs.
|
||||||
func startTCPServer(addr string, menu []menuItem) error {
|
func startTCPServer(addr string) error {
|
||||||
if len(menu) == 0 {
|
|
||||||
menu = defaultMenu
|
|
||||||
}
|
|
||||||
serverMenu = menu
|
|
||||||
|
|
||||||
ln, err := net.Listen("tcp", addr)
|
ln, err := net.Listen("tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
log.Printf("TCP chat server listening on %s", ln.Addr())
|
log.Printf("TCP chat server listening on %s", ln.Addr())
|
||||||
log.Printf("Menu items: %d", len(serverMenu))
|
|
||||||
|
|
||||||
hub := NewHub()
|
hub := NewHub()
|
||||||
go hub.Run()
|
go hub.Run()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user