Skip to content

Commit ec089df

Browse files
committed
feat: init basic server
1 parent 44347c1 commit ec089df

8 files changed

Lines changed: 954 additions & 0 deletions

File tree

README.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# zsocket
2+
3+
A small, gorilla-free WebSocket server for Go — clean API, RFC6455 handshake, frames, ping/pong keepalive, JSON helpers, and a simple room hub. Built to be extended (rate limiting, metrics, distributed hub) without leaking third-party types into your code.
4+
5+
> Module: `github.com/aminofox/zsocket`
6+
7+
## Features
8+
9+
- Minimal RFC6455 server-side implementation
10+
- Clean API:
11+
- `Accept(w, r, cfg) (*Conn, error)`
12+
- `Conn.Read(ctx) (MessageType, []byte, error)`
13+
- `Conn.Write(ctx, mt, data)`
14+
- `Conn.ReadJSON/WriteJSON`
15+
- `Conn.Close(code, reason)`
16+
- `Conn.Subprotocol()`, `Conn.RemoteAddr()`
17+
- Fragmentation (basic accumulation until `FIN`)
18+
- Control frames: ping/pong/close
19+
- Keepalive: `PingInterval` + `PongWait`
20+
- `Hub`: rooms & broadcast
21+
- Configurable read limit, deadlines, origin policy, subprotocols
22+
23+
## Quick Start
24+
25+
```bash
26+
go get github.com/aminofox/zsocket
27+
```
28+
29+
```code
30+
package main
31+
32+
import (
33+
"context"
34+
"log"
35+
"net/http"
36+
"github.com/aminofox/zsocket"
37+
)
38+
39+
func main() {
40+
cfg := zsocket.DefaultConfig()
41+
hub := zsocket.NewHub()
42+
43+
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
44+
conn, err := zsocket.Accept(w, r, cfg)
45+
if err != nil {
46+
http.Error(w, "upgrade failed", http.StatusBadRequest)
47+
return
48+
}
49+
hub.Join("lobby", conn)
50+
51+
go func() {
52+
defer func() {
53+
hub.LeaveAll(conn)
54+
_ = conn.Close(zsocket.CloseNormalClosure, "bye")
55+
}()
56+
ctx := context.Background()
57+
_ = conn.Write(ctx, zsocket.TextMessage, []byte("hello from zsocket"))
58+
59+
for {
60+
mt, msg, err := conn.Read(ctx)
61+
if err != nil {
62+
log.Printf("read: %v", err)
63+
return
64+
}
65+
_ = conn.Write(ctx, mt, msg) // echo
66+
hub.Broadcast(ctx, "lobby", mt, msg)
67+
}
68+
}()
69+
})
70+
71+
log.Fatal(http.ListenAndServe(":8080", nil))
72+
}
73+
```
74+
75+
## Config
76+
```
77+
cfg := zsocket.DefaultConfig()
78+
cfg.AllowedOrigins = []string{"https://example.com"} // or nil to allow all
79+
cfg.Subprotocols = []string{"chat", "json"}
80+
cfg.ReadLimit = 16 << 20 // 16 MiB per message
81+
cfg.PingInterval = 30 * time.Second
82+
cfg.PongWait = 15 * time.Second
83+
cfg.WriteTimeout = 10 * time.Second
84+
```
85+
86+
## JSON Helpers
87+
```
88+
type In struct { Cmd string `json:"cmd"`; Body any `json:"body"` }
89+
90+
var in In
91+
if err := conn.ReadJSON(ctx, &in); err != nil { ... }
92+
if err := conn.WriteJSON(ctx, map[string]any{"ok": true}); err != nil { ... }
93+
```
94+
95+
## Rooms & Broadcast
96+
```
97+
hub.Join("room-42", conn)
98+
hub.Broadcast(ctx, "room-42", zsocket.TextMessage, []byte("hi all"))
99+
hub.Leave("room-42", conn)
100+
```

