diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 4e3aec7f4e52a9c84da7bd1c741d52758cade4ac..0000000000000000000000000000000000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -name: Bug report -about: Report problems and unexpected behaviour -title: '' -labels: bug -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behaviour, ideally with source code: - -1. ... -2. ... -3. ... - -**Expected behaviour** -A clear and concise description of what you expected to happen. - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 5d424b505f22e83501f293cd2602ddac5abc33fe..0000000000000000000000000000000000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea -title: '' -labels: feature -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context about the feature request here. diff --git a/README.md b/README.md index 921e8485195a83ca3dbc882c867d44ea94a1cd79..11309bc627652388f1630748763847b6edd9a0d8 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ websocket is a minimal and idiomatic WebSocket library for Go. -At minimum Go 1.12 is required as websocket uses a new [feature](https://github.com/golang/go/issues/26937#issuecomment-415855861) in net/http +Go 1.12 is required as it uses a new [feature](https://github.com/golang/go/issues/26937#issuecomment-415855861) in net/http to perform WebSocket handshakes. This library is not final and the API is subject to change. @@ -19,13 +19,12 @@ go get nhooyr.io/websocket ## Features -- Full support of the WebSocket protocol -- Zero dependencies outside of the stdlib -- Very minimal and carefully considered API -- context.Context is first class -- net/http is used for WebSocket dials and upgrades +- Minimal yet pragmatic API +- First class context.Context support - Thoroughly tested, fully passes the [autobahn-testsuite](https://github.com/crossbario/autobahn-testsuite) -- All returned errors include detailed context +- Concurrent writes +- Zero dependencies outside of the stdlib for the core library +- JSON and ProtoBuf helpers in the wsjson and wspb subpackages ## Roadmap @@ -97,45 +96,45 @@ c.Close(websocket.StatusNormalClosure, "") ## Design considerations -- Minimal API is easier to maintain and for others to learn +- Minimal API is easier to maintain and learn - Context based cancellation is more ergonomic and robust than setting deadlines -- No pings or pongs because TCP keep alives work fine for HTTP/1.1 and they do not make +- No ping support because TCP keep alives work fine for HTTP/1.1 and they do not make sense with HTTP/2 (see #1) - net.Conn is never exposed as WebSocket's over HTTP/2 will not have a net.Conn. -- Functional options make the API very clean and easy to extend +- Structures are nicer than functional options, see [google/go-cloud#908](https://github.com/google/go-cloud/issues/908#issuecomment-445034143) - Using net/http's Client for dialing means we do not have to reinvent dialing hooks - and configurations. Just pass in a custom net/http client if you want custom dialing. + and configurations like other WebSocket libraries ## Comparison While I believe nhooyr/websocket has a better API than existing libraries, both gorilla/websocket and gobwas/ws were extremely useful in implementing the -WebSocket protocol correctly so big thanks to the authors of both. In particular, +WebSocket protocol correctly so **big thanks** to the authors of both. In particular, I made sure to go through the issue tracker of gorilla/websocket to make sure -I implemented details correctly. +I implemented details correctly and understood how people were using the package +in production. ### gorilla/websocket https://github.com/gorilla/websocket -This package is the community standard but it is very old and over time -has accumulated cruft. There are many ways to do the same thing and the API -is not clear. Just compare the godoc of +This package is the community standard but it is 6 years old and over time +has accumulated cruft. There are many ways to do the same thing, usage is not clear +and there are some rough edges. Just compare the godoc of [nhooyr/websocket](godoc.org/github.com/nhooyr/websocket) side by side with [gorilla/websocket](godoc.org/github.com/gorilla/websocket). The API for nhooyr/websocket has been designed such that there is only one way to do things -which makes using it correctly and safely much easier. - -In terms of lines of code, this library is around 2000 whereas gorilla/websocket is -at 7000. So while the API for nhooyr/websocket is simpler, the implementation is also -significantly simpler and easier to test which reduces the surface are of bugs. +which makes it easy to use correctly. Furthermore, nhooyr/websocket has support for newer Go idioms such as context.Context and also uses net/http's Client and ResponseWriter directly for WebSocket handshakes. gorilla/websocket writes its handshakes directly to a net.Conn which means it has to reinvent hooks for TLS and proxying and prevents support of HTTP/2. +Another advantage of nhooyr/websocket is that it supports multiple concurrent writers out +of the box. + ### x/net/websocket https://godoc.org/golang.org/x/net/websocket @@ -149,12 +148,12 @@ See https://github.com/golang/go/issues/18152 https://github.com/gobwas/ws This library has an extremely flexible API but that comes at the cost of usability -and clarity. Its not clear what the best way to do anything is. +and clarity. This library is fantastic in terms of performance. The author put in significant effort to ensure its speed and I have applied as many of its optimizations as -I could into nhooyr/websocket. +I could into nhooyr/websocket. Definitely check out his fantastic [blog post](https://medium.freecodecamp.org/million-websockets-and-go-cc58418460bb) about performant WebSocket servers. If you want a library that gives you absolute control over everything, this is the library, -but for most users, the API provided by nhooyr/websocket will definitely fit better as it will -be just as performant but much easier to use correctly. +but for most users, the API provided by nhooyr/websocket will fit better as it is just as +performant but much easier to use correctly and idiomatic. diff --git a/accept.go b/accept.go index 4fb98088bef691087e7e3bc42c39585585cea4bf..9cf546f65b1044b1ab21ce4680d874f45c529966 100644 --- a/accept.go +++ b/accept.go @@ -34,8 +34,8 @@ type AcceptOptions struct { // The only time you need this is if your javascript is running on a different domain // than your WebSocket server. // Please think carefully about whether you really need this option before you use it. - // If you do, remember if you store secure data in cookies, you wil need to verify the - // Origin header. + // If you do, remember that if you store secure data in cookies, you wil need to verify the + // Origin header yourself otherwise you are exposing yourself to a CSRF attack. InsecureSkipVerify bool } diff --git a/bench_test.go b/bench_test.go index e631c61782310d453a2f870e669cf052a8643520..a97341c1e53a746bb0071c4ea3ceca3ee28b0e60 100644 --- a/bench_test.go +++ b/bench_test.go @@ -85,14 +85,14 @@ func benchConn(b *testing.B, stream bool) { }) } - // runN(32) - // runN(128) - // runN(512) - // runN(1024) + runN(32) + runN(128) + runN(512) + runN(1024) runN(4096) runN(16384) - // runN(65536) - // runN(131072) + runN(65536) + runN(131072) c.Close(websocket.StatusNormalClosure, "") }) diff --git a/ci/bench/entrypoint.sh b/ci/bench/entrypoint.sh index 0e32cd4b5ebf1c307e4cd26e3a4ca692741d5c2a..5f7dcf73c1dcc136cbf40b304d0b97c1e8872774 100755 --- a/ci/bench/entrypoint.sh +++ b/ci/bench/entrypoint.sh @@ -9,7 +9,7 @@ go test --vet=off --run=^$ -bench=. \ -memprofile=profs/mem \ -blockprofile=profs/block \ -mutexprofile=profs/mutex \ - ./... + . set +x echo diff --git a/ci/test/entrypoint.sh b/ci/test/entrypoint.sh index d076fe06000c2383c51e591d1d2d8b8574da4385..2a39593fa426ac6c18cb859a0236764dc9e0aec6 100755 --- a/ci/test/entrypoint.sh +++ b/ci/test/entrypoint.sh @@ -5,9 +5,11 @@ source ci/lib.sh || exit 1 mkdir -p profs set +x +echo echo "this step includes benchmarks for race detection and coverage purposes but the numbers will be misleading. please see the bench step for more accurate numbers" +echo set -x go test -race -coverprofile=profs/coverage --vet=off -bench=. ./... diff --git a/dial.go b/dial.go index 909990c697aebfc8ca7f476cb9406a04ae73ab63..eee40dd4ee0f6dd90a2c0b39ef97fe226c17f2af 100644 --- a/dial.go +++ b/dial.go @@ -22,8 +22,8 @@ type DialOptions struct { // http.Transport does this correctly. HTTPClient *http.Client - // Header specifies the HTTP headers included in the handshake request. - Header http.Header + // HTTPHeader specifies the HTTP headers included in the handshake request. + HTTPHeader http.Header // Subprotocols lists the subprotocols to negotiate with the server. Subprotocols []string @@ -47,8 +47,11 @@ func dial(ctx context.Context, u string, opts DialOptions) (_ *Conn, _ *http.Res if opts.HTTPClient == nil { opts.HTTPClient = http.DefaultClient } - if opts.Header == nil { - opts.Header = http.Header{} + if opts.HTTPClient.Timeout > 0 { + return nil, nil, xerrors.Errorf("please use context for cancellation instead of http.Client.Timeout; see issue nhooyr.io/websocket#67") + } + if opts.HTTPHeader == nil { + opts.HTTPHeader = http.Header{} } parsedURL, err := url.Parse(u) @@ -67,7 +70,7 @@ func dial(ctx context.Context, u string, opts DialOptions) (_ *Conn, _ *http.Res req, _ := http.NewRequest("GET", parsedURL.String(), nil) req = req.WithContext(ctx) - req.Header = opts.Header + req.Header = opts.HTTPHeader req.Header.Set("Connection", "Upgrade") req.Header.Set("Upgrade", "websocket") req.Header.Set("Sec-WebSocket-Version", "13") diff --git a/doc.go b/doc.go index 22e6327378945db92ef16c2057a0fabdcebb90ab..9c5a077d513531637af252419eb5f3f250146b3a 100644 --- a/doc.go +++ b/doc.go @@ -2,8 +2,14 @@ // // See https://tools.ietf.org/html/rfc6455 // +// Please see https://nhooyr.io/websocket for overview docs and a +// comparison with existing implementations. +// +// Conn, Dial, and Accept are the main entrypoints into this package. Use Dial to dial +// a WebSocket server, Accept to accept a WebSocket client dial and then Conn to interact +// with the resulting WebSocket connections. +// // The echo example is the best way to understand how to correctly use the library. // -// Please see https://nhooyr.io/websocket for detailed design docs and a comparison with existing -// libraries. +// The wsjson and wspb packages contain helpers for JSON and ProtoBuf messages. package websocket diff --git a/docs/CONTRIBUTING.md b/docs/contributing.md similarity index 84% rename from docs/CONTRIBUTING.md rename to docs/contributing.md index b33e722d2767ee517d5be755c2b66991f1c05a5f..3f267ef2985f2ed4fe333a460057db556dcde8aa 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/contributing.md @@ -1,4 +1,10 @@ -# Contributing Guidelines +# Contributing + +## Issues + +Please be as descriptive as possible with your description. + +## Pull requests Please split up changes into several small descriptive commits. diff --git a/example_echo_test.go b/example_echo_test.go index a196affb7d94de346813fa19fc023943a4d79717..f424eef3d23beacd375de1ae0c96e4e4fb447fb5 100644 --- a/example_echo_test.go +++ b/example_echo_test.go @@ -2,10 +2,8 @@ package websocket_test import ( "context" - "encoding/json" "fmt" "io" - "io/ioutil" "log" "net" "net/http" @@ -15,9 +13,16 @@ import ( "golang.org/x/xerrors" "nhooyr.io/websocket" + "nhooyr.io/websocket/wsjson" ) +// Example_echo starts a WebSocket echo server and +// then dials the server and sends 5 different messages +// and prints out the server's responses. func Example_echo() { + // First we listen on port 0, that means the OS will + // assign us a random free port. This is the listener + // the server will serve on and the client will connect to. l, err := net.Listen("tcp", "localhost:0") if err != nil { log.Fatalf("failed to listen: %v", err) @@ -37,6 +42,7 @@ func Example_echo() { } defer s.Close() + // This starts the echo server on the listener. go func() { err := s.Serve(l) if err != http.ErrServerClosed { @@ -44,19 +50,23 @@ func Example_echo() { } }() + // Now we dial the server and send the messages. err = client("ws://" + l.Addr().String()) if err != nil { log.Fatalf("client failed: %v", err) } // Output: - // {"i":0} - // {"i":1} - // {"i":2} - // {"i":3} - // {"i":4} + // 0 + // 1 + // 2 + // 3 + // 4 } +// echoServer is the WebSocket echo server implementation. +// It ensures the client speaks the echo subprotocol and +// only allows one message every 100ms with a 10 message burst. func echoServer(w http.ResponseWriter, r *http.Request) error { c, err := websocket.Accept(w, r, websocket.AcceptOptions{ Subprotocols: []string{"echo"}, @@ -82,6 +92,10 @@ func echoServer(w http.ResponseWriter, r *http.Request) error { } } +// echo reads from the websocket connection and then writes +// the received message back to it. +// It only waits 1 minute to read and write the message and +// limits the received message to 32768 bytes. func echo(ctx context.Context, c *websocket.Conn, l *rate.Limiter) error { ctx, cancel := context.WithTimeout(ctx, time.Minute) defer cancel() @@ -111,6 +125,9 @@ func echo(ctx context.Context, c *websocket.Conn, l *rate.Limiter) error { return err } +// client dials the WebSocket echo server at the given url. +// It then sends it 5 different messages and echo's the server's +// response to each. func client(url string) error { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() @@ -124,39 +141,20 @@ func client(url string) error { defer c.Close(websocket.StatusInternalError, "") for i := 0; i < 5; i++ { - w, err := c.Writer(ctx, websocket.MessageText) - if err != nil { - return err - } - - e := json.NewEncoder(w) - err = e.Encode(map[string]int{ + err = wsjson.Write(ctx, c, map[string]int{ "i": i, }) if err != nil { return err } - err = w.Close() - if err != nil { - return err - } - - typ, r, err := c.Reader(ctx) - if err != nil { - return err - } - - if typ != websocket.MessageText { - return xerrors.Errorf("expected text message but got %v", typ) - } - - msg2, err := ioutil.ReadAll(r) + v := map[string]int{} + err = wsjson.Read(ctx, c, &v) if err != nil { return err } - fmt.Printf("%s", msg2) + fmt.Printf("%v\n", v["i"]) } c.Close(websocket.StatusNormalClosure, "") diff --git a/go.mod b/go.mod index 928137e48e52634b1a94aa1ddb0e056a55ac72fb..f39fe6fddf8b152f2922be76899afba941b51adc 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module nhooyr.io/websocket go 1.12 require ( + github.com/golang/protobuf v1.3.1 github.com/google/go-cmp v0.2.0 github.com/kr/pretty v0.1.0 // indirect go.coder.com/go-tools v0.0.0-20190317003359-0c6a35b74a16 diff --git a/go.sum b/go.sum index 0e10a2c0e7ee601636cba6f970ed173318b21656..3d455a2c4b57920e6462c2a7a423955127e2cf1c 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= diff --git a/mask.go b/mask.go deleted file mode 100644 index 6c67f1c0a4c384f14c0d7746be82c8b8a35523fe..0000000000000000000000000000000000000000 --- a/mask.go +++ /dev/null @@ -1,17 +0,0 @@ -package websocket - -// mask applies the WebSocket masking algorithm to p -// with the given key where the first 3 bits of pos -// are the starting position in the key. -// See https://tools.ietf.org/html/rfc6455#section-5.3 -// -// The returned value is the position of the next byte -// to be used for masking in the key. This is so that -// unmasking can be performed without the entire frame. -func mask(key [4]byte, pos int, p []byte) int { - for i := range p { - p[i] ^= key[pos&3] - pos++ - } - return pos & 3 -} diff --git a/websocket.go b/websocket.go index 0c3c3eef9f68e15ea1dbf14d6d3444505495c43d..2f324d3acb3cbe8cc67528981729aa6379050524 100644 --- a/websocket.go +++ b/websocket.go @@ -231,7 +231,7 @@ func (c *Conn) handleControl(h header) { } if h.masked { - mask(h.maskKey, 0, b) + xor(h.maskKey, 0, b) } switch h.opcode { @@ -325,7 +325,7 @@ func (c *Conn) dataReadLoop(h header) (err error) { left -= int64(len(b)) if h.masked { - maskPos = mask(h.maskKey, maskPos, b) + maskPos = xor(h.maskKey, maskPos, b) } // Must set this before we signal the read is done. @@ -429,6 +429,8 @@ func (c *Conn) writeControl(ctx context.Context, opcode opcode, p []byte) error // a WebSocket message of type dataType to the connection. // Ensure you close the writer once you have written the entire message. // Concurrent calls to Writer are ok. +// Writer will block if there is another goroutine with an open writer +// until writer is closed. func (c *Conn) Writer(ctx context.Context, typ MessageType) (io.WriteCloser, error) { select { case <-c.closed: @@ -487,9 +489,12 @@ func (w messageWriter) Close() error { // Reader will wait until there is a WebSocket data message to read from the connection. // It returns the type of the message and a reader to read it. // The passed context will also bound the reader. +// // Your application must keep reading messages for the Conn to automatically respond to ping // and close frames and not become stuck waiting for a data message to be read. -// Please ensure to read the full message from io.Reader. +// Please ensure to read the full message from io.Reader. If you do not read till +// io.EOF, the connection will break unless the next read would have yielded io.EOF. +// // You can only read a single message at a time so do not call this method // concurrently. func (c *Conn) Reader(ctx context.Context) (MessageType, io.Reader, error) { diff --git a/websocket_test.go b/websocket_test.go index be03cb01a3e897e6268395ab00cde95b4350b439..2df8c946de05beff7fbc2f2e41d4b34d09a2c679 100644 --- a/websocket_test.go +++ b/websocket_test.go @@ -12,16 +12,21 @@ import ( "net/url" "os" "os/exec" + "reflect" "strconv" "strings" "sync/atomic" "testing" "time" + "github.com/golang/protobuf/ptypes" + "github.com/golang/protobuf/ptypes/duration" "github.com/google/go-cmp/cmp" "golang.org/x/xerrors" "nhooyr.io/websocket" + "nhooyr.io/websocket/wsjson" + "nhooyr.io/websocket/wspb" ) func TestHandshake(t *testing.T) { @@ -144,7 +149,7 @@ func TestHandshake(t *testing.T) { h := http.Header{} h.Set("Origin", "http://unauthorized.com") c, _, err := websocket.Dial(ctx, u, websocket.DialOptions{ - Header: h, + HTTPHeader: h, }) if err == nil { c.Close(websocket.StatusInternalError, "") @@ -167,7 +172,7 @@ func TestHandshake(t *testing.T) { h := http.Header{} h.Set("Origin", u) c, _, err := websocket.Dial(ctx, u, websocket.DialOptions{ - Header: h, + HTTPHeader: h, }) if err != nil { return err @@ -192,7 +197,7 @@ func TestHandshake(t *testing.T) { h := http.Header{} h.Set("Origin", "https://example.com") c, _, err := websocket.Dial(ctx, u, websocket.DialOptions{ - Header: h, + HTTPHeader: h, }) if err != nil { return err @@ -201,84 +206,139 @@ func TestHandshake(t *testing.T) { return nil }, }, - // { - // name: "echo", - // server: func(w http.ResponseWriter, r *http.Request) error { - // c, err := websocket.Accept(w, r, websocket.AcceptOptions{}) - // if err != nil { - // return err - // } - // defer c.Close(websocket.StatusInternalError, "") - // - // ctx, cancel := context.WithTimeout(r.Context(), time.Second*5) - // defer cancel() - // - // write := func() error { - // jc := websocket.JSONConn{ - // C: c, - // } - // - // v := map[string]interface{}{ - // "anmol": "wowow", - // } - // err = jc.Write(ctx, v) - // if err != nil { - // return err - // } - // return nil - // } - // err = write() - // if err != nil { - // return err - // } - // err = write() - // if err != nil { - // return err - // } - // - // c.Close(websocket.StatusNormalClosure, "") - // return nil - // }, - // client: func(ctx context.Context, u string) error { - // c, _, err := websocket.Dial(ctx, u, websocket.DialOptions{}) - // if err != nil { - // return err - // } - // defer c.Close(websocket.StatusInternalError, "") - // - // jc := websocket.JSONConn{ - // C: c, - // } - // - // read := func() error { - // var v interface{} - // err = jc.Read(ctx, &v) - // if err != nil { - // return err - // } - // - // exp := map[string]interface{}{ - // "anmol": "wowow", - // } - // if !reflect.DeepEqual(exp, v) { - // return xerrors.Errorf("expected %v but got %v", exp, v) - // } - // return nil - // } - // err = read() - // if err != nil { - // return err - // } - // // Read twice to ensure the un EOFed previous reader works correctly. - // err = read() - // if err != nil { - // return err - // } - // - // c.Close(websocket.StatusNormalClosure, "") - // return nil - // }, - // }, + { + name: "jsonEcho", + server: func(w http.ResponseWriter, r *http.Request) error { + c, err := websocket.Accept(w, r, websocket.AcceptOptions{}) + if err != nil { + return err + } + defer c.Close(websocket.StatusInternalError, "") + + ctx, cancel := context.WithTimeout(r.Context(), time.Second*5) + defer cancel() + + write := func() error { + v := map[string]interface{}{ + "anmol": "wowow", + } + err := wsjson.Write(ctx, c, v) + return err + } + err = write() + if err != nil { + return err + } + err = write() + if err != nil { + return err + } + + c.Close(websocket.StatusNormalClosure, "") + return nil + }, + client: func(ctx context.Context, u string) error { + c, _, err := websocket.Dial(ctx, u, websocket.DialOptions{}) + if err != nil { + return err + } + defer c.Close(websocket.StatusInternalError, "") + + read := func() error { + var v interface{} + err := wsjson.Read(ctx, c, &v) + if err != nil { + return err + } + + exp := map[string]interface{}{ + "anmol": "wowow", + } + if !reflect.DeepEqual(exp, v) { + return xerrors.Errorf("expected %v but got %v", exp, v) + } + return nil + } + err = read() + if err != nil { + return err + } + // Read twice to ensure the un EOFed previous reader works correctly. + err = read() + if err != nil { + return err + } + + c.Close(websocket.StatusNormalClosure, "") + return nil + }, + }, + { + name: "protobufEcho", + server: func(w http.ResponseWriter, r *http.Request) error { + c, err := websocket.Accept(w, r, websocket.AcceptOptions{}) + if err != nil { + return err + } + defer c.Close(websocket.StatusInternalError, "") + + ctx, cancel := context.WithTimeout(r.Context(), time.Second*5) + defer cancel() + + write := func() error { + err := wspb.Write(ctx, c, ptypes.DurationProto(100)) + return err + } + err = write() + if err != nil { + return err + } + err = write() + if err != nil { + return err + } + + c.Close(websocket.StatusNormalClosure, "") + return nil + }, + client: func(ctx context.Context, u string) error { + c, _, err := websocket.Dial(ctx, u, websocket.DialOptions{}) + if err != nil { + return err + } + defer c.Close(websocket.StatusInternalError, "") + + read := func() error { + var v duration.Duration + err := wspb.Read(ctx, c, &v) + if err != nil { + return err + } + + d, err := ptypes.Duration(&v) + if err != nil { + return xerrors.Errorf("failed to convert duration.Duration to time.Duration: %w", err) + } + const exp = time.Duration(100) + if !reflect.DeepEqual(exp, d) { + return xerrors.Errorf("expected %v but got %v", exp, d) + } + return nil + } + err = read() + if err != nil { + return err + } + // Read twice to ensure the un EOFed previous reader works correctly. + err = read() + if err != nil { + return err + } + + c.Close(websocket.StatusNormalClosure, "") + return nil + }, + }, { name: "cookies", server: func(w http.ResponseWriter, r *http.Request) error { diff --git a/wsjson/wsjson.go b/wsjson/wsjson.go new file mode 100644 index 0000000000000000000000000000000000000000..df67cf9b3441903e3618a43978bffdc9d857ba99 --- /dev/null +++ b/wsjson/wsjson.go @@ -0,0 +1,71 @@ +// Package wsjson provides helpers for JSON messages. +package wsjson + +import ( + "context" + "encoding/json" + "io" + + "golang.org/x/xerrors" + + "nhooyr.io/websocket" +) + +// Read reads a json message from c into v. +// It will read a message up to 32768 bytes in length. +func Read(ctx context.Context, c *websocket.Conn, v interface{}) error { + err := read(ctx, c, v) + if err != nil { + return xerrors.Errorf("failed to read json: %w", err) + } + return nil +} + +func read(ctx context.Context, c *websocket.Conn, v interface{}) error { + typ, r, err := c.Reader(ctx) + if err != nil { + return err + } + + if typ != websocket.MessageText { + return xerrors.Errorf("unexpected frame type for json (expected %v): %v", websocket.MessageText, typ) + } + + r = io.LimitReader(r, 32768) + + d := json.NewDecoder(r) + err = d.Decode(v) + if err != nil { + return xerrors.Errorf("failed to decode json: %w", err) + } + + return nil +} + +// Write writes the json message v to c. +func Write(ctx context.Context, c *websocket.Conn, v interface{}) error { + err := write(ctx, c, v) + if err != nil { + return xerrors.Errorf("failed to write json: %w", err) + } + return nil +} + +func write(ctx context.Context, c *websocket.Conn, v interface{}) error { + w, err := c.Writer(ctx, websocket.MessageText) + if err != nil { + return err + } + + e := json.NewEncoder(w) + err = e.Encode(v) + if err != nil { + return xerrors.Errorf("failed to encode json: %w", err) + } + + err = w.Close() + if err != nil { + return err + } + return nil +} diff --git a/wspb/wspb.go b/wspb/wspb.go new file mode 100644 index 0000000000000000000000000000000000000000..159e92d12fac9aa453780b60fb78c24f77dbbf94 --- /dev/null +++ b/wspb/wspb.go @@ -0,0 +1,80 @@ +// Package wspb provides helpers for protobuf messages. +package wspb + +import ( + "context" + "io" + "io/ioutil" + + "github.com/golang/protobuf/proto" + "golang.org/x/xerrors" + + "nhooyr.io/websocket" +) + +// Read reads a protobuf message from c into v. +// It will read a message up to 32768 bytes in length. +func Read(ctx context.Context, c *websocket.Conn, v proto.Message) error { + err := read(ctx, c, v) + if err != nil { + return xerrors.Errorf("failed to read protobuf: %w", err) + } + return nil +} + +func read(ctx context.Context, c *websocket.Conn, v proto.Message) error { + typ, r, err := c.Reader(ctx) + if err != nil { + return err + } + + if typ != websocket.MessageBinary { + return xerrors.Errorf("unexpected frame type for protobuf (expected %v): %v", websocket.MessageBinary, typ) + } + + r = io.LimitReader(r, 32768) + + b, err := ioutil.ReadAll(r) + if err != nil { + return xerrors.Errorf("failed to read message: %w", err) + } + + err = proto.Unmarshal(b, v) + if err != nil { + return xerrors.Errorf("failed to unmarshal protobuf: %w", err) + } + + return nil +} + +// Write writes the protobuf message v to c. +func Write(ctx context.Context, c *websocket.Conn, v proto.Message) error { + err := write(ctx, c, v) + if err != nil { + return xerrors.Errorf("failed to write protobuf: %w", err) + } + return nil +} + +func write(ctx context.Context, c *websocket.Conn, v proto.Message) error { + b, err := proto.Marshal(v) + if err != nil { + return xerrors.Errorf("failed to marshal protobuf: %w", err) + } + + w, err := c.Writer(ctx, websocket.MessageBinary) + if err != nil { + return err + } + + _, err = w.Write(b) + if err != nil { + return err + } + + err = w.Close() + if err != nil { + return err + } + return nil +} diff --git a/xor.go b/xor.go new file mode 100644 index 0000000000000000000000000000000000000000..1422f847d737248b82d8247a932af23f708575fc --- /dev/null +++ b/xor.go @@ -0,0 +1,42 @@ +package websocket + +import ( + "encoding/binary" +) + +// xor applies the WebSocket masking algorithm to p +// with the given key where the first 3 bits of pos +// are the starting position in the key. +// See https://tools.ietf.org/html/rfc6455#section-5.3 +// +// The returned value is the position of the next byte +// to be used for masking in the key. This is so that +// unmasking can be performed without the entire frame. +func xor(key [4]byte, keyPos int, b []byte) int { + // If the payload is greater than 16 bytes, then it's worth + // masking 8 bytes at a time. + // Optimization from https://github.com/golang/go/issues/31586#issuecomment-485530859 + if len(b) > 16 { + // We first create a key that is 8 bytes long + // and is aligned on the position correctly. + var alignedKey [8]byte + for i := range alignedKey { + alignedKey[i] = key[(i+keyPos)&3] + } + k := binary.LittleEndian.Uint64(alignedKey[:]) + + // Then we xor until b is less than 8 bytes. + for len(b) >= 8 { + v := binary.LittleEndian.Uint64(b) + binary.LittleEndian.PutUint64(b, v^k) + b = b[8:] + } + } + + // xor remaining bytes. + for i := range b { + b[i] ^= key[keyPos&3] + keyPos++ + } + return keyPos & 3 +} diff --git a/mask_test.go b/xor_test.go similarity index 87% rename from mask_test.go rename to xor_test.go index 4a7b8c73c3783bfc074630dd27a47ce64be72c17..f715eda14c3e15d24c5e2992d13e2e9c41ea91ce 100644 --- a/mask_test.go +++ b/xor_test.go @@ -6,13 +6,13 @@ import ( "github.com/google/go-cmp/cmp" ) -func Test_mask(t *testing.T) { +func Test_xor(t *testing.T) { t.Parallel() key := [4]byte{0xa, 0xb, 0xc, 0xff} p := []byte{0xa, 0xb, 0xc, 0xf2, 0xc} pos := 0 - pos = mask(key, pos, p) + pos = xor(key, pos, p) if exp := []byte{0, 0, 0, 0x0d, 0x6}; !cmp.Equal(exp, p) { t.Fatalf("unexpected mask: %v", cmp.Diff(exp, p))