542 lines
12 KiB
Markdown
542 lines
12 KiB
Markdown
|
# ws
|
||
|
|
||
|
[![GoDoc][godoc-image]][godoc-url]
|
||
|
[![CI][ci-badge]][ci-url]
|
||
|
|
||
|
> [RFC6455][rfc-url] WebSocket implementation in Go.
|
||
|
|
||
|
# Features
|
||
|
|
||
|
- Zero-copy upgrade
|
||
|
- No intermediate allocations during I/O
|
||
|
- Low-level API which allows to build your own logic of packet handling and
|
||
|
buffers reuse
|
||
|
- High-level wrappers and helpers around API in `wsutil` package, which allow
|
||
|
to start fast without digging the protocol internals
|
||
|
|
||
|
# Documentation
|
||
|
|
||
|
[GoDoc][godoc-url].
|
||
|
|
||
|
# Why
|
||
|
|
||
|
Existing WebSocket implementations do not allow users to reuse I/O buffers
|
||
|
between connections in clear way. This library aims to export efficient
|
||
|
low-level interface for working with the protocol without forcing only one way
|
||
|
it could be used.
|
||
|
|
||
|
By the way, if you want get the higher-level tools, you can use `wsutil`
|
||
|
package.
|
||
|
|
||
|
# Status
|
||
|
|
||
|
Library is tagged as `v1*` so its API must not be broken during some
|
||
|
improvements or refactoring.
|
||
|
|
||
|
This implementation of RFC6455 passes [Autobahn Test
|
||
|
Suite](https://github.com/crossbario/autobahn-testsuite) and currently has
|
||
|
about 78% coverage.
|
||
|
|
||
|
# Examples
|
||
|
|
||
|
Example applications using `ws` are developed in separate repository
|
||
|
[ws-examples](https://github.com/gobwas/ws-examples).
|
||
|
|
||
|
# Usage
|
||
|
|
||
|
The higher-level example of WebSocket echo server:
|
||
|
|
||
|
```go
|
||
|
package main
|
||
|
|
||
|
import (
|
||
|
"net/http"
|
||
|
|
||
|
"github.com/gobwas/ws"
|
||
|
"github.com/gobwas/ws/wsutil"
|
||
|
)
|
||
|
|
||
|
func main() {
|
||
|
http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
conn, _, _, err := ws.UpgradeHTTP(r, w)
|
||
|
if err != nil {
|
||
|
// handle error
|
||
|
}
|
||
|
go func() {
|
||
|
defer conn.Close()
|
||
|
|
||
|
for {
|
||
|
msg, op, err := wsutil.ReadClientData(conn)
|
||
|
if err != nil {
|
||
|
// handle error
|
||
|
}
|
||
|
err = wsutil.WriteServerMessage(conn, op, msg)
|
||
|
if err != nil {
|
||
|
// handle error
|
||
|
}
|
||
|
}
|
||
|
}()
|
||
|
}))
|
||
|
}
|
||
|
```
|
||
|
|
||
|
Lower-level, but still high-level example:
|
||
|
|
||
|
|
||
|
```go
|
||
|
import (
|
||
|
"net/http"
|
||
|
"io"
|
||
|
|
||
|
"github.com/gobwas/ws"
|
||
|
"github.com/gobwas/ws/wsutil"
|
||
|
)
|
||
|
|
||
|
func main() {
|
||
|
http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
conn, _, _, err := ws.UpgradeHTTP(r, w)
|
||
|
if err != nil {
|
||
|
// handle error
|
||
|
}
|
||
|
go func() {
|
||
|
defer conn.Close()
|
||
|
|
||
|
var (
|
||
|
state = ws.StateServerSide
|
||
|
reader = wsutil.NewReader(conn, state)
|
||
|
writer = wsutil.NewWriter(conn, state, ws.OpText)
|
||
|
)
|
||
|
for {
|
||
|
header, err := reader.NextFrame()
|
||
|
if err != nil {
|
||
|
// handle error
|
||
|
}
|
||
|
|
||
|
// Reset writer to write frame with right operation code.
|
||
|
writer.Reset(conn, state, header.OpCode)
|
||
|
|
||
|
if _, err = io.Copy(writer, reader); err != nil {
|
||
|
// handle error
|
||
|
}
|
||
|
if err = writer.Flush(); err != nil {
|
||
|
// handle error
|
||
|
}
|
||
|
}
|
||
|
}()
|
||
|
}))
|
||
|
}
|
||
|
```
|
||
|
|
||
|
We can apply the same pattern to read and write structured responses through a JSON encoder and decoder.:
|
||
|
|
||
|
```go
|
||
|
...
|
||
|
var (
|
||
|
r = wsutil.NewReader(conn, ws.StateServerSide)
|
||
|
w = wsutil.NewWriter(conn, ws.StateServerSide, ws.OpText)
|
||
|
decoder = json.NewDecoder(r)
|
||
|
encoder = json.NewEncoder(w)
|
||
|
)
|
||
|
for {
|
||
|
hdr, err = r.NextFrame()
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
if hdr.OpCode == ws.OpClose {
|
||
|
return io.EOF
|
||
|
}
|
||
|
var req Request
|
||
|
if err := decoder.Decode(&req); err != nil {
|
||
|
return err
|
||
|
}
|
||
|
var resp Response
|
||
|
if err := encoder.Encode(&resp); err != nil {
|
||
|
return err
|
||
|
}
|
||
|
if err = w.Flush(); err != nil {
|
||
|
return err
|
||
|
}
|
||
|
}
|
||
|
...
|
||
|
```
|
||
|
|
||
|
The lower-level example without `wsutil`:
|
||
|
|
||
|
```go
|
||
|
package main
|
||
|
|
||
|
import (
|
||
|
"net"
|
||
|
"io"
|
||
|
|
||
|
"github.com/gobwas/ws"
|
||
|
)
|
||
|
|
||
|
func main() {
|
||
|
ln, err := net.Listen("tcp", "localhost:8080")
|
||
|
if err != nil {
|
||
|
log.Fatal(err)
|
||
|
}
|
||
|
|
||
|
for {
|
||
|
conn, err := ln.Accept()
|
||
|
if err != nil {
|
||
|
// handle error
|
||
|
}
|
||
|
_, err = ws.Upgrade(conn)
|
||
|
if err != nil {
|
||
|
// handle error
|
||
|
}
|
||
|
|
||
|
go func() {
|
||
|
defer conn.Close()
|
||
|
|
||
|
for {
|
||
|
header, err := ws.ReadHeader(conn)
|
||
|
if err != nil {
|
||
|
// handle error
|
||
|
}
|
||
|
|
||
|
payload := make([]byte, header.Length)
|
||
|
_, err = io.ReadFull(conn, payload)
|
||
|
if err != nil {
|
||
|
// handle error
|
||
|
}
|
||
|
if header.Masked {
|
||
|
ws.Cipher(payload, header.Mask, 0)
|
||
|
}
|
||
|
|
||
|
// Reset the Masked flag, server frames must not be masked as
|
||
|
// RFC6455 says.
|
||
|
header.Masked = false
|
||
|
|
||
|
if err := ws.WriteHeader(conn, header); err != nil {
|
||
|
// handle error
|
||
|
}
|
||
|
if _, err := conn.Write(payload); err != nil {
|
||
|
// handle error
|
||
|
}
|
||
|
|
||
|
if header.OpCode == ws.OpClose {
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
}()
|
||
|
}
|
||
|
}
|
||
|
```
|
||
|
|
||
|
# Zero-copy upgrade
|
||
|
|
||
|
Zero-copy upgrade helps to avoid unnecessary allocations and copying while
|
||
|
handling HTTP Upgrade request.
|
||
|
|
||
|
Processing of all non-websocket headers is made in place with use of registered
|
||
|
user callbacks whose arguments are only valid until callback returns.
|
||
|
|
||
|
The simple example looks like this:
|
||
|
|
||
|
```go
|
||
|
package main
|
||
|
|
||
|
import (
|
||
|
"net"
|
||
|
"log"
|
||
|
|
||
|
"github.com/gobwas/ws"
|
||
|
)
|
||
|
|
||
|
func main() {
|
||
|
ln, err := net.Listen("tcp", "localhost:8080")
|
||
|
if err != nil {
|
||
|
log.Fatal(err)
|
||
|
}
|
||
|
u := ws.Upgrader{
|
||
|
OnHeader: func(key, value []byte) (err error) {
|
||
|
log.Printf("non-websocket header: %q=%q", key, value)
|
||
|
return
|
||
|
},
|
||
|
}
|
||
|
for {
|
||
|
conn, err := ln.Accept()
|
||
|
if err != nil {
|
||
|
// handle error
|
||
|
}
|
||
|
|
||
|
_, err = u.Upgrade(conn)
|
||
|
if err != nil {
|
||
|
// handle error
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
```
|
||
|
|
||
|
Usage of `ws.Upgrader` here brings ability to control incoming connections on
|
||
|
tcp level and simply not to accept them by some logic.
|
||
|
|
||
|
Zero-copy upgrade is for high-load services which have to control many
|
||
|
resources such as connections buffers.
|
||
|
|
||
|
The real life example could be like this:
|
||
|
|
||
|
```go
|
||
|
package main
|
||
|
|
||
|
import (
|
||
|
"fmt"
|
||
|
"io"
|
||
|
"log"
|
||
|
"net"
|
||
|
"net/http"
|
||
|
"runtime"
|
||
|
|
||
|
"github.com/gobwas/httphead"
|
||
|
"github.com/gobwas/ws"
|
||
|
)
|
||
|
|
||
|
func main() {
|
||
|
ln, err := net.Listen("tcp", "localhost:8080")
|
||
|
if err != nil {
|
||
|
// handle error
|
||
|
}
|
||
|
|
||
|
// Prepare handshake header writer from http.Header mapping.
|
||
|
header := ws.HandshakeHeaderHTTP(http.Header{
|
||
|
"X-Go-Version": []string{runtime.Version()},
|
||
|
})
|
||
|
|
||
|
u := ws.Upgrader{
|
||
|
OnHost: func(host []byte) error {
|
||
|
if string(host) == "github.com" {
|
||
|
return nil
|
||
|
}
|
||
|
return ws.RejectConnectionError(
|
||
|
ws.RejectionStatus(403),
|
||
|
ws.RejectionHeader(ws.HandshakeHeaderString(
|
||
|
"X-Want-Host: github.com\r\n",
|
||
|
)),
|
||
|
)
|
||
|
},
|
||
|
OnHeader: func(key, value []byte) error {
|
||
|
if string(key) != "Cookie" {
|
||
|
return nil
|
||
|
}
|
||
|
ok := httphead.ScanCookie(value, func(key, value []byte) bool {
|
||
|
// Check session here or do some other stuff with cookies.
|
||
|
// Maybe copy some values for future use.
|
||
|
return true
|
||
|
})
|
||
|
if ok {
|
||
|
return nil
|
||
|
}
|
||
|
return ws.RejectConnectionError(
|
||
|
ws.RejectionReason("bad cookie"),
|
||
|
ws.RejectionStatus(400),
|
||
|
)
|
||
|
},
|
||
|
OnBeforeUpgrade: func() (ws.HandshakeHeader, error) {
|
||
|
return header, nil
|
||
|
},
|
||
|
}
|
||
|
for {
|
||
|
conn, err := ln.Accept()
|
||
|
if err != nil {
|
||
|
log.Fatal(err)
|
||
|
}
|
||
|
_, err = u.Upgrade(conn)
|
||
|
if err != nil {
|
||
|
log.Printf("upgrade error: %s", err)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
```
|
||
|
|
||
|
# Compression
|
||
|
|
||
|
There is a `ws/wsflate` package to support [Permessage-Deflate Compression
|
||
|
Extension][rfc-pmce].
|
||
|
|
||
|
It provides minimalistic I/O wrappers to be used in conjunction with any
|
||
|
deflate implementation (for example, the standard library's
|
||
|
[compress/flate][compress/flate]).
|
||
|
|
||
|
It is also compatible with `wsutil`'s reader and writer by providing
|
||
|
`wsflate.MessageState` type, which implements `wsutil.SendExtension` and
|
||
|
`wsutil.RecvExtension` interfaces.
|
||
|
|
||
|
```go
|
||
|
package main
|
||
|
|
||
|
import (
|
||
|
"bytes"
|
||
|
"log"
|
||
|
"net"
|
||
|
|
||
|
"github.com/gobwas/ws"
|
||
|
"github.com/gobwas/ws/wsflate"
|
||
|
)
|
||
|
|
||
|
func main() {
|
||
|
ln, err := net.Listen("tcp", "localhost:8080")
|
||
|
if err != nil {
|
||
|
// handle error
|
||
|
}
|
||
|
e := wsflate.Extension{
|
||
|
// We are using default parameters here since we use
|
||
|
// wsflate.{Compress,Decompress}Frame helpers below in the code.
|
||
|
// This assumes that we use standard compress/flate package as flate
|
||
|
// implementation.
|
||
|
Parameters: wsflate.DefaultParameters,
|
||
|
}
|
||
|
u := ws.Upgrader{
|
||
|
Negotiate: e.Negotiate,
|
||
|
}
|
||
|
for {
|
||
|
conn, err := ln.Accept()
|
||
|
if err != nil {
|
||
|
log.Fatal(err)
|
||
|
}
|
||
|
|
||
|
// Reset extension after previous upgrades.
|
||
|
e.Reset()
|
||
|
|
||
|
_, err = u.Upgrade(conn)
|
||
|
if err != nil {
|
||
|
log.Printf("upgrade error: %s", err)
|
||
|
continue
|
||
|
}
|
||
|
if _, ok := e.Accepted(); !ok {
|
||
|
log.Printf("didn't negotiate compression for %s", conn.RemoteAddr())
|
||
|
conn.Close()
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
go func() {
|
||
|
defer conn.Close()
|
||
|
for {
|
||
|
frame, err := ws.ReadFrame(conn)
|
||
|
if err != nil {
|
||
|
// Handle error.
|
||
|
return
|
||
|
}
|
||
|
|
||
|
frame = ws.UnmaskFrameInPlace(frame)
|
||
|
|
||
|
if wsflate.IsCompressed(frame.Header) {
|
||
|
// Note that even after successful negotiation of
|
||
|
// compression extension, both sides are able to send
|
||
|
// non-compressed messages.
|
||
|
frame, err = wsflate.DecompressFrame(frame)
|
||
|
if err != nil {
|
||
|
// Handle error.
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Do something with frame...
|
||
|
|
||
|
ack := ws.NewTextFrame([]byte("this is an acknowledgement"))
|
||
|
|
||
|
// Compress response unconditionally.
|
||
|
ack, err = wsflate.CompressFrame(ack)
|
||
|
if err != nil {
|
||
|
// Handle error.
|
||
|
return
|
||
|
}
|
||
|
if err = ws.WriteFrame(conn, ack); err != nil {
|
||
|
// Handle error.
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
}()
|
||
|
}
|
||
|
}
|
||
|
```
|
||
|
|
||
|
You can use compression with `wsutil` package this way:
|
||
|
|
||
|
```go
|
||
|
// Upgrade somehow and negotiate compression to get the conn...
|
||
|
|
||
|
// Initialize flate reader. We are using nil as a source io.Reader because
|
||
|
// we will Reset() it in the message i/o loop below.
|
||
|
fr := wsflate.NewReader(nil, func(r io.Reader) wsflate.Decompressor {
|
||
|
return flate.NewReader(r)
|
||
|
})
|
||
|
// Initialize flate writer. We are using nil as a destination io.Writer
|
||
|
// because we will Reset() it in the message i/o loop below.
|
||
|
fw := wsflate.NewWriter(nil, func(w io.Writer) wsflate.Compressor {
|
||
|
f, _ := flate.NewWriter(w, 9)
|
||
|
return f
|
||
|
})
|
||
|
|
||
|
// Declare compression message state variable.
|
||
|
//
|
||
|
// It has two goals:
|
||
|
// - Allow users to check whether received message is compressed or not.
|
||
|
// - Help wsutil.Reader and wsutil.Writer to set/unset appropriate
|
||
|
// WebSocket header bits while writing next frame to the wire (it
|
||
|
// implements wsutil.RecvExtension and wsutil.SendExtension).
|
||
|
var msg wsflate.MessageState
|
||
|
|
||
|
// Initialize WebSocket reader as previously.
|
||
|
// Please note the use of Reader.Extensions field as well as
|
||
|
// of ws.StateExtended flag.
|
||
|
rd := &wsutil.Reader{
|
||
|
Source: conn,
|
||
|
State: ws.StateServerSide | ws.StateExtended,
|
||
|
Extensions: []wsutil.RecvExtension{
|
||
|
&msg,
|
||
|
},
|
||
|
}
|
||
|
|
||
|
// Initialize WebSocket writer with ws.StateExtended flag as well.
|
||
|
wr := wsutil.NewWriter(conn, ws.StateServerSide|ws.StateExtended, 0)
|
||
|
// Use the message state as wsutil.SendExtension.
|
||
|
wr.SetExtensions(&msg)
|
||
|
|
||
|
for {
|
||
|
h, err := rd.NextFrame()
|
||
|
if err != nil {
|
||
|
// handle error.
|
||
|
}
|
||
|
if h.OpCode.IsControl() {
|
||
|
// handle control frame.
|
||
|
}
|
||
|
if !msg.IsCompressed() {
|
||
|
// handle uncompressed frame (skipped for the sake of example
|
||
|
// simplicity).
|
||
|
}
|
||
|
|
||
|
// Reset the writer to echo same op code.
|
||
|
wr.Reset(h.OpCode)
|
||
|
|
||
|
// Reset both flate reader and writer to start the new round of i/o.
|
||
|
fr.Reset(rd)
|
||
|
fw.Reset(wr)
|
||
|
|
||
|
// Copy whole message from reader to writer decompressing it and
|
||
|
// compressing again.
|
||
|
if _, err := io.Copy(fw, fr); err != nil {
|
||
|
// handle error.
|
||
|
}
|
||
|
// Flush any remaining buffers from flate writer to WebSocket writer.
|
||
|
if err := fw.Close(); err != nil {
|
||
|
// handle error.
|
||
|
}
|
||
|
// Flush the whole WebSocket message to the wire.
|
||
|
if err := wr.Flush(); err != nil {
|
||
|
// handle error.
|
||
|
}
|
||
|
}
|
||
|
```
|
||
|
|
||
|
|
||
|
[rfc-url]: https://tools.ietf.org/html/rfc6455
|
||
|
[rfc-pmce]: https://tools.ietf.org/html/rfc7692#section-7
|
||
|
[godoc-image]: https://godoc.org/github.com/gobwas/ws?status.svg
|
||
|
[godoc-url]: https://godoc.org/github.com/gobwas/ws
|
||
|
[compress/flate]: https://golang.org/pkg/compress/flate/
|
||
|
[ci-badge]: https://github.com/gobwas/ws/workflows/CI/badge.svg
|
||
|
[ci-url]: https://github.com/gobwas/ws/actions?query=workflow%3ACI
|