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 (