conn.go

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
package zsocket
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"context"
7+
"encoding/binary"
8+
"encoding/json"
9+
"fmt"
10+
"net"
11+
"sync"
12+
"sync/atomic"
13+
"time"
14+
)
15+
16+
// Conn is a server-side WebSocket connection managed by zsocket.
17+
// It exposes Read/Write methods, JSON helpers, and graceful Close.
18+
// Concurrency: one reader goroutine internally; writes are serialized via writeMu.
19+
//
20+
// NOTE: This MVP supports basic fragmentation (accumulate until FIN). Extensions are not implemented.
21+
type Conn struct {
22+
raw net.Conn
23+
br *bufio.Reader
24+
bw *bufio.Writer
25+
cfg Config
26+
subproto string
27+
remoteAddr string
28+
29+
readLimit int64
30+
31+
// pong keeper
32+
lastPong atomic.Value // time.Time
33+
closed atomic.Bool
34+
35+
// Ensure only one write at a time.
36+
writeMu sync.Mutex
37+
// Allow readers to stop.
38+
closeOnce sync.Once
39+
}
40+
41+
// newConn wraps a hijacked net.Conn into a zsocket Conn with buffers and starts keepalive if configured.
42+
func newConn(c net.Conn, cfg Config, subproto, remote string) (*Conn, error) {
43+
if cfg.ReadBufferSize <= 0 {
44+
cfg.ReadBufferSize = DefaultConfig().ReadBufferSize
45+
}
46+
if cfg.WriteBufferSize <= 0 {
47+
cfg.WriteBufferSize = DefaultConfig().WriteBufferSize
48+
}
49+
if cfg.ReadLimit < 0 {
50+
cfg.ReadLimit = 0
51+
}
52+
53+
z := &Conn{
54+
raw: c,
55+
br: bufio.NewReaderSize(c, cfg.ReadBufferSize),
56+
bw: bufio.NewWriterSize(c, cfg.WriteBufferSize),
57+
cfg: cfg,
58+
subproto: subproto,
59+
remoteAddr: remote,
60+
readLimit: cfg.ReadLimit,
61+
}
62+
z.lastPong.Store(time.Now())
63+
64+
// Start keepalive loop if enabled.
65+
if cfg.PingInterval > 0 && cfg.PongWait > 0 {
66+
go z.keepAlive()
67+
}
68+
69+
return z, nil
70+
}
71+
72+
// keepAlive periodically sends ping frames and checks for timely pongs.
73+
// If a pong is not received within PongWait, the connection is closed.
74+
func (c *Conn) keepAlive() {
75+
ticker := time.NewTicker(c.cfg.PingInterval)
76+
defer ticker.Stop()
77+
78+
for {
79+
if c.closed.Load() {
80+
return
81+
}
82+
select {
83+
case <-ticker.C:
84+
_ = c.WriteControl(opPing, nil) // best-effort; ignore error
85+
// check pong freshness
86+
last := c.lastPong.Load().(time.Time)
87+
if time.Since(last) > c.cfg.PongWait {
88+
_ = c.Close(CloseGoingAway, "pong timeout")
89+
return
90+
}
91+
}
92+
}
93+
}
94+
95+
// Subprotocol returns the negotiated subprotocol, if any.
96+
func (c *Conn) Subprotocol() string { return c.subproto }
97+
98+
// RemoteAddr returns the peer address as string.
99+
func (c *Conn) RemoteAddr() string { return c.remoteAddr }
100+
101+
// Close sends a close frame (best-effort) and closes the connection.
102+
func (c *Conn) Close(code CloseCode, reason string) error {
103+
var err error
104+
c.closeOnce.Do(func() {
105+
c.closed.Store(true)
106+
// Send close control frame (best-effort).
107+
_ = c.WriteControl(opClose, closePayload(code, reason))
108+
err = c.raw.Close()
109+
})
110+
return err
111+
}
112+
113+
// Read reads the next complete message, returning its type and payload.
114+
// It accumulates fragments until FIN=1. Control frames are handled internally.
115+
func (c *Conn) Read(ctx context.Context) (MessageType, []byte, error) {
116+
// Context deadline/timeout support on the underlying conn.
117+
if deadline, ok := ctx.Deadline(); ok {
118+
_ = c.raw.SetReadDeadline(deadline)
119+
defer c.raw.SetReadDeadline(time.Time{})
120+
}
121+
122+
var (
123+
msgOpcode byte
124+
full bytes.Buffer
125+
started bool
126+
)
127+
128+
for {
129+
fin, opcode, payload, err := readFrame(c.br, c.readLimit)
130+
if err != nil {
131+
return 0, nil, err
132+
}
133+
134+
switch opcode {
135+
case opText, opBinary:
136+
if started {
137+
return 0, nil, errFragmentState
138+
}
139+
msgOpcode = opcode
140+
full.Write(payload)
141+
started = true
142+
if fin {
143+
return toMsgType(msgOpcode), full.Bytes(), nil
144+
}
145+
146+
case opContinuation:
147+
if !started {
148+
return 0, nil, errUnexpectedOpcode
149+
}
150+
full.Write(payload)
151+
if fin {
152+
return toMsgType(msgOpcode), full.Bytes(), nil
153+
}
154+
155+
case opPing:
156+
// Immediately respond with pong (mirror payload).
157+
_ = c.WriteControl(opPong, payload)
158+
159+
case opPong:
160+
// Update lastPong marker.
161+
c.lastPong.Store(time.Now())
162+
163+
case opClose:
164+
code, reason := parseClosePayload(payload)
165+
// Echo close (best-effort) and close connection locally.
166+
_ = c.WriteControl(opClose, closePayload(code, reason))
167+
return 0, nil, CloseError{Code: code, Reason: reason}
168+
169+
default:
170+
// Protocol error → close
171+
_ = c.Close(CloseProtocolError, "unsupported opcode")
172+
return 0, nil, fmt.Errorf("zsocket: unsupported opcode %d", opcode)
173+
}
174+
}
175+
}
176+
177+
// Write sends a complete message (text or binary) as a single unfragmented frame.
178+
func (c *Conn) Write(ctx context.Context, mt MessageType, data []byte) error {
179+
if c.closed.Load() {
180+
return ErrClosed
181+
}
182+
if mt != TextMessage && mt != BinaryMessage {
183+
return fmt.Errorf("zsocket: invalid message type %d", mt)
184+
}
185+
// Apply write deadline if ctx has one or from config.
186+
if deadline, ok := ctx.Deadline(); ok {
187+
_ = c.raw.SetWriteDeadline(deadline)
188+
defer c.raw.SetWriteDeadline(time.Time{})
189+
} else if c.cfg.WriteTimeout > 0 {
190+
_ = c.raw.SetWriteDeadline(time.Now().Add(c.cfg.WriteTimeout))
191+
defer c.raw.SetWriteDeadline(time.Time{})
192+
}
193+
194+
c.writeMu.Lock()
195+
defer c.writeMu.Unlock()
196+
197+
var opcode byte
198+
if mt == TextMessage {
199+
opcode = opText
200+
} else {
201+
opcode = opBinary
202+
}
203+
204+
if err := writeFrame(c.bw, true, opcode, data); err != nil {
205+
return err
206+
}
207+
return c.bw.Flush()
208+
}
209+
210+
// WriteJSON marshals v to JSON and writes as a text message.
211+
func (c *Conn) WriteJSON(ctx context.Context, v any) error {
212+
b, err := json.Marshal(v)
213+
if err != nil {
214+
return err
215+
}
216+
return c.Write(ctx, TextMessage, b)
217+
}
218+
219+
// ReadJSON reads next message and unmarshals JSON into v.
220+
// It accepts both text and binary frames. If binary, it still tries to decode JSON.
221+
func (c *Conn) ReadJSON(ctx context.Context, v any) error {
222+
mt, b, err := c.Read(ctx)
223+
if err != nil {
224+
return err
225+
}
226+
if mt != TextMessage && mt != BinaryMessage {
227+
return fmt.Errorf("zsocket: unexpected message type %d", mt)
228+
}
229+
return json.Unmarshal(b, v)
230+
}
231+
232+
// WriteControl writes a control frame (ping/pong/close). Best-effort utility.
233+
func (c *Conn) WriteControl(opcode byte, payload []byte) error {
234+
if c.closed.Load() {
235+
return ErrClosed
236+
}
237+
c.writeMu.Lock()
238+
defer c.writeMu.Unlock()
239+
if err := writeFrame(c.bw, true, opcode, payload); err != nil {
240+
return err
241+
}
242+
return c.bw.Flush()
243+
}
244+
245+
func toMsgType(opcode byte) MessageType {
246+
if opcode == opText {
247+
return TextMessage
248+
}
249+
return BinaryMessage
250+
}
251+
252+
// closePayload builds a close frame payload (2-byte code + optional UTF-8 reason).
253+
func closePayload(code CloseCode, reason string) []byte {
254+
if code == 0 {
255+
return nil
256+
}
257+
// 2 bytes code + reason
258+
buf := make([]byte, 2+len(reason))
259+
binary.BigEndian.PutUint16(buf[:2], uint16(code))
260+
copy(buf[2:], []byte(reason))
261+
return buf
262+
}
263+
264+
// parseClosePayload extracts code & reason from a close frame payload.
265+
func parseClosePayload(p []byte) (CloseCode, string) {
266+
if len(p) < 2 {
267+
return CloseNoStatusRcvd, ""
268+
}
269+
code := CloseCode(binary.BigEndian.Uint16(p[:2]))
270+
reason := string(p[2:])
271+
return code, reason
272+
}

0 commit comments

Comments
 (0)