diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d889ab59f67c7156d3952d6a31c7eb293e4080e..b07c54b8854de084a863df77470795bb535c453a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: [push] jobs: fmt: runs-on: ubuntu-latest - container: docker://nhooyr/websocket-ci@sha256:6f6a00284eff008ad2cece8f3d0b4a2a3a8f2fcf7a54c691c64a92403abc4c75 + container: docker://nhooyr/websocket-ci@sha256:b6331f8f64803c8b1bbd2a0ee9e2547317e0de2348bccd9c8dbcc1d88ff5747f steps: - uses: actions/checkout@v1 with: @@ -12,7 +12,7 @@ jobs: - run: ./ci/fmt.sh lint: runs-on: ubuntu-latest - container: docker://nhooyr/websocket-ci@sha256:6f6a00284eff008ad2cece8f3d0b4a2a3a8f2fcf7a54c691c64a92403abc4c75 + container: docker://nhooyr/websocket-ci@sha256:b6331f8f64803c8b1bbd2a0ee9e2547317e0de2348bccd9c8dbcc1d88ff5747f steps: - uses: actions/checkout@v1 with: @@ -20,7 +20,7 @@ jobs: - run: ./ci/lint.sh test: runs-on: ubuntu-latest - container: docker://nhooyr/websocket-ci@sha256:6f6a00284eff008ad2cece8f3d0b4a2a3a8f2fcf7a54c691c64a92403abc4c75 + container: docker://nhooyr/websocket-ci@sha256:b6331f8f64803c8b1bbd2a0ee9e2547317e0de2348bccd9c8dbcc1d88ff5747f steps: - uses: actions/checkout@v1 with: @@ -30,7 +30,7 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} wasm: runs-on: ubuntu-latest - container: docker://nhooyr/websocket-ci@sha256:6f6a00284eff008ad2cece8f3d0b4a2a3a8f2fcf7a54c691c64a92403abc4c75 + container: docker://nhooyr/websocket-ci@sha256:b6331f8f64803c8b1bbd2a0ee9e2547317e0de2348bccd9c8dbcc1d88ff5747f steps: - uses: actions/checkout@v1 with: diff --git a/README.md b/README.md index f25dc79ecaf619d51084b5e6a2bacfcf288b76a4..ed22b1ded3eb5e3cae6c54d39b4f25f00051465e 100644 --- a/README.md +++ b/README.md @@ -16,18 +16,18 @@ go get nhooyr.io/websocket ## Features - Minimal and idiomatic API -- Tiny codebase at 1700 lines +- Tiny codebase at 2200 lines - First class [context.Context](https://blog.golang.org/context) support - Thorough tests, fully passes the [autobahn-testsuite](https://github.com/crossbario/autobahn-testsuite) - [Zero dependencies](https://godoc.org/nhooyr.io/websocket?imports) - JSON and ProtoBuf helpers in the [wsjson](https://godoc.org/nhooyr.io/websocket/wsjson) and [wspb](https://godoc.org/nhooyr.io/websocket/wspb) subpackages - Highly optimized by default - Concurrent writes out of the box +- [Complete WASM](https://godoc.org/nhooyr.io/websocket#hdr-WASM) support ## Roadmap - [ ] WebSockets over HTTP/2 [#4](https://github.com/nhooyr/websocket/issues/4) -- [ ] WASM Compilation [#121](https://github.com/nhooyr/websocket/issues/121) ## Examples @@ -115,7 +115,7 @@ Just compare the godoc of The API for nhooyr/websocket has been designed such that there is only one way to do things which makes it easy to use correctly. Not only is the API simpler, the implementation is -only 1700 lines whereas gorilla/websocket is at 3500 lines. That's more code to maintain, +only 2200 lines whereas gorilla/websocket is at 3500 lines. That's more code to maintain, more code to test, more code to document and more surface area for bugs. Moreover, nhooyr/websocket has support for newer Go idioms such as context.Context and @@ -131,6 +131,8 @@ which results in awkward control flow. With nhooyr/websocket you use the Ping me that sends a ping and also waits for the pong, though you must be reading from the connection for the pong to be read. +Additionally, nhooyr.io/websocket can compile to [WASM](https://godoc.org/nhooyr.io/websocket#hdr-WASM) for the browser. + In terms of performance, the differences mostly depend on your application code. nhooyr/websocket reuses message buffers out of the box if you use the wsjson and wspb subpackages. As mentioned above, nhooyr/websocket also supports concurrent writers. diff --git a/accept.go b/accept.go index 11611d81564cbc2dea90ae3b811b5025feb8ee47..e68a049b32987c4e7eaebedc4fd4a4a48cad1a95 100644 --- a/accept.go +++ b/accept.go @@ -1,3 +1,5 @@ +// +build !js + package websocket import ( @@ -41,6 +43,12 @@ type AcceptOptions struct { } func verifyClientRequest(w http.ResponseWriter, r *http.Request) error { + if !r.ProtoAtLeast(1, 1) { + err := fmt.Errorf("websocket protocol violation: handshake request must be at least HTTP/1.1: %q", r.Proto) + http.Error(w, err.Error(), http.StatusBadRequest) + return err + } + if !headerValuesContainsToken(r.Header, "Connection", "Upgrade") { err := fmt.Errorf("websocket protocol violation: Connection header %q does not contain Upgrade", r.Header.Get("Connection")) http.Error(w, err.Error(), http.StatusBadRequest) diff --git a/accept_test.go b/accept_test.go index 6602a8d0e0a428e2e9ee4e8f123d2a98276a03bb..44a956a85c4bf7f16a7c6abdfa029de04810626a 100644 --- a/accept_test.go +++ b/accept_test.go @@ -1,3 +1,5 @@ +// +build !js + package websocket import ( @@ -45,6 +47,7 @@ func Test_verifyClientHandshake(t *testing.T) { testCases := []struct { name string method string + http1 bool h map[string]string success bool }{ @@ -86,6 +89,16 @@ func Test_verifyClientHandshake(t *testing.T) { "Sec-WebSocket-Key": "", }, }, + { + name: "badHTTPVersion", + h: map[string]string{ + "Connection": "Upgrade", + "Upgrade": "websocket", + "Sec-WebSocket-Version": "13", + "Sec-WebSocket-Key": "meow123", + }, + http1: true, + }, { name: "success", h: map[string]string{ @@ -106,6 +119,12 @@ func Test_verifyClientHandshake(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequest(tc.method, "/", nil) + r.ProtoMajor = 1 + r.ProtoMinor = 1 + if tc.http1 { + r.ProtoMinor = 0 + } + for k, v := range tc.h { r.Header.Set(k, v) } diff --git a/assert_test.go b/assert_test.go new file mode 100644 index 0000000000000000000000000000000000000000..cddae99d5d44351bc9f3f63f0d5ce31e38799846 --- /dev/null +++ b/assert_test.go @@ -0,0 +1,124 @@ +package websocket_test + +import ( + "context" + "encoding/hex" + "fmt" + "math/rand" + "reflect" + + "github.com/google/go-cmp/cmp" + + "nhooyr.io/websocket" + "nhooyr.io/websocket/wsjson" +) + +// https://github.com/google/go-cmp/issues/40#issuecomment-328615283 +func cmpDiff(exp, act interface{}) string { + return cmp.Diff(exp, act, deepAllowUnexported(exp, act)) +} + +func deepAllowUnexported(vs ...interface{}) cmp.Option { + m := make(map[reflect.Type]struct{}) + for _, v := range vs { + structTypes(reflect.ValueOf(v), m) + } + var typs []interface{} + for t := range m { + typs = append(typs, reflect.New(t).Elem().Interface()) + } + return cmp.AllowUnexported(typs...) +} + +func structTypes(v reflect.Value, m map[reflect.Type]struct{}) { + if !v.IsValid() { + return + } + switch v.Kind() { + case reflect.Ptr: + if !v.IsNil() { + structTypes(v.Elem(), m) + } + case reflect.Interface: + if !v.IsNil() { + structTypes(v.Elem(), m) + } + case reflect.Slice, reflect.Array: + for i := 0; i < v.Len(); i++ { + structTypes(v.Index(i), m) + } + case reflect.Map: + for _, k := range v.MapKeys() { + structTypes(v.MapIndex(k), m) + } + case reflect.Struct: + m[v.Type()] = struct{}{} + for i := 0; i < v.NumField(); i++ { + structTypes(v.Field(i), m) + } + } +} + +func assertEqualf(exp, act interface{}, f string, v ...interface{}) error { + if diff := cmpDiff(exp, act); diff != "" { + return fmt.Errorf(f+": %v", append(v, diff)...) + } + return nil +} + +func assertJSONEcho(ctx context.Context, c *websocket.Conn, n int) error { + exp := randString(n) + err := wsjson.Write(ctx, c, exp) + if err != nil { + return err + } + + var act interface{} + err = wsjson.Read(ctx, c, &act) + if err != nil { + return err + } + + return assertEqualf(exp, act, "unexpected JSON") +} + +func assertJSONRead(ctx context.Context, c *websocket.Conn, exp interface{}) error { + var act interface{} + err := wsjson.Read(ctx, c, &act) + if err != nil { + return err + } + + return assertEqualf(exp, act, "unexpected JSON") +} + +func randBytes(n int) []byte { + b := make([]byte, n) + rand.Read(b) + return b +} + +func randString(n int) string { + return hex.EncodeToString(randBytes(n))[:n] +} + +func assertEcho(ctx context.Context, c *websocket.Conn, typ websocket.MessageType, n int) error { + p := randBytes(n) + err := c.Write(ctx, typ, p) + if err != nil { + return err + } + typ2, p2, err := c.Read(ctx) + if err != nil { + return err + } + err = assertEqualf(typ, typ2, "unexpected data type") + if err != nil { + return err + } + return assertEqualf(p, p2, "unexpected payload") +} + +func assertSubprotocol(c *websocket.Conn, exp string) error { + return assertEqualf(exp, c.Subprotocol(), "unexpected subprotocol") +} diff --git a/ci/fmt.sh b/ci/fmt.sh index dee94e870c751ec0f3783434d20d81af2d6ce8ca..d6251e054bb488ae69146924f9d3df2f2ca9aaab 100755 --- a/ci/fmt.sh +++ b/ci/fmt.sh @@ -18,7 +18,7 @@ fmt() { go run go.coder.com/go-tools/cmd/goimports -w "-local=$(go list -m)" . go run mvdan.cc/sh/cmd/shfmt -i 2 -w -s -sr . # shellcheck disable=SC2046 - npx prettier \ + npx -q prettier \ --write \ --print-width 120 \ --no-semi \ diff --git a/ci/run.sh b/ci/run.sh index 9e47d29162a605692a31a85c8e07a8836b1d9dd6..1e386ff139bc4c850b52fe7e7572a4d56c691b43 100755 --- a/ci/run.sh +++ b/ci/run.sh @@ -6,7 +6,14 @@ set -euo pipefail cd "$(dirname "${0}")" cd "$(git rev-parse --show-toplevel)" +echo "--- fmt" ./ci/fmt.sh + +echo "--- lint" ./ci/lint.sh + +echo "--- test" ./ci/test.sh + +echo "--- wasm" ./ci/wasm.sh diff --git a/ci/test.sh b/ci/test.sh index 81d6f462a70e22aba61e55c291b91066fa8ab225..1f5b5102bda6001d322a9bbfc6b819ccfa84e72e 100755 --- a/ci/test.sh +++ b/ci/test.sh @@ -16,9 +16,13 @@ if [[ ${CI-} ]]; then ) fi -argv+=( - "$@" -) +if [[ $# -gt 0 ]]; then + argv+=( + "$@" + ) +else + argv+=(./...) +fi mkdir -p ci/out/websocket "${argv[@]}" diff --git a/ci/tools.go b/ci/tools.go index 5aebe7d42cfb97fe7df6bd7d7bc1d9995135d0e2..1ec11eb4a6fd5647db91b9e7f8df6d38a26bd944 100644 --- a/ci/tools.go +++ b/ci/tools.go @@ -4,6 +4,7 @@ package ci // See https://github.com/go-modules-by-example/index/blob/master/010_tools/README.md import ( + _ "github.com/agnivade/wasmbrowsertest" _ "go.coder.com/go-tools/cmd/goimports" _ "golang.org/x/lint/golint" _ "golang.org/x/tools/cmd/stringer" diff --git a/ci/wasm.sh b/ci/wasm.sh index 943d380626f6c0ec7d9969aab085236091a93556..0290f188df5f31132fbbd065235a84caeb8456bb 100755 --- a/ci/wasm.sh +++ b/ci/wasm.sh @@ -5,7 +5,26 @@ cd "$(dirname "${0}")" cd "$(git rev-parse --show-toplevel)" GOOS=js GOARCH=wasm go vet ./... + go install golang.org/x/lint/golint -# Get passing later. -#GOOS=js GOARCH=wasm golint -set_exit_status ./... -GOOS=js GOARCH=wasm go test ./internal/wsjs +GOOS=js GOARCH=wasm golint -set_exit_status ./... + +wsjstestOut="$(mktemp -d)/stdout" +mkfifo "$wsjstestOut" +go install ./internal/wsjstest +timeout 30s wsjstest > "$wsjstestOut" & +wsjstestPID=$! + +WS_ECHO_SERVER_URL="$(timeout 10s head -n 1 "$wsjstestOut")" || true +if [[ -z $WS_ECHO_SERVER_URL ]]; then + echo "./internal/wsjstest failed to start in 10s" + exit 1 +fi + +go install github.com/agnivade/wasmbrowsertest +GOOS=js GOARCH=wasm go test -exec=wasmbrowsertest ./... -args "$WS_ECHO_SERVER_URL" + +if ! wait "$wsjstestPID"; then + echo "wsjstest exited unsuccessfully" + exit 1 +fi diff --git a/cmp_test.go b/cmp_test.go deleted file mode 100644 index ad4cd75a07fa2b61b49e101b938f00f1bcfa4a70..0000000000000000000000000000000000000000 --- a/cmp_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package websocket_test - -import ( - "reflect" - - "github.com/google/go-cmp/cmp" -) - -// https://github.com/google/go-cmp/issues/40#issuecomment-328615283 -func cmpDiff(exp, act interface{}) string { - return cmp.Diff(exp, act, deepAllowUnexported(exp, act)) -} - -func deepAllowUnexported(vs ...interface{}) cmp.Option { - m := make(map[reflect.Type]struct{}) - for _, v := range vs { - structTypes(reflect.ValueOf(v), m) - } - var typs []interface{} - for t := range m { - typs = append(typs, reflect.New(t).Elem().Interface()) - } - return cmp.AllowUnexported(typs...) -} - -func structTypes(v reflect.Value, m map[reflect.Type]struct{}) { - if !v.IsValid() { - return - } - switch v.Kind() { - case reflect.Ptr: - if !v.IsNil() { - structTypes(v.Elem(), m) - } - case reflect.Interface: - if !v.IsNil() { - structTypes(v.Elem(), m) - } - case reflect.Slice, reflect.Array: - for i := 0; i < v.Len(); i++ { - structTypes(v.Index(i), m) - } - case reflect.Map: - for _, k := range v.MapKeys() { - structTypes(v.MapIndex(k), m) - } - case reflect.Struct: - m[v.Type()] = struct{}{} - for i := 0; i < v.NumField(); i++ { - structTypes(v.Field(i), m) - } - } -} diff --git a/dial.go b/dial.go index 51d2af807b5775d7bfbdf32b461eb60dfdc49b9f..79232aac86c0bd4dcbeea2137a01898eaee3bd3e 100644 --- a/dial.go +++ b/dial.go @@ -1,3 +1,5 @@ +// +build !js + package websocket import ( @@ -149,6 +151,10 @@ func verifyServerResponse(r *http.Request, resp *http.Response) error { ) } + if proto := resp.Header.Get("Sec-WebSocket-Protocol"); proto != "" && !headerValuesContainsToken(r.Header, "Sec-WebSocket-Protocol", proto) { + return fmt.Errorf("websocket protocol violation: unexpected Sec-WebSocket-Protocol from server: %q", proto) + } + return nil } diff --git a/dial_test.go b/dial_test.go index 96537bdbbd824b57e3a4f864bd9ed2c83514787b..083b9bf3ed6bea5984a751f1e2a5fac7954d757a 100644 --- a/dial_test.go +++ b/dial_test.go @@ -1,3 +1,5 @@ +// +build !js + package websocket import ( @@ -97,6 +99,16 @@ func Test_verifyServerHandshake(t *testing.T) { }, success: false, }, + { + name: "badSecWebSocketProtocol", + response: func(w http.ResponseWriter) { + w.Header().Set("Connection", "Upgrade") + w.Header().Set("Upgrade", "websocket") + w.Header().Set("Sec-WebSocket-Protocol", "xd") + w.WriteHeader(http.StatusSwitchingProtocols) + }, + success: false, + }, { name: "success", response: func(w http.ResponseWriter) { diff --git a/doc.go b/doc.go index 189952571860dae98e415388c2fb08ea729210ec..4c07d37ab10173cc1866b3ab1d6af298b229bc7b 100644 --- a/doc.go +++ b/doc.go @@ -1,3 +1,5 @@ +// +build !js + // Package websocket is a minimal and idiomatic implementation of the WebSocket protocol. // // https://tools.ietf.org/html/rfc6455 @@ -15,4 +17,27 @@ // // Use the errors.As function new in Go 1.13 to check for websocket.CloseError. // See the CloseError example. +// +// WASM +// +// The client side fully supports compiling to WASM. +// It wraps the WebSocket browser API. +// See https://developer.mozilla.org/en-US/docs/Web/API/WebSocket +// +// Thus the unsupported features when compiling to WASM are: +// - Accept API +// - Reader/Writer API +// - SetReadLimit +// - Ping +// - HTTPClient and HTTPHeader dial options +// +// The *http.Response returned by Dial will always either be nil or &http.Response{} as +// we do not have access to the handshake response in the browser. +// +// Writes are also always async so the passed context is no-op. +// +// Everything else is fully supported. This includes the wsjson and wspb helper packages. +// +// Once https://github.com/gopherjs/gopherjs/issues/929 is closed, GopherJS should be supported +// as well. package websocket // import "nhooyr.io/websocket" diff --git a/example_echo_test.go b/example_echo_test.go index aad326756e03a362d4001b41e266a31e6723c447..b1afe8b3552e14b23421b72c89d51ba621fa2b94 100644 --- a/example_echo_test.go +++ b/example_echo_test.go @@ -1,3 +1,5 @@ +// +build !js + package websocket_test import ( diff --git a/example_test.go b/example_test.go index 36cab2bd6b3698c9bd97f1781e6eb37347437899..2cedddf384e3b37931670ecf5e79cde470471a31 100644 --- a/example_test.go +++ b/example_test.go @@ -1,3 +1,5 @@ +// +build !js + package websocket_test import ( diff --git a/export_test.go b/export_test.go index 5a0d1c32482aee5d69f9c6362c46ce3a1bcb1340..32340b56d7ad385e7998652b3a26e4a04b277451 100644 --- a/export_test.go +++ b/export_test.go @@ -1,3 +1,5 @@ +// +build !js + package websocket import ( diff --git a/go.mod b/go.mod index 34a7f872d72e550fd0f6945250462f979bf55100..86a9403b244132a9b0291031e1588bdcd40a869d 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module nhooyr.io/websocket go 1.13 require ( + github.com/agnivade/wasmbrowsertest v0.3.0 github.com/fatih/color v1.7.0 // indirect github.com/golang/protobuf v1.3.2 github.com/google/go-cmp v0.3.1 diff --git a/go.sum b/go.sum index 97d6a8358df28bc7ea0ca370efcdd26e3cca2489..4af0094612a0141082b8b901d1c8e38396e3510e 100644 --- a/go.sum +++ b/go.sum @@ -1,23 +1,44 @@ +github.com/agnivade/wasmbrowsertest v0.3.0 h1:5pAabhWzTVCLoVWqYejEbmWyzNGFR7K/Nu5lsmD1fVc= +github.com/agnivade/wasmbrowsertest v0.3.0/go.mod h1:zQt6ZTdl338xxRaMW395qccVE2eQm0SjC/SDz0mPWQI= +github.com/chromedp/cdproto v0.0.0-20190614062957-d6d2f92b486d/go.mod h1:S8mB5wY3vV+vRIzf39xDXsw3XKYewW9X6rW2aEmkrSw= +github.com/chromedp/cdproto v0.0.0-20190621002710-8cbd498dd7a0 h1:4Wocv9f+KWF4GtZudyrn8JSBTgHQbGp86mcsoH7j1iQ= +github.com/chromedp/cdproto v0.0.0-20190621002710-8cbd498dd7a0/go.mod h1:S8mB5wY3vV+vRIzf39xDXsw3XKYewW9X6rW2aEmkrSw= +github.com/chromedp/chromedp v0.3.1-0.20190619195644-fd957a4d2901 h1:tg66ykM8VYqP9k4DFQwSMnYv84HNTruF+GR6kefFNg4= +github.com/chromedp/chromedp v0.3.1-0.20190619195644-fd957a4d2901/go.mod h1:mJdvfrVn594N9tfiPecUidF6W5jPRKHymqHfzbobPsM= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/edsrzf/mmap-go v1.0.0 h1:CEBF7HpRnUCSJgGUb5h1Gm7e3VkmVDrR8lvWVLtrOFw= +github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= github.com/fatih/color v1.6.0 h1:66qjqZk8kalYAvDRtM1AdAJQI0tj4Wrue3Eq3B3pmFU= github.com/fatih/color v1.6.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-interpreter/wagon v0.5.1-0.20190713202023-55a163980b6c h1:DLLAPVFrk9iNzljMKF512CUmrFImQ6WU3sDiUS4IRqk= +github.com/go-interpreter/wagon v0.5.1-0.20190713202023-55a163980b6c/go.mod h1:5+b/MBYkclRZngKF5s6qrgWxSLgE9F5dFdO1hAueZLc= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= +github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f h1:Jnx61latede7zDD3DiiP4gmNz33uK0U5HDUaF0a/HVQ= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/jonboulle/clockwork v0.1.0 h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307 h1:vl4eIlySbjertFaNwiMjXsGrFVK25aOWLq7n+3gh2ls= +github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307/go.mod h1:BjPj+aVjl9FW/cCGiF3nGh5v+9Gd3VCgBQbod/GlMaQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= @@ -27,6 +48,10 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190620125010-da37f6c1e481 h1:IaSjLMT6WvkoZZjspGxy3rdaTEmWLoRm49WbtVUi9sA= +github.com/mailru/easyjson v0.0.0-20190620125010-da37f6c1e481/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= @@ -61,6 +86,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc h1:RTUQlKzoZZVG3umWNzOYeFecQLIh+dbxXvJp1zPQJTI= +github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc/go.mod h1:NoCfSFWosfqMqmmD7hApkirIK9ozpHjxRnRxs1l413A= go.coder.com/go-tools v0.0.0-20190317003359-0c6a35b74a16 h1:3gGa1bM0nG7Ruhu5b7wKnoOOwAD/fJ8iyyAcpOzDG3A= go.coder.com/go-tools v0.0.0-20190317003359-0c6a35b74a16/go.mod h1:iKV5yK9t+J5nG9O3uF6KYdPEz3dyfMyB15MN1rbQ8Qw= go.uber.org/atomic v1.4.0 h1:cxzIVoETapQEqDhQu3QfnvXAV4AlzcvUCxkVUFw3+EU= @@ -86,7 +113,10 @@ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190306220234-b354f8bf4d9e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190618155005-516e3c20635f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190712062909-fae7ac547cb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190919044723-0c1ff786ef13 h1:/zi0zzlPHWXYXrO1LjNRByFu8sdGgCkj2JLDdBIB84k= golang.org/x/sys v0.0.0-20190919044723-0c1ff786ef13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -97,6 +127,7 @@ golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxb golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190920225731-5eefd052ad72 h1:bw9doJza/SFBEweII/rHQh338oozWyiFsBRHtrflcws= golang.org/x/tools v0.0.0-20190920225731-5eefd052ad72/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/airbrake/gobrake.v2 v2.0.9 h1:7z2uVWwn7oVeeugY1DtlPAy5H+KYgB1KeKTnqjNatLo= gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= diff --git a/header.go b/header.go index 6eb8610f5a65c067a581afab3214dd3ee79a57e4..613b1d1510ffca71cdaf800a0d2cbbbec20c6833 100644 --- a/header.go +++ b/header.go @@ -1,3 +1,5 @@ +// +build !js + package websocket import ( diff --git a/header_test.go b/header_test.go index 45d0535ae52d1b5aaa8909b736a687ae3632e6c4..5d0fd6a264fcdbacd9f6ef6cd2ce719929f9fb44 100644 --- a/header_test.go +++ b/header_test.go @@ -1,3 +1,5 @@ +// +build !js + package websocket import ( diff --git a/internal/wsecho/wsecho.go b/internal/wsecho/wsecho.go new file mode 100644 index 0000000000000000000000000000000000000000..c408f07f725e6aa13b6d4f73251c4dd764fb4dc0 --- /dev/null +++ b/internal/wsecho/wsecho.go @@ -0,0 +1,55 @@ +// +build !js + +package wsecho + +import ( + "context" + "io" + "time" + + "nhooyr.io/websocket" +) + +// Loop echos every msg received from c until an error +// occurs or the context expires. +// The read limit is set to 1 << 30. +func Loop(ctx context.Context, c *websocket.Conn) error { + defer c.Close(websocket.StatusInternalError, "") + + c.SetReadLimit(1 << 30) + + ctx, cancel := context.WithTimeout(ctx, time.Minute) + defer cancel() + + b := make([]byte, 32<<10) + echo := func() error { + typ, r, err := c.Reader(ctx) + if err != nil { + return err + } + + w, err := c.Writer(ctx, typ) + if err != nil { + return err + } + + _, err = io.CopyBuffer(w, r, b) + if err != nil { + return err + } + + err = w.Close() + if err != nil { + return err + } + + return nil + } + + for { + err := echo() + if err != nil { + return err + } + } +} diff --git a/internal/wsjs/wsjs.go b/internal/wsjs/wsjs.go index 4adb71ad2072a3d0e27616d4d8906d99d4ee97ff..68078cf2e74c6fd9614d2d8f3f9a8de91930e200 100644 --- a/internal/wsjs/wsjs.go +++ b/internal/wsjs/wsjs.go @@ -1,11 +1,11 @@ // +build js // Package wsjs implements typed access to the browser javascript WebSocket API. +// // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket package wsjs import ( - "context" "syscall/js" ) @@ -26,9 +26,10 @@ func handleJSError(err *error, onErr func()) { } } -func New(ctx context.Context, url string, protocols []string) (c *WebSocket, err error) { +// New is a wrapper around the javascript WebSocket constructor. +func New(url string, protocols []string) (c WebSocket, err error) { defer handleJSError(&err, func() { - c = nil + c = WebSocket{} }) jsProtocols := make([]interface{}, len(protocols)) @@ -36,11 +37,11 @@ func New(ctx context.Context, url string, protocols []string) (c *WebSocket, err jsProtocols[i] = p } - c = &WebSocket{ + c = WebSocket{ v: js.Global().Get("WebSocket").New(url, jsProtocols), } - c.setBinaryType("arrayBuffer") + c.setBinaryType("arraybuffer") c.Extensions = c.v.Get("extensions").String() c.Protocol = c.v.Get("protocol").String() @@ -49,6 +50,7 @@ func New(ctx context.Context, url string, protocols []string) (c *WebSocket, err return c, nil } +// WebSocket is a wrapper around a javascript WebSocket object. type WebSocket struct { Extensions string Protocol string @@ -57,29 +59,33 @@ type WebSocket struct { v js.Value } -func (c *WebSocket) setBinaryType(typ string) { +func (c WebSocket) setBinaryType(typ string) { c.v.Set("binaryType", string(typ)) } -func (c *WebSocket) BufferedAmount() uint32 { - return uint32(c.v.Get("bufferedAmount").Int()) -} - -func (c *WebSocket) addEventListener(eventType string, fn func(e js.Value)) { - c.v.Call("addEventListener", eventType, js.FuncOf(func(this js.Value, args []js.Value) interface{} { +func (c WebSocket) addEventListener(eventType string, fn func(e js.Value)) func() { + f := js.FuncOf(func(this js.Value, args []js.Value) interface{} { fn(args[0]) return nil - })) + }) + c.v.Call("addEventListener", eventType, f) + + return func() { + c.v.Call("removeEventListener", eventType, f) + f.Release() + } } +// CloseEvent is the type passed to a WebSocket close handler. type CloseEvent struct { Code uint16 Reason string WasClean bool } -func (c *WebSocket) OnClose(fn func(CloseEvent)) { - c.addEventListener("close", func(e js.Value) { +// OnClose registers a function to be called when the WebSocket is closed. +func (c WebSocket) OnClose(fn func(CloseEvent)) (remove func()) { + return c.addEventListener("close", func(e js.Value) { ce := CloseEvent{ Code: uint16(e.Get("code").Int()), Reason: e.Get("reason").String(), @@ -89,23 +95,29 @@ func (c *WebSocket) OnClose(fn func(CloseEvent)) { }) } -func (c *WebSocket) OnError(fn func(e js.Value)) { - c.addEventListener("error", fn) +// OnError registers a function to be called when there is an error +// with the WebSocket. +func (c WebSocket) OnError(fn func(e js.Value)) (remove func()) { + return c.addEventListener("error", fn) } +// MessageEvent is the type passed to a message handler. type MessageEvent struct { - Data []byte - // There are more types to the interface but we don't use them. + // string or []byte. + Data interface{} + + // There are more fields to the interface but we don't use them. // See https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent } -func (c *WebSocket) OnMessage(fn func(m MessageEvent)) { - c.addEventListener("message", func(e js.Value) { - var data []byte +// OnMessage registers a function to be called when the websocket receives a message. +func (c WebSocket) OnMessage(fn func(m MessageEvent)) (remove func()) { + return c.addEventListener("message", func(e js.Value) { + var data interface{} arrayBuffer := e.Get("data") if arrayBuffer.Type() == js.TypeString { - data = []byte(arrayBuffer.String()) + data = arrayBuffer.String() } else { data = extractArrayBuffer(arrayBuffer) } @@ -119,23 +131,29 @@ func (c *WebSocket) OnMessage(fn func(m MessageEvent)) { }) } -func (c *WebSocket) OnOpen(fn func(e js.Value)) { - c.addEventListener("open", fn) +// OnOpen registers a function to be called when the websocket is opened. +func (c WebSocket) OnOpen(fn func(e js.Value)) (remove func()) { + return c.addEventListener("open", fn) } -func (c *WebSocket) Close(code int, reason string) (err error) { +// Close closes the WebSocket with the given code and reason. +func (c WebSocket) Close(code int, reason string) (err error) { defer handleJSError(&err, nil) c.v.Call("close", code, reason) return err } -func (c *WebSocket) SendText(v string) (err error) { +// SendText sends the given string as a text message +// on the WebSocket. +func (c WebSocket) SendText(v string) (err error) { defer handleJSError(&err, nil) c.v.Call("send", v) return err } -func (c *WebSocket) SendBytes(v []byte) (err error) { +// SendBytes sends the given message as a binary message +// on the WebSocket. +func (c WebSocket) SendBytes(v []byte) (err error) { defer handleJSError(&err, nil) c.v.Call("send", uint8Array(v)) return err diff --git a/internal/wsjs/wsjs_test.go b/internal/wsjs/wsjs_test.go deleted file mode 100644 index 4f5f18789845d93665d16a72a8a87d8b4f8a3e02..0000000000000000000000000000000000000000 --- a/internal/wsjs/wsjs_test.go +++ /dev/null @@ -1,26 +0,0 @@ -// +build js - -package wsjs - -import ( - "context" - "syscall/js" - "testing" - "time" -) - -func TestWebSocket(t *testing.T) { - t.Parallel() - - c, err := New(context.Background(), "ws://localhost:8081", nil) - if err != nil { - t.Fatal(err) - } - - c.OnError(func(e js.Value) { - t.Log(js.Global().Get("JSON").Call("stringify", e)) - t.Log(c.v.Get("readyState")) - }) - - time.Sleep(time.Second) -} diff --git a/internal/wsjstest/main.go b/internal/wsjstest/main.go new file mode 100644 index 0000000000000000000000000000000000000000..a1ad1b0201f0ffdd5314f10100be8a7c963560e0 --- /dev/null +++ b/internal/wsjstest/main.go @@ -0,0 +1,43 @@ +// +build !js + +package main + +import ( + "errors" + "fmt" + "log" + "net/http" + "net/http/httptest" + "os" + "runtime" + "strings" + + "nhooyr.io/websocket" + "nhooyr.io/websocket/internal/wsecho" +) + +func main() { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + Subprotocols: []string{"echo"}, + InsecureSkipVerify: true, + }) + if err != nil { + log.Fatalf("echo server: failed to accept: %+v", err) + } + defer c.Close(websocket.StatusInternalError, "") + + err = wsecho.Loop(r.Context(), c) + + var ce websocket.CloseError + if !errors.As(err, &ce) || ce.Code != websocket.StatusNormalClosure { + log.Fatalf("unexpected loop error: %+v", err) + } + + os.Exit(0) + })) + wsURL := strings.Replace(s.URL, "http", "ws", 1) + fmt.Printf("%v\n", wsURL) + + runtime.Goexit() +} diff --git a/netconn.go b/netconn.go index 20b99c2a69d531245263235fe36db69f26659a0a..8efdade22d9b172c29dfe6053cb7c7e292b5a32d 100644 --- a/netconn.go +++ b/netconn.go @@ -93,7 +93,7 @@ func (c *netConn) Read(p []byte) (int, error) { } if c.reader == nil { - typ, r, err := c.c.Reader(c.readContext) + typ, r, err := c.netConnReader(c.readContext) if err != nil { var ce CloseError if errors.As(err, &ce) && (ce.Code == StatusNormalClosure) || (ce.Code == StatusGoingAway) { diff --git a/opcode.go b/opcode.go index 86f94bd999ce8f9463ad8c291ce91503433f8369..df708aa0baa8e8c4630bab0201bed823ab2dd470 100644 --- a/opcode.go +++ b/opcode.go @@ -3,7 +3,7 @@ package websocket // opcode represents a WebSocket Opcode. type opcode int -//go:generate go run golang.org/x/tools/cmd/stringer -type=opcode +//go:generate go run golang.org/x/tools/cmd/stringer -type=opcode -tags js // opcode constants. const ( diff --git a/opcode_string.go b/opcode_string.go index 740b5e709a6b6ea12b1fba25ba855bc970e0c14d..d7b88961e4765a28c102310ad9ea564f9c042dcc 100644 --- a/opcode_string.go +++ b/opcode_string.go @@ -1,4 +1,4 @@ -// Code generated by "stringer -type=opcode"; DO NOT EDIT. +// Code generated by "stringer -type=opcode -tags js"; DO NOT EDIT. package websocket diff --git a/statuscode.go b/statuscode.go index d2a64d62e8be495b779e9241dadc0995abde98c3..e7bb94999b45fc0279feddce500458376cee767b 100644 --- a/statuscode.go +++ b/statuscode.go @@ -18,12 +18,15 @@ const ( StatusGoingAway StatusProtocolError StatusUnsupportedData + _ // 1004 is reserved. + StatusNoStatusRcvd - // statusAbnormalClosure is unexported because it isn't necessary, at least until WASM. - // The error returned will indicate whether the connection was closed or not or what happened. - // It only makes sense for browser clients. - statusAbnormalClosure + + // This StatusCode is only exported for use with WASM. + // In pure Go, the returned error will indicate whether the connection was closed or not or what happened. + StatusAbnormalClosure + StatusInvalidFramePayloadData StatusPolicyViolation StatusMessageTooBig @@ -32,10 +35,10 @@ const ( StatusServiceRestart StatusTryAgainLater StatusBadGateway - // statusTLSHandshake is unexported because we just return - // the handshake error in dial. We do not return a conn - // so there is nothing to use this on. At least until WASM. - statusTLSHandshake + + // This StatusCode is only exported for use with WASM. + // In pure Go, the returned error will indicate whether there was a TLS handshake failure. + StatusTLSHandshake ) // CloseError represents a WebSocket close frame. @@ -79,7 +82,7 @@ func parseClosePayload(p []byte) (CloseError, error) { // and https://tools.ietf.org/html/rfc6455#section-7.4.1 func validWireCloseCode(code StatusCode) bool { switch code { - case 1004, StatusNoStatusRcvd, statusAbnormalClosure, statusTLSHandshake: + case 1004, StatusNoStatusRcvd, StatusAbnormalClosure, StatusTLSHandshake: return false } diff --git a/statuscode_string.go b/statuscode_string.go index 11725e4dcf4a3d63e171511730cfc90904333b56..fc8cea0d6faa717bb71e2c3ce1f4426d635ffa76 100644 --- a/statuscode_string.go +++ b/statuscode_string.go @@ -13,7 +13,7 @@ func _() { _ = x[StatusProtocolError-1002] _ = x[StatusUnsupportedData-1003] _ = x[StatusNoStatusRcvd-1005] - _ = x[statusAbnormalClosure-1006] + _ = x[StatusAbnormalClosure-1006] _ = x[StatusInvalidFramePayloadData-1007] _ = x[StatusPolicyViolation-1008] _ = x[StatusMessageTooBig-1009] @@ -22,12 +22,12 @@ func _() { _ = x[StatusServiceRestart-1012] _ = x[StatusTryAgainLater-1013] _ = x[StatusBadGateway-1014] - _ = x[statusTLSHandshake-1015] + _ = x[StatusTLSHandshake-1015] } const ( _StatusCode_name_0 = "StatusNormalClosureStatusGoingAwayStatusProtocolErrorStatusUnsupportedData" - _StatusCode_name_1 = "StatusNoStatusRcvdstatusAbnormalClosureStatusInvalidFramePayloadDataStatusPolicyViolationStatusMessageTooBigStatusMandatoryExtensionStatusInternalErrorStatusServiceRestartStatusTryAgainLaterStatusBadGatewaystatusTLSHandshake" + _StatusCode_name_1 = "StatusNoStatusRcvdStatusAbnormalClosureStatusInvalidFramePayloadDataStatusPolicyViolationStatusMessageTooBigStatusMandatoryExtensionStatusInternalErrorStatusServiceRestartStatusTryAgainLaterStatusBadGatewayStatusTLSHandshake" ) var ( diff --git a/websocket.go b/websocket.go index 9976d0fafebf80beebbb17db2516685181ec9acb..bbadb9bc619ebb66117d63ac4f1a8fdcd7d5c61a 100644 --- a/websocket.go +++ b/websocket.go @@ -1,3 +1,5 @@ +// +build !js + package websocket import ( @@ -438,8 +440,8 @@ func (r *messageReader) eof() bool { func (r *messageReader) Read(p []byte) (int, error) { n, err := r.read(p) if err != nil { - // Have to return io.EOF directly for now, we cannot wrap as xerrors - // isn't used in stdlib. + // Have to return io.EOF directly for now, we cannot wrap as errors.Is + // isn't used widely yet. if errors.Is(err, io.EOF) { return n, io.EOF } @@ -944,3 +946,7 @@ func (c *Conn) extractBufioWriterBuf(w io.Writer) { c.bw.Reset(w) } + +func (c *netConn) netConnReader(ctx context.Context) (MessageType, io.Reader, error) { + return c.c.Reader(c.readContext) +} diff --git a/websocket_autobahn_python_test.go b/websocket_autobahn_python_test.go index a1e5cccbd6af600652a8c03836de458cabcbfce7..62aa3f8e1ee26446735faf6ea45d8fa5642b55ea 100644 --- a/websocket_autobahn_python_test.go +++ b/websocket_autobahn_python_test.go @@ -1,6 +1,7 @@ // This file contains the old autobahn test suite tests that use the -// python binary. The approach is very clunky and slow so new tests +// python binary. The approach is clunky and slow so new tests // have been written in pure Go in websocket_test.go. +// These have been kept for correctness purposes and are occasionally ran. // +build autobahn-python package websocket_test @@ -19,6 +20,8 @@ import ( "strings" "testing" "time" + + "nhooyr.io/websocket/internal/wsecho" ) // https://github.com/crossbario/autobahn-python/tree/master/wstest @@ -33,7 +36,7 @@ func TestPythonAutobahnServer(t *testing.T) { t.Logf("server handshake failed: %+v", err) return } - echoLoop(r.Context(), c) + wsecho.Loop(r.Context(), c) })) defer s.Close() @@ -185,7 +188,7 @@ func TestPythonAutobahnClientOld(t *testing.T) { if err != nil { t.Fatal(err) } - echoLoop(ctx, c) + wsecho.Loop(ctx, c) }() } diff --git a/websocket_bench_test.go b/websocket_bench_test.go index 6a54fab21c0dbf3106db21a48e13a3d51d5ba61a..ff2fd70416da5243cf90fb68678e914e2b645836 100644 --- a/websocket_bench_test.go +++ b/websocket_bench_test.go @@ -1,3 +1,5 @@ +// +build !js + package websocket_test import ( @@ -11,6 +13,7 @@ import ( "time" "nhooyr.io/websocket" + "nhooyr.io/websocket/internal/wsecho" ) func BenchmarkConn(b *testing.B) { @@ -52,7 +55,7 @@ func benchConn(b *testing.B, echo, stream bool, size int) { return err } if echo { - echoLoop(r.Context(), c) + wsecho.Loop(r.Context(), c) } else { discardLoop(r.Context(), c) } diff --git a/websocket_js.go b/websocket_js.go new file mode 100644 index 0000000000000000000000000000000000000000..14f198d15364d0d2046d360cabf5b63c7f478f6a --- /dev/null +++ b/websocket_js.go @@ -0,0 +1,217 @@ +package websocket // import "nhooyr.io/websocket" + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "reflect" + "runtime" + "sync" + "syscall/js" + + "nhooyr.io/websocket/internal/wsjs" +) + +// Conn provides a wrapper around the browser WebSocket API. +type Conn struct { + ws wsjs.WebSocket + + closeOnce sync.Once + closed chan struct{} + closeErr error + + releaseOnClose func() + releaseOnMessage func() + + readch chan wsjs.MessageEvent +} + +func (c *Conn) close(err error) { + c.closeOnce.Do(func() { + runtime.SetFinalizer(c, nil) + + c.closeErr = fmt.Errorf("websocket closed: %w", err) + close(c.closed) + }) +} + +func (c *Conn) init() { + c.closed = make(chan struct{}) + c.readch = make(chan wsjs.MessageEvent, 1) + + c.releaseOnClose = c.ws.OnClose(func(e wsjs.CloseEvent) { + cerr := CloseError{ + Code: StatusCode(e.Code), + Reason: e.Reason, + } + + c.close(fmt.Errorf("received close frame: %w", cerr)) + + c.releaseOnClose() + c.releaseOnMessage() + }) + + c.releaseOnMessage = c.ws.OnMessage(func(e wsjs.MessageEvent) { + c.readch <- e + }) + + runtime.SetFinalizer(c, func(c *Conn) { + c.ws.Close(int(StatusInternalError), "") + c.close(errors.New("connection garbage collected")) + }) +} + +// Read attempts to read a message from the connection. +// The maximum time spent waiting is bounded by the context. +func (c *Conn) Read(ctx context.Context) (MessageType, []byte, error) { + typ, p, err := c.read(ctx) + if err != nil { + return 0, nil, fmt.Errorf("failed to read: %w", err) + } + return typ, p, nil +} + +func (c *Conn) read(ctx context.Context) (MessageType, []byte, error) { + var me wsjs.MessageEvent + select { + case <-ctx.Done(): + return 0, nil, ctx.Err() + case me = <-c.readch: + case <-c.closed: + return 0, nil, c.closeErr + } + + switch p := me.Data.(type) { + case string: + return MessageText, []byte(p), nil + case []byte: + return MessageBinary, p, nil + default: + panic("websocket: unexpected data type from wsjs OnMessage: " + reflect.TypeOf(me.Data).String()) + } +} + +// Write writes a message of the given type to the connection. +// Always non blocking. +func (c *Conn) Write(ctx context.Context, typ MessageType, p []byte) error { + err := c.write(ctx, typ, p) + if err != nil { + return fmt.Errorf("failed to write: %w", err) + } + return nil +} + +func (c *Conn) write(ctx context.Context, typ MessageType, p []byte) error { + if c.isClosed() { + return c.closeErr + } + switch typ { + case MessageBinary: + return c.ws.SendBytes(p) + case MessageText: + return c.ws.SendText(string(p)) + default: + return fmt.Errorf("unexpected message type: %v", typ) + } +} + +func (c *Conn) isClosed() bool { + select { + case <-c.closed: + return true + default: + return false + } +} + +// Close closes the websocket with the given code and reason. +func (c *Conn) Close(code StatusCode, reason string) error { + if c.isClosed() { + return fmt.Errorf("already closed: %w", c.closeErr) + } + + err := fmt.Errorf("sent close frame: %v", CloseError{ + Code: code, + Reason: reason, + }) + + err2 := c.ws.Close(int(code), reason) + if err2 != nil { + err = err2 + } + c.close(err) + + if !errors.Is(c.closeErr, err) { + return fmt.Errorf("failed to close websocket: %w", err) + } + + return nil +} + +// Subprotocol returns the negotiated subprotocol. +// An empty string means the default protocol. +func (c *Conn) Subprotocol() string { + return c.ws.Protocol +} + +// DialOptions represents the options available to pass to Dial. +type DialOptions struct { + // Subprotocols lists the subprotocols to negotiate with the server. + Subprotocols []string +} + +// Dial creates a new WebSocket connection to the given url with the given options. +// The passed context bounds the maximum time spent waiting for the connection to open. +// The returned *http.Response is always nil or the zero value. It's only in the signature +// to match the core API. +func Dial(ctx context.Context, url string, opts *DialOptions) (*Conn, *http.Response, error) { + c, resp, err := dial(ctx, url, opts) + if err != nil { + return nil, resp, fmt.Errorf("failed to websocket dial: %w", err) + } + return c, resp, nil +} + +func dial(ctx context.Context, url string, opts *DialOptions) (*Conn, *http.Response, error) { + if opts == nil { + opts = &DialOptions{} + } + + ws, err := wsjs.New(url, opts.Subprotocols) + if err != nil { + return nil, nil, err + } + + c := &Conn{ + ws: ws, + } + c.init() + + opench := make(chan struct{}) + releaseOpen := ws.OnOpen(func(e js.Value) { + close(opench) + }) + defer releaseOpen() + + select { + case <-ctx.Done(): + return nil, nil, ctx.Err() + case <-opench: + case <-c.closed: + return c, nil, c.closeErr + } + + // Have to return a non nil response as the normal API does that. + return c, &http.Response{}, nil +} + +func (c *netConn) netConnReader(ctx context.Context) (MessageType, io.Reader, error) { + typ, p, err := c.c.Read(ctx) + if err != nil { + return 0, nil, err + } + return typ, bytes.NewReader(p), nil +} diff --git a/websocket_js_test.go b/websocket_js_test.go new file mode 100644 index 0000000000000000000000000000000000000000..1142190c2039f54d133abbfaa1b0d6f3b4ffaf38 --- /dev/null +++ b/websocket_js_test.go @@ -0,0 +1,55 @@ +package websocket_test + +import ( + "context" + "flag" + "net/http" + "testing" + "time" + + "nhooyr.io/websocket" +) + +func TestConn(t *testing.T) { + t.Parallel() + + wsEchoServerURL := flag.Arg(0) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + c, resp, err := websocket.Dial(ctx, wsEchoServerURL, &websocket.DialOptions{ + Subprotocols: []string{"echo"}, + }) + if err != nil { + t.Fatal(err) + } + defer c.Close(websocket.StatusInternalError, "") + + assertSubprotocol(c, "echo") + if err != nil { + t.Fatal(err) + } + + err = assertEqualf(&http.Response{}, resp, "unexpected http response") + if err != nil { + t.Fatal(err) + } + + err = assertJSONEcho(ctx, c, 16) + if err != nil { + t.Fatal(err) + } + + err = assertEcho(ctx, c, websocket.MessageBinary, 16) + if err != nil { + t.Fatal(err) + } + + err = c.Close(websocket.StatusNormalClosure, "") + if err != nil { + t.Fatal(err) + } + + time.Sleep(time.Millisecond * 100) +} diff --git a/websocket_test.go b/websocket_test.go index 1aa8b201a5a6a9d2f3dabb23207533cafdd9a1d7..2fabba545ebc9a03be2b75a64e622b2aa3504b82 100644 --- a/websocket_test.go +++ b/websocket_test.go @@ -1,3 +1,5 @@ +// +build !js + package websocket_test import ( @@ -27,6 +29,7 @@ import ( "go.uber.org/multierr" "nhooyr.io/websocket" + "nhooyr.io/websocket/internal/wsecho" "nhooyr.io/websocket/wsjson" "nhooyr.io/websocket/wspb" ) @@ -960,14 +963,14 @@ func TestAutobahn(t *testing.T) { return err } defer c.Close(websocket.StatusInternalError, "") - c.SetReadLimit(1 << 40) ctx := r.Context() if testingClient { - echoLoop(r.Context(), c) + wsecho.Loop(r.Context(), c) return nil } + c.SetReadLimit(1 << 30) err = fn(ctx, c) if err != nil { return err @@ -994,9 +997,9 @@ func TestAutobahn(t *testing.T) { t.Fatal(err) } defer c.Close(websocket.StatusInternalError, "") - c.SetReadLimit(1 << 40) if testingClient { + c.SetReadLimit(1 << 30) err = fn(ctx, c) if err != nil { t.Fatalf("client failed: %+v", err) @@ -1005,7 +1008,7 @@ func TestAutobahn(t *testing.T) { return } - echoLoop(ctx, c) + wsecho.Loop(ctx, c) } t.Run(name, func(t *testing.T) { t.Parallel() @@ -1847,47 +1850,6 @@ func TestAutobahn(t *testing.T) { }) } -func echoLoop(ctx context.Context, c *websocket.Conn) { - defer c.Close(websocket.StatusInternalError, "") - - c.SetReadLimit(1 << 40) - - ctx, cancel := context.WithTimeout(ctx, time.Minute) - defer cancel() - - b := make([]byte, 32768) - echo := func() error { - typ, r, err := c.Reader(ctx) - if err != nil { - return err - } - - w, err := c.Writer(ctx, typ) - if err != nil { - return err - } - - _, err = io.CopyBuffer(w, r, b) - if err != nil { - return err - } - - err = w.Close() - if err != nil { - return err - } - - return nil - } - - for { - err := echo() - if err != nil { - return - } - } -} - func assertCloseStatus(err error, code websocket.StatusCode) error { var cerr websocket.CloseError if !errors.As(err, &cerr) { @@ -1896,41 +1858,6 @@ func assertCloseStatus(err error, code websocket.StatusCode) error { return assertEqualf(code, cerr.Code, "unexpected status code") } -func assertJSONRead(ctx context.Context, c *websocket.Conn, exp interface{}) (err error) { - var act interface{} - err = wsjson.Read(ctx, c, &act) - if err != nil { - return err - } - - return assertEqualf(exp, act, "unexpected JSON") -} - -func randBytes(n int) []byte { - return make([]byte, n) -} - -func randString(n int) string { - return string(randBytes(n)) -} - -func assertEcho(ctx context.Context, c *websocket.Conn, typ websocket.MessageType, n int) error { - p := randBytes(n) - err := c.Write(ctx, typ, p) - if err != nil { - return err - } - typ2, p2, err := c.Read(ctx) - if err != nil { - return err - } - err = assertEqualf(typ, typ2, "unexpected data type") - if err != nil { - return err - } - return assertEqualf(p, p2, "unexpected payload") -} - func assertProtobufRead(ctx context.Context, c *websocket.Conn, exp interface{}) error { expType := reflect.TypeOf(exp) actv := reflect.New(expType.Elem()) @@ -1943,17 +1870,6 @@ func assertProtobufRead(ctx context.Context, c *websocket.Conn, exp interface{}) return assertEqualf(exp, act, "unexpected protobuf") } -func assertSubprotocol(c *websocket.Conn, exp string) error { - return assertEqualf(exp, c.Subprotocol(), "unexpected subprotocol") -} - -func assertEqualf(exp, act interface{}, f string, v ...interface{}) error { - if diff := cmpDiff(exp, act); diff != "" { - return fmt.Errorf(f+": %v", append(v, diff)...) - } - return nil -} - func assertNetConnRead(r io.Reader, exp string) error { act := make([]byte, len(exp)) _, err := r.Read(act) diff --git a/wsjson/wsjson.go b/wsjson/wsjson.go index 1e63f940ea7948c7267b36495e20c37e4e0f8588..ffdd24ac0c60d395122e8eb871c0875d00de2719 100644 --- a/wsjson/wsjson.go +++ b/wsjson/wsjson.go @@ -1,3 +1,5 @@ +// +build !js + // Package wsjson provides websocket helpers for JSON messages. package wsjson // import "nhooyr.io/websocket/wsjson" diff --git a/wsjson/wsjson_js.go b/wsjson/wsjson_js.go new file mode 100644 index 0000000000000000000000000000000000000000..5b88ce3ba5e58113e56e19f3967f3319b7c042a4 --- /dev/null +++ b/wsjson/wsjson_js.go @@ -0,0 +1,58 @@ +// +build js + +package wsjson + +import ( + "context" + "encoding/json" + "fmt" + + "nhooyr.io/websocket" +) + +// Read reads a json message from c into v. +func Read(ctx context.Context, c *websocket.Conn, v interface{}) error { + err := read(ctx, c, v) + if err != nil { + return fmt.Errorf("failed to read json: %w", err) + } + return nil +} + +func read(ctx context.Context, c *websocket.Conn, v interface{}) error { + typ, b, err := c.Read(ctx) + if err != nil { + return err + } + + if typ != websocket.MessageText { + c.Close(websocket.StatusUnsupportedData, "can only accept text messages") + return fmt.Errorf("unexpected frame type for json (expected %v): %v", websocket.MessageText, typ) + } + + err = json.Unmarshal(b, v) + if err != nil { + c.Close(websocket.StatusInvalidFramePayloadData, "failed to unmarshal JSON") + return fmt.Errorf("failed to unmarshal 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 fmt.Errorf("failed to write json: %w", err) + } + return nil +} + +func write(ctx context.Context, c *websocket.Conn, v interface{}) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + return c.Write(ctx, websocket.MessageText, b) +} diff --git a/wspb/wspb.go b/wspb/wspb.go index 8613a08093bdc2a68195f5cea55a1b84284a6a61..b32b0c1ba0bb06a9eb5270b79e50f4a679a53b4c 100644 --- a/wspb/wspb.go +++ b/wspb/wspb.go @@ -1,3 +1,5 @@ +// +build !js + // Package wspb provides websocket helpers for protobuf messages. package wspb // import "nhooyr.io/websocket/wspb" @@ -5,7 +7,6 @@ import ( "bytes" "context" "fmt" - "sync" "github.com/golang/protobuf/proto" @@ -63,8 +64,6 @@ func Write(ctx context.Context, c *websocket.Conn, v proto.Message) error { return nil } -var writeBufPool sync.Pool - func write(ctx context.Context, c *websocket.Conn, v proto.Message) error { b := bpool.Get() pb := proto.NewBuffer(b.Bytes()) diff --git a/wspb/wspb_js.go b/wspb/wspb_js.go new file mode 100644 index 0000000000000000000000000000000000000000..6f69eddd0b3947db09efd881c6a131e3f84d554b --- /dev/null +++ b/wspb/wspb_js.go @@ -0,0 +1,67 @@ +// +build js + +package wspb // import "nhooyr.io/websocket/wspb" + +import ( + "bytes" + "context" + "fmt" + + "github.com/golang/protobuf/proto" + + "nhooyr.io/websocket" + "nhooyr.io/websocket/internal/bpool" +) + +// Read reads a protobuf message from c into v. +func Read(ctx context.Context, c *websocket.Conn, v proto.Message) error { + err := read(ctx, c, v) + if err != nil { + return fmt.Errorf("failed to read protobuf: %w", err) + } + return nil +} + +func read(ctx context.Context, c *websocket.Conn, v proto.Message) error { + typ, p, err := c.Read(ctx) + if err != nil { + return err + } + + if typ != websocket.MessageBinary { + c.Close(websocket.StatusUnsupportedData, "can only accept binary messages") + return fmt.Errorf("unexpected frame type for protobuf (expected %v): %v", websocket.MessageBinary, typ) + } + + err = proto.Unmarshal(p, v) + if err != nil { + c.Close(websocket.StatusInvalidFramePayloadData, "failed to unmarshal protobuf") + return fmt.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 fmt.Errorf("failed to write protobuf: %w", err) + } + return nil +} + +func write(ctx context.Context, c *websocket.Conn, v proto.Message) error { + b := bpool.Get() + pb := proto.NewBuffer(b.Bytes()) + defer func() { + bpool.Put(bytes.NewBuffer(pb.Bytes())) + }() + + err := pb.Marshal(v) + if err != nil { + return fmt.Errorf("failed to marshal protobuf: %w", err) + } + + return c.Write(ctx, websocket.MessageBinary, pb.Bytes()) +} diff --git a/xor.go b/xor.go index 852930df813dd1101584ecd1be90eccee9a29188..f9fe2051fceb16a186fe096b0071d1d7d1a87975 100644 --- a/xor.go +++ b/xor.go @@ -1,3 +1,5 @@ +// +build !js + package websocket import ( diff --git a/xor_test.go b/xor_test.go index 634af606ac0184ef6ba244a7fb168f2e7403d1ca..70047a9cba2440bf6be246a3a486eb6edbf1d795 100644 --- a/xor_test.go +++ b/xor_test.go @@ -1,3 +1,5 @@ +// +build !js + package websocket import (