Repository: nhooyr/websocket
Pull request #371 (2023-02-25 21:30:39)

Migrate from deprecated `io/ioutil`
This addresses #370 by switched out `ioutil` methods with their `io` and `os` equivalents.


Patch:
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..d2eae33
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,1 @@
+* @nhooyr
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
new file mode 100644
index 0000000..3d9829e
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,39 @@
+name: ci
+
+on: [push, pull_request]
+
+jobs:
+  fmt:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v1
+      - name: Run ./ci/fmt.sh
+        uses: ./ci/container
+        with:
+          args: ./ci/fmt.sh
+
+  lint:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v1
+      - name: Run ./ci/lint.sh
+        uses: ./ci/container
+        with:
+          args: ./ci/lint.sh
+
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v1
+      - name: Run ./ci/test.sh
+        uses: ./ci/container
+        with:
+          args: ./ci/test.sh
+        env:
+          NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+          NETLIFY_SITE_ID: 9b3ee4dc-8297-4774-b4b9-a61561fbbce7
+      - name: Upload coverage.html
+        uses: actions/upload-artifact@v2
+        with:
+          name: coverage.html
+          path: ./ci/out/coverage.html
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
deleted file mode 100644
index 8b88e81..0000000
--- a/.github/workflows/ci.yml
+++ /dev/null
@@ -1,38 +0,0 @@
-name: ci
-on: [push, pull_request]
-concurrency:
-  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
-  cancel-in-progress: true
-
-jobs:
-  fmt:
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v4
-      - uses: actions/setup-go@v4
-        with:
-          go-version-file: ./go.mod
-      - run: ./ci/fmt.sh
-
-  lint:
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v4
-      - run: go version
-      - uses: actions/setup-go@v4
-        with:
-          go-version-file: ./go.mod
-      - run: ./ci/lint.sh
-
-  test:
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v4
-      - uses: actions/setup-go@v4
-        with:
-          go-version-file: ./go.mod
-      - run: ./ci/test.sh
-      - uses: actions/upload-artifact@v3
-        with:
-          name: coverage.html
-          path: ./ci/out/coverage.html
diff --git a/.github/workflows/daily.yml b/.github/workflows/daily.yml
deleted file mode 100644
index cbac574..0000000
--- a/.github/workflows/daily.yml
+++ /dev/null
@@ -1,22 +0,0 @@
-name: daily
-on:
-  workflow_dispatch:
-  schedule:
-    - cron: '42 0 * * *' # daily at 00:42
-concurrency:
-  group: ${{ github.workflow }}
-  cancel-in-progress: true
-
-jobs:
-  ci:
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v4
-      - uses: actions/setup-go@v4
-        with:
-          go-version-file: ./go.mod
-      - run: AUTOBAHN=1 ./ci/test.sh
-      - uses: actions/upload-artifact@v3
-        with:
-          name: coverage.html
-          path: ./ci/out/coverage.html
diff --git a/LICENSE.txt b/LICENSE.txt
index 77b5bef..b5b5fef 100644
--- a/LICENSE.txt
+++ b/LICENSE.txt
@@ -1,13 +1,21 @@
-Copyright (c) 2023 Anmol Sethi <hi@nhooyr.io>
-
-Permission to use, copy, modify, and distribute this software for any
-purpose with or without fee is hereby granted, provided that the above
-copyright notice and this permission notice appear in all copies.
-
-THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
-WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
-MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
-ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
-WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
-ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
-OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+MIT License
+
+Copyright (c) 2018 Anmol Sethi
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 4e73a26..15e3c8e 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
 # websocket
 
 [![godoc](https://godoc.org/nhooyr.io/websocket?status.svg)](https://pkg.go.dev/nhooyr.io/websocket)
-[![coverage](https://img.shields.io/badge/coverage-86%25-success)](https://nhooyrio-websocket-coverage.netlify.app)
+[![coverage](https://img.shields.io/badge/coverage-88%25-success)](https://nhooyrio-websocket-coverage.netlify.app)
 
 websocket is a minimal and idiomatic WebSocket library for Go.
 
@@ -22,7 +22,7 @@ go get nhooyr.io/websocket
 - Minimal and idiomatic API
 - First class [context.Context](https://blog.golang.org/context) support
 - Fully passes the WebSocket [autobahn-testsuite](https://github.com/crossbario/autobahn-testsuite)
-- [Zero dependencies](https://pkg.go.dev/nhooyr.io/websocket?tab=imports)
+- [Single dependency](https://pkg.go.dev/nhooyr.io/websocket?tab=imports)
 - JSON and protobuf helpers in the [wsjson](https://pkg.go.dev/nhooyr.io/websocket/wsjson) and [wspb](https://pkg.go.dev/nhooyr.io/websocket/wspb) subpackages
 - Zero alloc reads and writes
 - Concurrent writes
@@ -118,6 +118,7 @@ Advantages of nhooyr.io/websocket:
   - Gorilla's implementation is slower and uses [unsafe](https://golang.org/pkg/unsafe/).
 - Full [permessage-deflate](https://tools.ietf.org/html/rfc7692) compression extension support
   - Gorilla only supports no context takeover mode
+  - We use [klauspost/compress](https://github.com/klauspost/compress) for much lower memory usage ([gorilla/websocket#203](https://github.com/gorilla/websocket/issues/203))
 - [CloseRead](https://pkg.go.dev/nhooyr.io/websocket#Conn.CloseRead) helper ([gorilla/websocket#492](https://github.com/gorilla/websocket/issues/492))
 - Actively maintained ([gorilla/websocket#370](https://github.com/gorilla/websocket/issues/370))
 
diff --git a/accept.go b/accept.go
index d918aab..18536bd 100644
--- a/accept.go
+++ b/accept.go
@@ -1,4 +1,3 @@
-//go:build !js
 // +build !js
 
 package websocket
@@ -52,7 +51,7 @@ type AcceptOptions struct {
 	OriginPatterns []string
 
 	// CompressionMode controls the compression mode.
-	// Defaults to CompressionDisabled.
+	// Defaults to CompressionNoContextTakeover.
 	//
 	// See docs on CompressionMode for details.
 	CompressionMode CompressionMode
@@ -64,14 +63,6 @@ type AcceptOptions struct {
 	CompressionThreshold int
 }
 
-func (opts *AcceptOptions) cloneWithDefaults() *AcceptOptions {
-	var o AcceptOptions
-	if opts != nil {
-		o = *opts
-	}
-	return &o
-}
-
 // Accept accepts a WebSocket handshake from a client and upgrades the
 // the connection to a WebSocket.
 //
@@ -86,13 +77,17 @@ func Accept(w http.ResponseWriter, r *http.Request, opts *AcceptOptions) (*Conn,
 func accept(w http.ResponseWriter, r *http.Request, opts *AcceptOptions) (_ *Conn, err error) {
 	defer errd.Wrap(&err, "failed to accept WebSocket connection")
 
+	if opts == nil {
+		opts = &AcceptOptions{}
+	}
+	opts = &*opts
+
 	errCode, err := verifyClientRequest(w, r)
 	if err != nil {
 		http.Error(w, err.Error(), errCode)
 		return nil, err
 	}
 
-	opts = opts.cloneWithDefaults()
 	if !opts.InsecureSkipVerify {
 		err = authenticateOrigin(r, opts.OriginPatterns)
 		if err != nil {
@@ -216,10 +211,7 @@ func authenticateOrigin(r *http.Request, originHosts []string) error {
 			return nil
 		}
 	}
-	if u.Host == "" {
-		return fmt.Errorf("request Origin %q is not a valid URL with a host", origin)
-	}
-	return fmt.Errorf("request Origin %q is not authorized for Host %q", u.Host, r.Host)
+	return fmt.Errorf("request Origin %q is not authorized for Host %q", origin, r.Host)
 }
 
 func match(pattern, s string) (bool, error) {
diff --git a/accept_js.go b/accept_js.go
new file mode 100644
index 0000000..daad4b7
--- /dev/null
+++ b/accept_js.go
@@ -0,0 +1,20 @@
+package websocket
+
+import (
+	"errors"
+	"net/http"
+)
+
+// AcceptOptions represents Accept's options.
+type AcceptOptions struct {
+	Subprotocols         []string
+	InsecureSkipVerify   bool
+	OriginPatterns       []string
+	CompressionMode      CompressionMode
+	CompressionThreshold int
+}
+
+// Accept is stubbed out for Wasm.
+func Accept(w http.ResponseWriter, r *http.Request, opts *AcceptOptions) (*Conn, error) {
+	return nil, errors.New("unimplemented")
+}
diff --git a/ci/all.sh b/ci/all.sh
new file mode 100755
index 0000000..1ee7640
--- /dev/null
+++ b/ci/all.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+main() {
+  cd "$(dirname "$0")/.."
+
+  ./ci/fmt.sh
+  ./ci/lint.sh
+  ./ci/test.sh "$@"
+}
+
+main "$@"
diff --git a/ci/container/Dockerfile b/ci/container/Dockerfile
new file mode 100644
index 0000000..0c6c2a5
--- /dev/null
+++ b/ci/container/Dockerfile
@@ -0,0 +1,14 @@
+FROM golang
+
+RUN apt-get update
+RUN apt-get install -y npm shellcheck chromium
+
+ENV GO111MODULE=on
+RUN go get golang.org/x/tools/cmd/goimports
+RUN go get mvdan.cc/sh/v3/cmd/shfmt
+RUN go get golang.org/x/tools/cmd/stringer
+RUN go get golang.org/x/lint/golint
+RUN go get github.com/agnivade/wasmbrowsertest
+
+RUN npm --unsafe-perm=true install -g prettier
+RUN npm --unsafe-perm=true install -g netlify-cli
diff --git a/ci/fmt.sh b/ci/fmt.sh
index 0d90273..e6a2d68 100755
--- a/ci/fmt.sh
+++ b/ci/fmt.sh
@@ -1,18 +1,38 @@
-#!/bin/sh
-set -eu
-cd -- "$(dirname "$0")/.."
+#!/usr/bin/env bash
+set -euo pipefail
 
-go mod tidy
-gofmt -w -s .
-go run golang.org/x/tools/cmd/goimports@latest -w "-local=$(go list -m)" .
+main() {
+  cd "$(dirname "$0")/.."
 
-npx prettier@3.0.3 \
-  --write \
-  --log-level=warn \
-  --print-width=90 \
-  --no-semi \
-  --single-quote \
-  --arrow-parens=avoid \
-  $(git ls-files "*.yml" "*.md" "*.js" "*.css" "*.html")
+  go mod tidy
+  gofmt -w -s .
+  goimports -w "-local=$(go list -m)" .
 
-go run golang.org/x/tools/cmd/stringer@latest -type=opcode,MessageType,StatusCode -output=stringer.go
+  prettier \
+    --write \
+    --print-width=120 \
+    --no-semi \
+    --trailing-comma=all \
+    --loglevel=warn \
+    --arrow-parens=avoid \
+    $(git ls-files "*.yml" "*.md" "*.js" "*.css" "*.html")
+  shfmt -i 2 -w -s -sr $(git ls-files "*.sh")
+
+  stringer -type=opcode,MessageType,StatusCode -output=stringer.go
+
+  if [[ ${CI-} ]]; then
+    ensure_fmt
+  fi
+}
+
+ensure_fmt() {
+  if [[ $(git ls-files --other --modified --exclude-standard) ]]; then
+    git -c color.ui=always --no-pager diff
+    echo
+    echo "Please run the following locally:"
+    echo "  ./ci/fmt.sh"
+    exit 1
+  fi
+}
+
+main "$@"
diff --git a/ci/lint.sh b/ci/lint.sh
index a8ab302..e1053d1 100755
--- a/ci/lint.sh
+++ b/ci/lint.sh
@@ -1,14 +1,16 @@
-#!/bin/sh
-set -eu
-cd -- "$(dirname "$0")/.."
+#!/usr/bin/env bash
+set -euo pipefail
 
-go vet ./...
-GOOS=js GOARCH=wasm go vet ./...
+main() {
+  cd "$(dirname "$0")/.."
 
-go install golang.org/x/lint/golint@latest
-golint -set_exit_status ./...
-GOOS=js GOARCH=wasm golint -set_exit_status ./...
+  go vet ./...
+  GOOS=js GOARCH=wasm go vet ./...
 
-go install honnef.co/go/tools/cmd/staticcheck@latest
-staticcheck ./...
-GOOS=js GOARCH=wasm staticcheck ./...
+  golint -set_exit_status ./...
+  GOOS=js GOARCH=wasm golint -set_exit_status ./...
+
+  shellcheck --exclude=SC2046 $(git ls-files "*.sh")
+}
+
+main "$@"
diff --git a/close.go b/close.go
index eab49a8..7cbc19e 100644
--- a/close.go
+++ b/close.go
@@ -1,17 +1,8 @@
-//go:build !js
-// +build !js
-
 package websocket
 
 import (
-	"context"
-	"encoding/binary"
 	"errors"
 	"fmt"
-	"log"
-	"time"
-
-	"nhooyr.io/websocket/internal/errd"
 )
 
 // StatusCode represents a WebSocket status code.
@@ -83,200 +74,3 @@ func CloseStatus(err error) StatusCode {
 	}
 	return -1
 }
-
-// Close performs the WebSocket close handshake with the given status code and reason.
-//
-// It will write a WebSocket close frame with a timeout of 5s and then wait 5s for
-// the peer to send a close frame.
-// All data messages received from the peer during the close handshake will be discarded.
-//
-// The connection can only be closed once. Additional calls to Close
-// are no-ops.
-//
-// The maximum length of reason must be 125 bytes. Avoid
-// sending a dynamic reason.
-//
-// Close will unblock all goroutines interacting with the connection once
-// complete.
-func (c *Conn) Close(code StatusCode, reason string) error {
-	return c.closeHandshake(code, reason)
-}
-
-func (c *Conn) closeHandshake(code StatusCode, reason string) (err error) {
-	defer errd.Wrap(&err, "failed to close WebSocket")
-
-	writeErr := c.writeClose(code, reason)
-	closeHandshakeErr := c.waitCloseHandshake()
-
-	if writeErr != nil {
-		return writeErr
-	}
-
-	if CloseStatus(closeHandshakeErr) == -1 {
-		return closeHandshakeErr
-	}
-
-	return nil
-}
-
-var errAlreadyWroteClose = errors.New("already wrote close")
-
-func (c *Conn) writeClose(code StatusCode, reason string) error {
-	c.closeMu.Lock()
-	wroteClose := c.wroteClose
-	c.wroteClose = true
-	c.closeMu.Unlock()
-	if wroteClose {
-		return errAlreadyWroteClose
-	}
-
-	ce := CloseError{
-		Code:   code,
-		Reason: reason,
-	}
-
-	var p []byte
-	var marshalErr error
-	if ce.Code != StatusNoStatusRcvd {
-		p, marshalErr = ce.bytes()
-		if marshalErr != nil {
-			log.Printf("websocket: %v", marshalErr)
-		}
-	}
-
-	writeErr := c.writeControl(context.Background(), opClose, p)
-	if CloseStatus(writeErr) != -1 {
-		// Not a real error if it's due to a close frame being received.
-		writeErr = nil
-	}
-
-	// We do this after in case there was an error writing the close frame.
-	c.setCloseErr(fmt.Errorf("sent close frame: %w", ce))
-
-	if marshalErr != nil {
-		return marshalErr
-	}
-	return writeErr
-}
-
-func (c *Conn) waitCloseHandshake() error {
-	defer c.close(nil)
-
-	ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
-	defer cancel()
-
-	err := c.readMu.lock(ctx)
-	if err != nil {
-		return err
-	}
-	defer c.readMu.unlock()
-
-	if c.readCloseFrameErr != nil {
-		return c.readCloseFrameErr
-	}
-
-	for {
-		h, err := c.readLoop(ctx)
-		if err != nil {
-			return err
-		}
-
-		for i := int64(0); i < h.payloadLength; i++ {
-			_, err := c.br.ReadByte()
-			if err != nil {
-				return err
-			}
-		}
-	}
-}
-
-func parseClosePayload(p []byte) (CloseError, error) {
-	if len(p) == 0 {
-		return CloseError{
-			Code: StatusNoStatusRcvd,
-		}, nil
-	}
-
-	if len(p) < 2 {
-		return CloseError{}, fmt.Errorf("close payload %q too small, cannot even contain the 2 byte status code", p)
-	}
-
-	ce := CloseError{
-		Code:   StatusCode(binary.BigEndian.Uint16(p)),
-		Reason: string(p[2:]),
-	}
-
-	if !validWireCloseCode(ce.Code) {
-		return CloseError{}, fmt.Errorf("invalid status code %v", ce.Code)
-	}
-
-	return ce, nil
-}
-
-// See http://www.iana.org/assignments/websocket/websocket.xhtml#close-code-number
-// and https://tools.ietf.org/html/rfc6455#section-7.4.1
-func validWireCloseCode(code StatusCode) bool {
-	switch code {
-	case statusReserved, StatusNoStatusRcvd, StatusAbnormalClosure, StatusTLSHandshake:
-		return false
-	}
-
-	if code >= StatusNormalClosure && code <= StatusBadGateway {
-		return true
-	}
-	if code >= 3000 && code <= 4999 {
-		return true
-	}
-
-	return false
-}
-
-func (ce CloseError) bytes() ([]byte, error) {
-	p, err := ce.bytesErr()
-	if err != nil {
-		err = fmt.Errorf("failed to marshal close frame: %w", err)
-		ce = CloseError{
-			Code: StatusInternalError,
-		}
-		p, _ = ce.bytesErr()
-	}
-	return p, err
-}
-
-const maxCloseReason = maxControlPayload - 2
-
-func (ce CloseError) bytesErr() ([]byte, error) {
-	if len(ce.Reason) > maxCloseReason {
-		return nil, fmt.Errorf("reason string max is %v but got %q with length %v", maxCloseReason, ce.Reason, len(ce.Reason))
-	}
-
-	if !validWireCloseCode(ce.Code) {
-		return nil, fmt.Errorf("status code %v cannot be set", ce.Code)
-	}
-
-	buf := make([]byte, 2+len(ce.Reason))
-	binary.BigEndian.PutUint16(buf, uint16(ce.Code))
-	copy(buf[2:], ce.Reason)
-	return buf, nil
-}
-
-func (c *Conn) setCloseErr(err error) {
-	c.closeMu.Lock()
-	c.setCloseErrLocked(err)
-	c.closeMu.Unlock()
-}
-
-func (c *Conn) setCloseErrLocked(err error) {
-	if c.closeErr == nil {
-		c.closeErr = fmt.Errorf("WebSocket closed: %w", err)
-	}
-}
-
-func (c *Conn) isClosed() bool {
-	select {
-	case <-c.closed:
-		return true
-	default:
-		return false
-	}
-}
diff --git a/close_notjs.go b/close_notjs.go
new file mode 100644
index 0000000..4251311
--- /dev/null
+++ b/close_notjs.go
@@ -0,0 +1,211 @@
+// +build !js
+
+package websocket
+
+import (
+	"context"
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"log"
+	"time"
+
+	"nhooyr.io/websocket/internal/errd"
+)
+
+// Close performs the WebSocket close handshake with the given status code and reason.
+//
+// It will write a WebSocket close frame with a timeout of 5s and then wait 5s for
+// the peer to send a close frame.
+// All data messages received from the peer during the close handshake will be discarded.
+//
+// The connection can only be closed once. Additional calls to Close
+// are no-ops.
+//
+// The maximum length of reason must be 125 bytes. Avoid
+// sending a dynamic reason.
+//
+// Close will unblock all goroutines interacting with the connection once
+// complete.
+func (c *Conn) Close(code StatusCode, reason string) error {
+	return c.closeHandshake(code, reason)
+}
+
+func (c *Conn) closeHandshake(code StatusCode, reason string) (err error) {
+	defer errd.Wrap(&err, "failed to close WebSocket")
+
+	writeErr := c.writeClose(code, reason)
+	closeHandshakeErr := c.waitCloseHandshake()
+
+	if writeErr != nil {
+		return writeErr
+	}
+
+	if CloseStatus(closeHandshakeErr) == -1 {
+		return closeHandshakeErr
+	}
+
+	return nil
+}
+
+var errAlreadyWroteClose = errors.New("already wrote close")
+
+func (c *Conn) writeClose(code StatusCode, reason string) error {
+	c.closeMu.Lock()
+	wroteClose := c.wroteClose
+	c.wroteClose = true
+	c.closeMu.Unlock()
+	if wroteClose {
+		return errAlreadyWroteClose
+	}
+
+	ce := CloseError{
+		Code:   code,
+		Reason: reason,
+	}
+
+	var p []byte
+	var marshalErr error
+	if ce.Code != StatusNoStatusRcvd {
+		p, marshalErr = ce.bytes()
+		if marshalErr != nil {
+			log.Printf("websocket: %v", marshalErr)
+		}
+	}
+
+	writeErr := c.writeControl(context.Background(), opClose, p)
+	if CloseStatus(writeErr) != -1 {
+		// Not a real error if it's due to a close frame being received.
+		writeErr = nil
+	}
+
+	// We do this after in case there was an error writing the close frame.
+	c.setCloseErr(fmt.Errorf("sent close frame: %w", ce))
+
+	if marshalErr != nil {
+		return marshalErr
+	}
+	return writeErr
+}
+
+func (c *Conn) waitCloseHandshake() error {
+	defer c.close(nil)
+
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
+	defer cancel()
+
+	err := c.readMu.lock(ctx)
+	if err != nil {
+		return err
+	}
+	defer c.readMu.unlock()
+
+	if c.readCloseFrameErr != nil {
+		return c.readCloseFrameErr
+	}
+
+	for {
+		h, err := c.readLoop(ctx)
+		if err != nil {
+			return err
+		}
+
+		for i := int64(0); i < h.payloadLength; i++ {
+			_, err := c.br.ReadByte()
+			if err != nil {
+				return err
+			}
+		}
+	}
+}
+
+func parseClosePayload(p []byte) (CloseError, error) {
+	if len(p) == 0 {
+		return CloseError{
+			Code: StatusNoStatusRcvd,
+		}, nil
+	}
+
+	if len(p) < 2 {
+		return CloseError{}, fmt.Errorf("close payload %q too small, cannot even contain the 2 byte status code", p)
+	}
+
+	ce := CloseError{
+		Code:   StatusCode(binary.BigEndian.Uint16(p)),
+		Reason: string(p[2:]),
+	}
+
+	if !validWireCloseCode(ce.Code) {
+		return CloseError{}, fmt.Errorf("invalid status code %v", ce.Code)
+	}
+
+	return ce, nil
+}
+
+// See http://www.iana.org/assignments/websocket/websocket.xhtml#close-code-number
+// and https://tools.ietf.org/html/rfc6455#section-7.4.1
+func validWireCloseCode(code StatusCode) bool {
+	switch code {
+	case statusReserved, StatusNoStatusRcvd, StatusAbnormalClosure, StatusTLSHandshake:
+		return false
+	}
+
+	if code >= StatusNormalClosure && code <= StatusBadGateway {
+		return true
+	}
+	if code >= 3000 && code <= 4999 {
+		return true
+	}
+
+	return false
+}
+
+func (ce CloseError) bytes() ([]byte, error) {
+	p, err := ce.bytesErr()
+	if err != nil {
+		err = fmt.Errorf("failed to marshal close frame: %w", err)
+		ce = CloseError{
+			Code: StatusInternalError,
+		}
+		p, _ = ce.bytesErr()
+	}
+	return p, err
+}
+
+const maxCloseReason = maxControlPayload - 2
+
+func (ce CloseError) bytesErr() ([]byte, error) {
+	if len(ce.Reason) > maxCloseReason {
+		return nil, fmt.Errorf("reason string max is %v but got %q with length %v", maxCloseReason, ce.Reason, len(ce.Reason))
+	}
+
+	if !validWireCloseCode(ce.Code) {
+		return nil, fmt.Errorf("status code %v cannot be set", ce.Code)
+	}
+
+	buf := make([]byte, 2+len(ce.Reason))
+	binary.BigEndian.PutUint16(buf, uint16(ce.Code))
+	copy(buf[2:], ce.Reason)
+	return buf, nil
+}
+
+func (c *Conn) setCloseErr(err error) {
+	c.closeMu.Lock()
+	c.setCloseErrLocked(err)
+	c.closeMu.Unlock()
+}
+
+func (c *Conn) setCloseErrLocked(err error) {
+	if c.closeErr == nil {
+		c.closeErr = fmt.Errorf("WebSocket closed: %w", err)
+	}
+}
+
+func (c *Conn) isClosed() bool {
+	select {
+	case <-c.closed:
+		return true
+	default:
+		return false
+	}
+}
diff --git a/compress.go b/compress.go
index a9e1fa3..80b46d1 100644
--- a/compress.go
+++ b/compress.go
@@ -1,15 +1,5 @@
-//go:build !js
-// +build !js
-
 package websocket
 
-import (
-	"compress/flate"
-	"io"
-	"net/http"
-	"sync"
-)
-
 // CompressionMode represents the modes available to the deflate extension.
 // See https://tools.ietf.org/html/rfc7692
 //
@@ -17,224 +7,33 @@ import (
 // by safari. See https://tools.ietf.org/html/draft-tyoshino-hybi-websocket-perframe-deflate-06
 // It will work the same in every way except that we cannot signal to the peer we
 // want to use no context takeover on our side, we can only signal that they should.
-// But it is currently disabled due to Safari bugs. See https://github.com/nhooyr/websocket/issues/218
+// It is however currently disabled due to Safari bugs. See https://github.com/nhooyr/websocket/issues/218
 type CompressionMode int
 
 const (
-	// CompressionDisabled disables the deflate extension.
+	// CompressionNoContextTakeover grabs a new flate.Reader and flate.Writer as needed
+	// for every message. This applies to both server and client side.
 	//
-	// Use this if you are using a predominantly binary protocol with very
-	// little duplication in between messages or CPU and memory are more
-	// important than bandwidth.
+	// This means less efficient compression as the sliding window from previous messages
+	// will not be used but the memory overhead will be lower if the connections
+	// are long lived and seldom used.
 	//
-	// This is the default.
-	CompressionDisabled CompressionMode = iota
+	// The message will only be compressed if greater than 512 bytes.
+	CompressionNoContextTakeover CompressionMode = iota
 
-	// CompressionContextTakeover uses a 32 kB sliding window and flate.Writer per connection.
-	// It reusing the sliding window from previous messages.
+	// CompressionContextTakeover uses a flate.Reader and flate.Writer per connection.
+	// This enables reusing the sliding window from previous messages.
 	// As most WebSocket protocols are repetitive, this can be very efficient.
-	// It carries an overhead of 32 kB + 1.2 MB for every connection compared to CompressionNoContextTakeover.
-	//
-	// Sometime in the future it will carry 65 kB overhead instead once https://github.com/golang/go/issues/36919
-	// is fixed.
+	// It carries an overhead of 8 kB for every connection compared to CompressionNoContextTakeover.
 	//
 	// If the peer negotiates NoContextTakeover on the client or server side, it will be
 	// used instead as this is required by the RFC.
 	CompressionContextTakeover
 
-	// CompressionNoContextTakeover grabs a new flate.Reader and flate.Writer as needed
-	// for every message. This applies to both server and client side.
-	//
-	// This means less efficient compression as the sliding window from previous messages
-	// will not be used but the memory overhead will be lower if the connections
-	// are long lived and seldom used.
+	// CompressionDisabled disables the deflate extension.
 	//
-	// The message will only be compressed if greater than 512 bytes.
-	CompressionNoContextTakeover
+	// Use this if you are using a predominantly binary protocol with very
+	// little duplication in between messages or CPU and memory are more
+	// important than bandwidth.
+	CompressionDisabled
 )
-
-func (m CompressionMode) opts() *compressionOptions {
-	return &compressionOptions{
-		clientNoContextTakeover: m == CompressionNoContextTakeover,
-		serverNoContextTakeover: m == CompressionNoContextTakeover,
-	}
-}
-
-type compressionOptions struct {
-	clientNoContextTakeover bool
-	serverNoContextTakeover bool
-}
-
-func (copts *compressionOptions) setHeader(h http.Header) {
-	s := "permessage-deflate"
-	if copts.clientNoContextTakeover {
-		s += "; client_no_context_takeover"
-	}
-	if copts.serverNoContextTakeover {
-		s += "; server_no_context_takeover"
-	}
-	h.Set("Sec-WebSocket-Extensions", s)
-}
-
-// These bytes are required to get flate.Reader to return.
-// They are removed when sending to avoid the overhead as
-// WebSocket framing tell's when the message has ended but then
-// we need to add them back otherwise flate.Reader keeps
-// trying to return more bytes.
-const deflateMessageTail = "\x00\x00\xff\xff"
-
-type trimLastFourBytesWriter struct {
-	w    io.Writer
-	tail []byte
-}
-
-func (tw *trimLastFourBytesWriter) reset() {
-	if tw != nil && tw.tail != nil {
-		tw.tail = tw.tail[:0]
-	}
-}
-
-func (tw *trimLastFourBytesWriter) Write(p []byte) (int, error) {
-	if tw.tail == nil {
-		tw.tail = make([]byte, 0, 4)
-	}
-
-	extra := len(tw.tail) + len(p) - 4
-
-	if extra <= 0 {
-		tw.tail = append(tw.tail, p...)
-		return len(p), nil
-	}
-
-	// Now we need to write as many extra bytes as we can from the previous tail.
-	if extra > len(tw.tail) {
-		extra = len(tw.tail)
-	}
-	if extra > 0 {
-		_, err := tw.w.Write(tw.tail[:extra])
-		if err != nil {
-			return 0, err
-		}
-
-		// Shift remaining bytes in tail over.
-		n := copy(tw.tail, tw.tail[extra:])
-		tw.tail = tw.tail[:n]
-	}
-
-	// If p is less than or equal to 4 bytes,
-	// all of it is is part of the tail.
-	if len(p) <= 4 {
-		tw.tail = append(tw.tail, p...)
-		return len(p), nil
-	}
-
-	// Otherwise, only the last 4 bytes are.
-	tw.tail = append(tw.tail, p[len(p)-4:]...)
-
-	p = p[:len(p)-4]
-	n, err := tw.w.Write(p)
-	return n + 4, err
-}
-
-var flateReaderPool sync.Pool
-
-func getFlateReader(r io.Reader, dict []byte) io.Reader {
-	fr, ok := flateReaderPool.Get().(io.Reader)
-	if !ok {
-		return flate.NewReaderDict(r, dict)
-	}
-	fr.(flate.Resetter).Reset(r, dict)
-	return fr
-}
-
-func putFlateReader(fr io.Reader) {
-	flateReaderPool.Put(fr)
-}
-
-var flateWriterPool sync.Pool
-
-func getFlateWriter(w io.Writer) *flate.Writer {
-	fw, ok := flateWriterPool.Get().(*flate.Writer)
-	if !ok {
-		fw, _ = flate.NewWriter(w, flate.BestSpeed)
-		return fw
-	}
-	fw.Reset(w)
-	return fw
-}
-
-func putFlateWriter(w *flate.Writer) {
-	flateWriterPool.Put(w)
-}
-
-type slidingWindow struct {
-	buf []byte
-}
-
-var swPoolMu sync.RWMutex
-var swPool = map[int]*sync.Pool{}
-
-func slidingWindowPool(n int) *sync.Pool {
-	swPoolMu.RLock()
-	p, ok := swPool[n]
-	swPoolMu.RUnlock()
-	if ok {
-		return p
-	}
-
-	p = &sync.Pool{}
-
-	swPoolMu.Lock()
-	swPool[n] = p
-	swPoolMu.Unlock()
-
-	return p
-}
-
-func (sw *slidingWindow) init(n int) {
-	if sw.buf != nil {
-		return
-	}
-
-	if n == 0 {
-		n = 32768
-	}
-
-	p := slidingWindowPool(n)
-	buf, ok := p.Get().([]byte)
-	if ok {
-		sw.buf = buf[:0]
-	} else {
-		sw.buf = make([]byte, 0, n)
-	}
-}
-
-func (sw *slidingWindow) close() {
-	if sw.buf == nil {
-		return
-	}
-
-	swPoolMu.Lock()
-	swPool[cap(sw.buf)].Put(sw.buf)
-	swPoolMu.Unlock()
-	sw.buf = nil
-}
-
-func (sw *slidingWindow) write(p []byte) {
-	if len(p) >= cap(sw.buf) {
-		sw.buf = sw.buf[:cap(sw.buf)]
-		p = p[len(p)-cap(sw.buf):]
-		copy(sw.buf, p)
-		return
-	}
-
-	left := cap(sw.buf) - len(sw.buf)
-	if left < len(p) {
-		// We need to shift spaceNeeded bytes from the end to make room for p at the end.
-		spaceNeeded := len(p) - left
-		copy(sw.buf, sw.buf[spaceNeeded:])
-		sw.buf = sw.buf[:len(sw.buf)-spaceNeeded]
-	}
-
-	sw.buf = append(sw.buf, p...)
-}
diff --git a/compress_notjs.go b/compress_notjs.go
new file mode 100644
index 0000000..809a272
--- /dev/null
+++ b/compress_notjs.go
@@ -0,0 +1,181 @@
+// +build !js
+
+package websocket
+
+import (
+	"io"
+	"net/http"
+	"sync"
+
+	"github.com/klauspost/compress/flate"
+)
+
+func (m CompressionMode) opts() *compressionOptions {
+	return &compressionOptions{
+		clientNoContextTakeover: m == CompressionNoContextTakeover,
+		serverNoContextTakeover: m == CompressionNoContextTakeover,
+	}
+}
+
+type compressionOptions struct {
+	clientNoContextTakeover bool
+	serverNoContextTakeover bool
+}
+
+func (copts *compressionOptions) setHeader(h http.Header) {
+	s := "permessage-deflate"
+	if copts.clientNoContextTakeover {
+		s += "; client_no_context_takeover"
+	}
+	if copts.serverNoContextTakeover {
+		s += "; server_no_context_takeover"
+	}
+	h.Set("Sec-WebSocket-Extensions", s)
+}
+
+// These bytes are required to get flate.Reader to return.
+// They are removed when sending to avoid the overhead as
+// WebSocket framing tell's when the message has ended but then
+// we need to add them back otherwise flate.Reader keeps
+// trying to return more bytes.
+const deflateMessageTail = "\x00\x00\xff\xff"
+
+type trimLastFourBytesWriter struct {
+	w    io.Writer
+	tail []byte
+}
+
+func (tw *trimLastFourBytesWriter) reset() {
+	if tw != nil && tw.tail != nil {
+		tw.tail = tw.tail[:0]
+	}
+}
+
+func (tw *trimLastFourBytesWriter) Write(p []byte) (int, error) {
+	if tw.tail == nil {
+		tw.tail = make([]byte, 0, 4)
+	}
+
+	extra := len(tw.tail) + len(p) - 4
+
+	if extra <= 0 {
+		tw.tail = append(tw.tail, p...)
+		return len(p), nil
+	}
+
+	// Now we need to write as many extra bytes as we can from the previous tail.
+	if extra > len(tw.tail) {
+		extra = len(tw.tail)
+	}
+	if extra > 0 {
+		_, err := tw.w.Write(tw.tail[:extra])
+		if err != nil {
+			return 0, err
+		}
+
+		// Shift remaining bytes in tail over.
+		n := copy(tw.tail, tw.tail[extra:])
+		tw.tail = tw.tail[:n]
+	}
+
+	// If p is less than or equal to 4 bytes,
+	// all of it is is part of the tail.
+	if len(p) <= 4 {
+		tw.tail = append(tw.tail, p...)
+		return len(p), nil
+	}
+
+	// Otherwise, only the last 4 bytes are.
+	tw.tail = append(tw.tail, p[len(p)-4:]...)
+
+	p = p[:len(p)-4]
+	n, err := tw.w.Write(p)
+	return n + 4, err
+}
+
+var flateReaderPool sync.Pool
+
+func getFlateReader(r io.Reader, dict []byte) io.Reader {
+	fr, ok := flateReaderPool.Get().(io.Reader)
+	if !ok {
+		return flate.NewReaderDict(r, dict)
+	}
+	fr.(flate.Resetter).Reset(r, dict)
+	return fr
+}
+
+func putFlateReader(fr io.Reader) {
+	flateReaderPool.Put(fr)
+}
+
+type slidingWindow struct {
+	buf []byte
+}
+
+var swPoolMu sync.RWMutex
+var swPool = map[int]*sync.Pool{}
+
+func slidingWindowPool(n int) *sync.Pool {
+	swPoolMu.RLock()
+	p, ok := swPool[n]
+	swPoolMu.RUnlock()
+	if ok {
+		return p
+	}
+
+	p = &sync.Pool{}
+
+	swPoolMu.Lock()
+	swPool[n] = p
+	swPoolMu.Unlock()
+
+	return p
+}
+
+func (sw *slidingWindow) init(n int) {
+	if sw.buf != nil {
+		return
+	}
+
+	if n == 0 {
+		n = 32768
+	}
+
+	p := slidingWindowPool(n)
+	buf, ok := p.Get().([]byte)
+	if ok {
+		sw.buf = buf[:0]
+	} else {
+		sw.buf = make([]byte, 0, n)
+	}
+}
+
+func (sw *slidingWindow) close() {
+	if sw.buf == nil {
+		return
+	}
+
+	swPoolMu.Lock()
+	swPool[cap(sw.buf)].Put(sw.buf)
+	swPoolMu.Unlock()
+	sw.buf = nil
+}
+
+func (sw *slidingWindow) write(p []byte) {
+	if len(p) >= cap(sw.buf) {
+		sw.buf = sw.buf[:cap(sw.buf)]
+		p = p[len(p)-cap(sw.buf):]
+		copy(sw.buf, p)
+		return
+	}
+
+	left := cap(sw.buf) - len(sw.buf)
+	if left < len(p) {
+		// We need to shift spaceNeeded bytes from the end to make room for p at the end.
+		spaceNeeded := len(p) - left
+		copy(sw.buf, sw.buf[spaceNeeded:])
+		sw.buf = sw.buf[:len(sw.buf)-spaceNeeded]
+	}
+
+	sw.buf = append(sw.buf, p...)
+}
diff --git a/conn.go b/conn.go
index ab37248..a41808b 100644
--- a/conn.go
+++ b/conn.go
@@ -1,20 +1,5 @@
-//go:build !js
-// +build !js
-
 package websocket
 
-import (
-	"bufio"
-	"context"
-	"errors"
-	"fmt"
-	"io"
-	"runtime"
-	"strconv"
-	"sync"
-	"sync/atomic"
-)
-
 // MessageType represents the type of a WebSocket message.
 // See https://tools.ietf.org/html/rfc6455#section-5.6
 type MessageType int
@@ -26,265 +11,3 @@ const (
 	// MessageBinary is for binary messages like protobufs.
 	MessageBinary
 )
-
-// Conn represents a WebSocket connection.
-// All methods may be called concurrently except for Reader and Read.
-//
-// You must always read from the connection. Otherwise control
-// frames will not be handled. See Reader and CloseRead.
-//
-// Be sure to call Close on the connection when you
-// are finished with it to release associated resources.
-//
-// On any error from any method, the connection is closed
-// with an appropriate reason.
-//
-// This applies to context expirations as well unfortunately.
-// See https://github.com/nhooyr/websocket/issues/242#issuecomment-633182220
-type Conn struct {
-	subprotocol    string
-	rwc            io.ReadWriteCloser
-	client         bool
-	copts          *compressionOptions
-	flateThreshold int
-	br             *bufio.Reader
-	bw             *bufio.Writer
-
-	readTimeout  chan context.Context
-	writeTimeout chan context.Context
-
-	// Read state.
-	readMu            *mu
-	readHeaderBuf     [8]byte
-	readControlBuf    [maxControlPayload]byte
-	msgReader         *msgReader
-	readCloseFrameErr error
-
-	// Write state.
-	msgWriterState *msgWriterState
-	writeFrameMu   *mu
-	writeBuf       []byte
-	writeHeaderBuf [8]byte
-	writeHeader    header
-
-	closed     chan struct{}
-	closeMu    sync.Mutex
-	closeErr   error
-	wroteClose bool
-
-	pingCounter   int32
-	activePingsMu sync.Mutex
-	activePings   map[string]chan<- struct{}
-}
-
-type connConfig struct {
-	subprotocol    string
-	rwc            io.ReadWriteCloser
-	client         bool
-	copts          *compressionOptions
-	flateThreshold int
-
-	br *bufio.Reader
-	bw *bufio.Writer
-}
-
-func newConn(cfg connConfig) *Conn {
-	c := &Conn{
-		subprotocol:    cfg.subprotocol,
-		rwc:            cfg.rwc,
-		client:         cfg.client,
-		copts:          cfg.copts,
-		flateThreshold: cfg.flateThreshold,
-
-		br: cfg.br,
-		bw: cfg.bw,
-
-		readTimeout:  make(chan context.Context),
-		writeTimeout: make(chan context.Context),
-
-		closed:      make(chan struct{}),
-		activePings: make(map[string]chan<- struct{}),
-	}
-
-	c.readMu = newMu(c)
-	c.writeFrameMu = newMu(c)
-
-	c.msgReader = newMsgReader(c)
-
-	c.msgWriterState = newMsgWriterState(c)
-	if c.client {
-		c.writeBuf = extractBufioWriterBuf(c.bw, c.rwc)
-	}
-
-	if c.flate() && c.flateThreshold == 0 {
-		c.flateThreshold = 128
-		if !c.msgWriterState.flateContextTakeover() {
-			c.flateThreshold = 512
-		}
-	}
-
-	runtime.SetFinalizer(c, func(c *Conn) {
-		c.close(errors.New("connection garbage collected"))
-	})
-
-	go c.timeoutLoop()
-
-	return c
-}
-
-// Subprotocol returns the negotiated subprotocol.
-// An empty string means the default protocol.
-func (c *Conn) Subprotocol() string {
-	return c.subprotocol
-}
-
-func (c *Conn) close(err error) {
-	c.closeMu.Lock()
-	defer c.closeMu.Unlock()
-
-	if c.isClosed() {
-		return
-	}
-	c.setCloseErrLocked(err)
-	close(c.closed)
-	runtime.SetFinalizer(c, nil)
-
-	// Have to close after c.closed is closed to ensure any goroutine that wakes up
-	// from the connection being closed also sees that c.closed is closed and returns
-	// closeErr.
-	c.rwc.Close()
-
-	go func() {
-		c.msgWriterState.close()
-
-		c.msgReader.close()
-	}()
-}
-
-func (c *Conn) timeoutLoop() {
-	readCtx := context.Background()
-	writeCtx := context.Background()
-
-	for {
-		select {
-		case <-c.closed:
-			return
-
-		case writeCtx = <-c.writeTimeout:
-		case readCtx = <-c.readTimeout:
-
-		case <-readCtx.Done():
-			c.setCloseErr(fmt.Errorf("read timed out: %w", readCtx.Err()))
-			go c.writeError(StatusPolicyViolation, errors.New("timed out"))
-		case <-writeCtx.Done():
-			c.close(fmt.Errorf("write timed out: %w", writeCtx.Err()))
-			return
-		}
-	}
-}
-
-func (c *Conn) flate() bool {
-	return c.copts != nil
-}
-
-// Ping sends a ping to the peer and waits for a pong.
-// Use this to measure latency or ensure the peer is responsive.
-// Ping must be called concurrently with Reader as it does
-// not read from the connection but instead waits for a Reader call
-// to read the pong.
-//
-// TCP Keepalives should suffice for most use cases.
-func (c *Conn) Ping(ctx context.Context) error {
-	p := atomic.AddInt32(&c.pingCounter, 1)
-
-	err := c.ping(ctx, strconv.Itoa(int(p)))
-	if err != nil {
-		return fmt.Errorf("failed to ping: %w", err)
-	}
-	return nil
-}
-
-func (c *Conn) ping(ctx context.Context, p string) error {
-	pong := make(chan struct{}, 1)
-
-	c.activePingsMu.Lock()
-	c.activePings[p] = pong
-	c.activePingsMu.Unlock()
-
-	defer func() {
-		c.activePingsMu.Lock()
-		delete(c.activePings, p)
-		c.activePingsMu.Unlock()
-	}()
-
-	err := c.writeControl(ctx, opPing, []byte(p))
-	if err != nil {
-		return err
-	}
-
-	select {
-	case <-c.closed:
-		return c.closeErr
-	case <-ctx.Done():
-		err := fmt.Errorf("failed to wait for pong: %w", ctx.Err())
-		c.close(err)
-		return err
-	case <-pong:
-		return nil
-	}
-}
-
-type mu struct {
-	c  *Conn
-	ch chan struct{}
-}
-
-func newMu(c *Conn) *mu {
-	return &mu{
-		c:  c,
-		ch: make(chan struct{}, 1),
-	}
-}
-
-func (m *mu) forceLock() {
-	m.ch <- struct{}{}
-}
-
-func (m *mu) tryLock() bool {
-	select {
-	case m.ch <- struct{}{}:
-		return true
-	default:
-		return false
-	}
-}
-
-func (m *mu) lock(ctx context.Context) error {
-	select {
-	case <-m.c.closed:
-		return m.c.closeErr
-	case <-ctx.Done():
-		err := fmt.Errorf("failed to acquire lock: %w", ctx.Err())
-		m.c.close(err)
-		return err
-	case m.ch <- struct{}{}:
-		// To make sure the connection is certainly alive.
-		// As it's possible the send on m.ch was selected
-		// over the receive on closed.
-		select {
-		case <-m.c.closed:
-			// Make sure to release.
-			m.unlock()
-			return m.c.closeErr
-		default:
-		}
-		return nil
-	}
-}
-
-func (m *mu) unlock() {
-	select {
-	case <-m.ch:
-	default:
-	}
-}
diff --git a/conn_notjs.go b/conn_notjs.go
new file mode 100644
index 0000000..0c85ab7
--- /dev/null
+++ b/conn_notjs.go
@@ -0,0 +1,265 @@
+// +build !js
+
+package websocket
+
+import (
+	"bufio"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"runtime"
+	"strconv"
+	"sync"
+	"sync/atomic"
+)
+
+// Conn represents a WebSocket connection.
+// All methods may be called concurrently except for Reader and Read.
+//
+// You must always read from the connection. Otherwise control
+// frames will not be handled. See Reader and CloseRead.
+//
+// Be sure to call Close on the connection when you
+// are finished with it to release associated resources.
+//
+// On any error from any method, the connection is closed
+// with an appropriate reason.
+type Conn struct {
+	subprotocol    string
+	rwc            io.ReadWriteCloser
+	client         bool
+	copts          *compressionOptions
+	flateThreshold int
+	br             *bufio.Reader
+	bw             *bufio.Writer
+
+	readTimeout  chan context.Context
+	writeTimeout chan context.Context
+
+	// Read state.
+	readMu            *mu
+	readHeaderBuf     [8]byte
+	readControlBuf    [maxControlPayload]byte
+	msgReader         *msgReader
+	readCloseFrameErr error
+
+	// Write state.
+	msgWriterState *msgWriterState
+	writeFrameMu   *mu
+	writeBuf       []byte
+	writeHeaderBuf [8]byte
+	writeHeader    header
+
+	closed     chan struct{}
+	closeMu    sync.Mutex
+	closeErr   error
+	wroteClose bool
+
+	pingCounter   int32
+	activePingsMu sync.Mutex
+	activePings   map[string]chan<- struct{}
+}
+
+type connConfig struct {
+	subprotocol    string
+	rwc            io.ReadWriteCloser
+	client         bool
+	copts          *compressionOptions
+	flateThreshold int
+
+	br *bufio.Reader
+	bw *bufio.Writer
+}
+
+func newConn(cfg connConfig) *Conn {
+	c := &Conn{
+		subprotocol:    cfg.subprotocol,
+		rwc:            cfg.rwc,
+		client:         cfg.client,
+		copts:          cfg.copts,
+		flateThreshold: cfg.flateThreshold,
+
+		br: cfg.br,
+		bw: cfg.bw,
+
+		readTimeout:  make(chan context.Context),
+		writeTimeout: make(chan context.Context),
+
+		closed:      make(chan struct{}),
+		activePings: make(map[string]chan<- struct{}),
+	}
+
+	c.readMu = newMu(c)
+	c.writeFrameMu = newMu(c)
+
+	c.msgReader = newMsgReader(c)
+
+	c.msgWriterState = newMsgWriterState(c)
+	if c.client {
+		c.writeBuf = extractBufioWriterBuf(c.bw, c.rwc)
+	}
+
+	if c.flate() && c.flateThreshold == 0 {
+		c.flateThreshold = 128
+		if !c.msgWriterState.flateContextTakeover() {
+			c.flateThreshold = 512
+		}
+	}
+
+	runtime.SetFinalizer(c, func(c *Conn) {
+		c.close(errors.New("connection garbage collected"))
+	})
+
+	go c.timeoutLoop()
+
+	return c
+}
+
+// Subprotocol returns the negotiated subprotocol.
+// An empty string means the default protocol.
+func (c *Conn) Subprotocol() string {
+	return c.subprotocol
+}
+
+func (c *Conn) close(err error) {
+	c.closeMu.Lock()
+	defer c.closeMu.Unlock()
+
+	if c.isClosed() {
+		return
+	}
+	c.setCloseErrLocked(err)
+	close(c.closed)
+	runtime.SetFinalizer(c, nil)
+
+	// Have to close after c.closed is closed to ensure any goroutine that wakes up
+	// from the connection being closed also sees that c.closed is closed and returns
+	// closeErr.
+	c.rwc.Close()
+
+	go func() {
+		c.msgWriterState.close()
+
+		c.msgReader.close()
+	}()
+}
+
+func (c *Conn) timeoutLoop() {
+	readCtx := context.Background()
+	writeCtx := context.Background()
+
+	for {
+		select {
+		case <-c.closed:
+			return
+
+		case writeCtx = <-c.writeTimeout:
+		case readCtx = <-c.readTimeout:
+
+		case <-readCtx.Done():
+			c.setCloseErr(fmt.Errorf("read timed out: %w", readCtx.Err()))
+			go c.writeError(StatusPolicyViolation, errors.New("timed out"))
+		case <-writeCtx.Done():
+			c.close(fmt.Errorf("write timed out: %w", writeCtx.Err()))
+			return
+		}
+	}
+}
+
+func (c *Conn) flate() bool {
+	return c.copts != nil
+}
+
+// Ping sends a ping to the peer and waits for a pong.
+// Use this to measure latency or ensure the peer is responsive.
+// Ping must be called concurrently with Reader as it does
+// not read from the connection but instead waits for a Reader call
+// to read the pong.
+//
+// TCP Keepalives should suffice for most use cases.
+func (c *Conn) Ping(ctx context.Context) error {
+	p := atomic.AddInt32(&c.pingCounter, 1)
+
+	err := c.ping(ctx, strconv.Itoa(int(p)))
+	if err != nil {
+		return fmt.Errorf("failed to ping: %w", err)
+	}
+	return nil
+}
+
+func (c *Conn) ping(ctx context.Context, p string) error {
+	pong := make(chan struct{}, 1)
+
+	c.activePingsMu.Lock()
+	c.activePings[p] = pong
+	c.activePingsMu.Unlock()
+
+	defer func() {
+		c.activePingsMu.Lock()
+		delete(c.activePings, p)
+		c.activePingsMu.Unlock()
+	}()
+
+	err := c.writeControl(ctx, opPing, []byte(p))
+	if err != nil {
+		return err
+	}
+
+	select {
+	case <-c.closed:
+		return c.closeErr
+	case <-ctx.Done():
+		err := fmt.Errorf("failed to wait for pong: %w", ctx.Err())
+		c.close(err)
+		return err
+	case <-pong:
+		return nil
+	}
+}
+
+type mu struct {
+	c  *Conn
+	ch chan struct{}
+}
+
+func newMu(c *Conn) *mu {
+	return &mu{
+		c:  c,
+		ch: make(chan struct{}, 1),
+	}
+}
+
+func (m *mu) forceLock() {
+	m.ch <- struct{}{}
+}
+
+func (m *mu) lock(ctx context.Context) error {
+	select {
+	case <-m.c.closed:
+		return m.c.closeErr
+	case <-ctx.Done():
+		err := fmt.Errorf("failed to acquire lock: %w", ctx.Err())
+		m.c.close(err)
+		return err
+	case m.ch <- struct{}{}:
+		// To make sure the connection is certainly alive.
+		// As it's possible the send on m.ch was selected
+		// over the receive on closed.
+		select {
+		case <-m.c.closed:
+			// Make sure to release.
+			m.unlock()
+			return m.c.closeErr
+		default:
+		}
+		return nil
+	}
+}
+
+func (m *mu) unlock() {
+	select {
+	case <-m.ch:
+	default:
+	}
+}
diff --git a/dial.go b/dial.go
index 7e77d6e..2889a37 100644
--- a/dial.go
+++ b/dial.go
@@ -1,4 +1,3 @@
-//go:build !js
 // +build !js
 
 package websocket
@@ -11,7 +10,6 @@ import (
 	"encoding/base64"
 	"fmt"
 	"io"
-	"io/ioutil"
 	"net/http"
 	"net/url"
 	"strings"
@@ -35,7 +33,7 @@ type DialOptions struct {
 	Subprotocols []string
 
 	// CompressionMode controls the compression mode.
-	// Defaults to CompressionDisabled.
+	// Defaults to CompressionNoContextTakeover.
 	//
 	// See docs on CompressionMode for details.
 	CompressionMode CompressionMode
@@ -47,29 +45,6 @@ type DialOptions struct {
 	CompressionThreshold int
 }
 
-func (opts *DialOptions) cloneWithDefaults(ctx context.Context) (context.Context, context.CancelFunc, *DialOptions) {
-	var cancel context.CancelFunc
-
-	var o DialOptions
-	if opts != nil {
-		o = *opts
-	}
-	if o.HTTPClient == nil {
-		o.HTTPClient = http.DefaultClient
-	} else if opts.HTTPClient.Timeout > 0 {
-		ctx, cancel = context.WithTimeout(ctx, opts.HTTPClient.Timeout)
-
-		newClient := *opts.HTTPClient
-		newClient.Timeout = 0
-		opts.HTTPClient = &newClient
-	}
-	if o.HTTPHeader == nil {
-		o.HTTPHeader = http.Header{}
-	}
-
-	return ctx, cancel, &o
-}
-
 // Dial performs a WebSocket handshake on url.
 //
 // The response is the WebSocket handshake response from the server.
@@ -90,10 +65,26 @@ func Dial(ctx context.Context, u string, opts *DialOptions) (*Conn, *http.Respon
 func dial(ctx context.Context, urls string, opts *DialOptions, rand io.Reader) (_ *Conn, _ *http.Response, err error) {
 	defer errd.Wrap(&err, "failed to WebSocket dial")
 
-	var cancel context.CancelFunc
-	ctx, cancel, opts = opts.cloneWithDefaults(ctx)
-	if cancel != nil {
+	if opts == nil {
+		opts = &DialOptions{}
+	}
+
+	opts = &*opts
+	if opts.HTTPClient == nil {
+		opts.HTTPClient = http.DefaultClient
+	} else if opts.HTTPClient.Timeout > 0 {
+		var cancel context.CancelFunc
+
+		ctx, cancel = context.WithTimeout(ctx, opts.HTTPClient.Timeout)
 		defer cancel()
+
+		newClient := *opts.HTTPClient
+		newClient.Timeout = 0
+		opts.HTTPClient = &newClient
+	}
+
+	if opts.HTTPHeader == nil {
+		opts.HTTPHeader = http.Header{}
 	}
 
 	secWebSocketKey, err := secWebSocketKey(rand)
@@ -122,9 +113,9 @@ func dial(ctx context.Context, urls string, opts *DialOptions, rand io.Reader) (
 			})
 			defer timer.Stop()
 
-			b, _ := ioutil.ReadAll(r)
+			b, _ := io.ReadAll(r)
 			respBody.Close()
-			resp.Body = ioutil.NopCloser(bytes.NewReader(b))
+			resp.Body = io.NopCloser(bytes.NewReader(b))
 		}
 	}()
 
@@ -251,8 +242,7 @@ func verifyServerExtensions(copts *compressionOptions, h http.Header) (*compress
 		return nil, fmt.Errorf("WebSocket protcol violation: unsupported extensions from server: %+v", exts[1:])
 	}
 
-	_copts := *copts
-	copts = &_copts
+	copts = &*copts
 
 	for _, p := range ext.params {
 		switch p {
diff --git a/doc.go b/doc.go
index a2b873c..43c7d92 100644
--- a/doc.go
+++ b/doc.go
@@ -1,4 +1,3 @@
-//go:build !js
 // +build !js
 
 // Package websocket implements the RFC 6455 WebSocket protocol.
diff --git a/examples/chat/chat.go b/examples/chat/chat.go
index 532e50f..9d393d8 100644
--- a/examples/chat/chat.go
+++ b/examples/chat/chat.go
@@ -3,7 +3,7 @@ package main
 import (
 	"context"
 	"errors"
-	"io/ioutil"
+	"io"
 	"log"
 	"net/http"
 	"sync"
@@ -98,7 +98,7 @@ func (cs *chatServer) publishHandler(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	body := http.MaxBytesReader(w, r.Body, 8192)
-	msg, err := ioutil.ReadAll(body)
+	msg, err := io.ReadAll(body)
 	if err != nil {
 		http.Error(w, http.StatusText(http.StatusRequestEntityTooLarge), http.StatusRequestEntityTooLarge)
 		return
diff --git a/examples/chat/index.css b/examples/chat/index.css
index ce27c37..73a8e0f 100644
--- a/examples/chat/index.css
+++ b/examples/chat/index.css
@@ -54,7 +54,7 @@ body {
   margin: 0 0 0 10px;
 }
 
-#publish-form input[type='text'] {
+#publish-form input[type="text"] {
   flex-grow: 1;
 
   -moz-appearance: none;
@@ -64,7 +64,7 @@ body {
   border: 1px solid #ccc;
 }
 
-#publish-form input[type='submit'] {
+#publish-form input[type="submit"] {
   color: white;
   background-color: black;
   border-radius: 5px;
@@ -72,10 +72,10 @@ body {
   border: none;
 }
 
-#publish-form input[type='submit']:hover {
+#publish-form input[type="submit"]:hover {
   background-color: red;
 }
 
-#publish-form input[type='submit']:active {
+#publish-form input[type="submit"]:active {
   background-color: red;
 }
diff --git a/examples/chat/index.html b/examples/chat/index.html
index 64edd28..76ae837 100644
--- a/examples/chat/index.html
+++ b/examples/chat/index.html
@@ -1,4 +1,4 @@
-<!doctype html>
+<!DOCTYPE html>
 <html lang="en-CA">
   <head>
     <meta charset="UTF-8" />
diff --git a/examples/chat/index.js b/examples/chat/index.js
index 2efca01..5868e7c 100644
--- a/examples/chat/index.js
+++ b/examples/chat/index.js
@@ -6,21 +6,21 @@
   function dial() {
     const conn = new WebSocket(`ws://${location.host}/subscribe`)
 
-    conn.addEventListener('close', ev => {
+    conn.addEventListener("close", ev => {
       appendLog(`WebSocket Disconnected code: ${ev.code}, reason: ${ev.reason}`, true)
       if (ev.code !== 1001) {
-        appendLog('Reconnecting in 1s', true)
+        appendLog("Reconnecting in 1s", true)
         setTimeout(dial, 1000)
       }
     })
-    conn.addEventListener('open', ev => {
-      console.info('websocket connected')
+    conn.addEventListener("open", ev => {
+      console.info("websocket connected")
     })
 
     // This is where we handle messages received.
-    conn.addEventListener('message', ev => {
-      if (typeof ev.data !== 'string') {
-        console.error('unexpected message type', typeof ev.data)
+    conn.addEventListener("message", ev => {
+      if (typeof ev.data !== "string") {
+        console.error("unexpected message type", typeof ev.data)
         return
       }
       const p = appendLog(ev.data)
@@ -32,38 +32,38 @@
   }
   dial()
 
-  const messageLog = document.getElementById('message-log')
-  const publishForm = document.getElementById('publish-form')
-  const messageInput = document.getElementById('message-input')
+  const messageLog = document.getElementById("message-log")
+  const publishForm = document.getElementById("publish-form")
+  const messageInput = document.getElementById("message-input")
 
   // appendLog appends the passed text to messageLog.
   function appendLog(text, error) {
-    const p = document.createElement('p')
+    const p = document.createElement("p")
     // Adding a timestamp to each message makes the log easier to read.
     p.innerText = `${new Date().toLocaleTimeString()}: ${text}`
     if (error) {
-      p.style.color = 'red'
-      p.style.fontStyle = 'bold'
+      p.style.color = "red"
+      p.style.fontStyle = "bold"
     }
     messageLog.append(p)
     return p
   }
-  appendLog('Submit a message to get started!')
+  appendLog("Submit a message to get started!")
 
   // onsubmit publishes the message from the user when the form is submitted.
   publishForm.onsubmit = async ev => {
     ev.preventDefault()
 
     const msg = messageInput.value
-    if (msg === '') {
+    if (msg === "") {
       return
     }
-    messageInput.value = ''
+    messageInput.value = ""
 
     expectingMessage = true
     try {
-      const resp = await fetch('/publish', {
-        method: 'POST',
+      const resp = await fetch("/publish", {
+        method: "POST",
         body: msg,
       })
       if (resp.status !== 202) {
diff --git a/go.mod b/go.mod
index 50c873b..c5f1a20 100644
--- a/go.mod
+++ b/go.mod
@@ -1,41 +1,15 @@
 module nhooyr.io/websocket
 
-go 1.19
+go 1.13
 
 require (
-	github.com/gin-gonic/gin v1.9.1
-	github.com/gobwas/ws v1.3.0
-	github.com/golang/protobuf v1.5.3
-	github.com/google/go-cmp v0.5.9
-	github.com/gorilla/websocket v1.5.0
-	golang.org/x/time v0.3.0
-)
-
-require (
-	github.com/bytedance/sonic v1.9.1 // indirect
-	github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
-	github.com/gabriel-vasile/mimetype v1.4.2 // indirect
-	github.com/gin-contrib/sse v0.1.0 // indirect
-	github.com/go-playground/locales v0.14.1 // indirect
-	github.com/go-playground/universal-translator v0.18.1 // indirect
-	github.com/go-playground/validator/v10 v10.14.0 // indirect
-	github.com/gobwas/httphead v0.1.0 // indirect
-	github.com/gobwas/pool v0.2.1 // indirect
-	github.com/goccy/go-json v0.10.2 // indirect
-	github.com/json-iterator/go v1.1.12 // indirect
-	github.com/klauspost/cpuid/v2 v2.2.4 // indirect
-	github.com/leodido/go-urn v1.2.4 // indirect
-	github.com/mattn/go-isatty v0.0.19 // indirect
-	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
-	github.com/modern-go/reflect2 v1.0.2 // indirect
-	github.com/pelletier/go-toml/v2 v2.0.8 // indirect
-	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
-	github.com/ugorji/go/codec v1.2.11 // indirect
-	golang.org/x/arch v0.3.0 // indirect
-	golang.org/x/crypto v0.9.0 // indirect
-	golang.org/x/net v0.10.0 // indirect
-	golang.org/x/sys v0.8.0 // indirect
-	golang.org/x/text v0.9.0 // indirect
-	google.golang.org/protobuf v1.30.0 // indirect
-	gopkg.in/yaml.v3 v3.0.1 // indirect
+	github.com/gin-gonic/gin v1.6.3
+	github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee // indirect
+	github.com/gobwas/pool v0.2.0 // indirect
+	github.com/gobwas/ws v1.0.2
+	github.com/golang/protobuf v1.3.5
+	github.com/google/go-cmp v0.4.0
+	github.com/gorilla/websocket v1.4.1
+	github.com/klauspost/compress v1.10.3
+	golang.org/x/time v0.0.0-20191024005414-555d28b269f0
 )
diff --git a/go.sum b/go.sum
index 78c452e..155c301 100644
--- a/go.sum
+++ b/go.sum
@@ -1,98 +1,64 @@
-github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
-github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
-github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
-github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
-github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
-github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
 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/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
-github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
 github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
 github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
-github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
-github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
-github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
-github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
-github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
-github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
-github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
-github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
-github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
-github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
-github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
-github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
-github.com/gobwas/ws v1.3.0 h1:sbeU3Y4Qzlb+MOzIe6mQGf7QR4Hkv6ZD0qhGkBFL2O0=
-github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
-github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
-github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
-github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
-github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
-github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
-github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
+github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
+github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
+github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
+github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
+github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
+github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
+github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY=
+github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
+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.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.5 h1:F768QJ1E9tib+q5Sc8MkdJi1RxLTbRcTf8LJV56aRls=
+github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
+github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
-github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
-github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
-github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
-github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
-github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
-github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
-github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
-github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
-github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
+github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
+github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8=
+github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
+github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
+github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
+github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
-github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
-github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
-github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
-github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
-github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
-github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
-github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
-golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
-golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
-golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
-golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
-golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
-golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
-golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
-golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
-golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
-golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
+github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
+github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
+github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs=
+golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
-google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
-google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/internal/wsjs/wsjs_js.go b/internal/wsjs/wsjs_js.go
index 88e8f43..26ffb45 100644
--- a/internal/wsjs/wsjs_js.go
+++ b/internal/wsjs/wsjs_js.go
@@ -1,4 +1,3 @@
-//go:build js
 // +build js
 
 // Package wsjs implements typed access to the browser javascript WebSocket API.
diff --git a/make.sh b/make.sh
deleted file mode 100755
index 6f5d1f5..0000000
--- a/make.sh
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/bin/sh
-set -eu
-cd -- "$(dirname "$0")"
-
-./ci/fmt.sh
-./ci/lint.sh
-./ci/test.sh
diff --git a/netconn.go b/netconn.go
index aea1a02..64aadf0 100644
--- a/netconn.go
+++ b/netconn.go
@@ -6,7 +6,7 @@ import (
 	"io"
 	"math"
 	"net"
-	"sync/atomic"
+	"sync"
 	"time"
 )
 
@@ -28,59 +28,30 @@ import (
 //
 // Close will close the *websocket.Conn with StatusNormalClosure.
 //
-// When a deadline is hit and there is an active read or write goroutine, the
-// connection will be closed. This is different from most net.Conn implementations
-// where only the reading/writing goroutines are interrupted but the connection
-// is kept alive.
+// When a deadline is hit, the connection will be closed. This is
+// different from most net.Conn implementations where only the
+// reading/writing goroutines are interrupted but the connection is kept alive.
 //
 // The Addr methods will return a mock net.Addr that returns "websocket" for Network
 // and "websocket/unknown-addr" for String.
 //
 // A received StatusNormalClosure or StatusGoingAway close frame will be translated to
 // io.EOF when reading.
-//
-// Furthermore, the ReadLimit is set to -1 to disable it.
 func NetConn(ctx context.Context, c *Conn, msgType MessageType) net.Conn {
-	c.SetReadLimit(-1)
-
 	nc := &netConn{
 		c:       c,
 		msgType: msgType,
-		readMu:  newMu(c),
-		writeMu: newMu(c),
 	}
 
-	nc.writeCtx, nc.writeCancel = context.WithCancel(ctx)
-	nc.readCtx, nc.readCancel = context.WithCancel(ctx)
-
-	nc.writeTimer = time.AfterFunc(math.MaxInt64, func() {
-		if !nc.writeMu.tryLock() {
-			// If the lock cannot be acquired, then there is an
-			// active write goroutine and so we should cancel the context.
-			nc.writeCancel()
-			return
-		}
-		defer nc.writeMu.unlock()
-
-		// Prevents future writes from writing until the deadline is reset.
-		atomic.StoreInt64(&nc.writeExpired, 1)
-	})
+	var cancel context.CancelFunc
+	nc.writeContext, cancel = context.WithCancel(ctx)
+	nc.writeTimer = time.AfterFunc(math.MaxInt64, cancel)
 	if !nc.writeTimer.Stop() {
 		<-nc.writeTimer.C
 	}
 
-	nc.readTimer = time.AfterFunc(math.MaxInt64, func() {
-		if !nc.readMu.tryLock() {
-			// If the lock cannot be acquired, then there is an
-			// active read goroutine and so we should cancel the context.
-			nc.readCancel()
-			return
-		}
-		defer nc.readMu.unlock()
-
-		// Prevents future reads from reading until the deadline is reset.
-		atomic.StoreInt64(&nc.readExpired, 1)
-	})
+	nc.readContext, cancel = context.WithCancel(ctx)
+	nc.readTimer = time.AfterFunc(math.MaxInt64, cancel)
 	if !nc.readTimer.Stop() {
 		<-nc.readTimer.C
 	}
@@ -93,78 +64,59 @@ type netConn struct {
 	msgType MessageType
 
 	writeTimer   *time.Timer
-	writeMu      *mu
-	writeExpired int64
-	writeCtx     context.Context
-	writeCancel  context.CancelFunc
+	writeContext context.Context
 
 	readTimer   *time.Timer
-	readMu      *mu
-	readExpired int64
-	readCtx     context.Context
-	readCancel  context.CancelFunc
-	readEOFed   bool
-	reader      io.Reader
+	readContext context.Context
+
+	readMu sync.Mutex
+	eofed  bool
+	reader io.Reader
 }
 
 var _ net.Conn = &netConn{}
 
-func (nc *netConn) Close() error {
-	nc.writeTimer.Stop()
-	nc.writeCancel()
-	nc.readTimer.Stop()
-	nc.readCancel()
-	return nc.c.Close(StatusNormalClosure, "")
+func (c *netConn) Close() error {
+	return c.c.Close(StatusNormalClosure, "")
 }
 
-func (nc *netConn) Write(p []byte) (int, error) {
-	nc.writeMu.forceLock()
-	defer nc.writeMu.unlock()
-
-	if atomic.LoadInt64(&nc.writeExpired) == 1 {
-		return 0, fmt.Errorf("failed to write: %w", context.DeadlineExceeded)
-	}
-
-	err := nc.c.Write(nc.writeCtx, nc.msgType, p)
+func (c *netConn) Write(p []byte) (int, error) {
+	err := c.c.Write(c.writeContext, c.msgType, p)
 	if err != nil {
 		return 0, err
 	}
 	return len(p), nil
 }
 
-func (nc *netConn) Read(p []byte) (int, error) {
-	nc.readMu.forceLock()
-	defer nc.readMu.unlock()
-
-	if atomic.LoadInt64(&nc.readExpired) == 1 {
-		return 0, fmt.Errorf("failed to read: %w", context.DeadlineExceeded)
-	}
+func (c *netConn) Read(p []byte) (int, error) {
+	c.readMu.Lock()
+	defer c.readMu.Unlock()
 
-	if nc.readEOFed {
+	if c.eofed {
 		return 0, io.EOF
 	}
 
-	if nc.reader == nil {
-		typ, r, err := nc.c.Reader(nc.readCtx)
+	if c.reader == nil {
+		typ, r, err := c.c.Reader(c.readContext)
 		if err != nil {
 			switch CloseStatus(err) {
 			case StatusNormalClosure, StatusGoingAway:
-				nc.readEOFed = true
+				c.eofed = true
 				return 0, io.EOF
 			}
 			return 0, err
 		}
-		if typ != nc.msgType {
-			err := fmt.Errorf("unexpected frame type read (expected %v): %v", nc.msgType, typ)
-			nc.c.Close(StatusUnsupportedData, err.Error())
+		if typ != c.msgType {
+			err := fmt.Errorf("unexpected frame type read (expected %v): %v", c.msgType, typ)
+			c.c.Close(StatusUnsupportedData, err.Error())
 			return 0, err
 		}
-		nc.reader = r
+		c.reader = r
 	}
 
-	n, err := nc.reader.Read(p)
+	n, err := c.reader.Read(p)
 	if err == io.EOF {
-		nc.reader = nil
+		c.reader = nil
 		err = nil
 	}
 	return n, err
@@ -181,36 +133,34 @@ func (a websocketAddr) String() string {
 	return "websocket/unknown-addr"
 }
 
-func (nc *netConn) RemoteAddr() net.Addr {
+func (c *netConn) RemoteAddr() net.Addr {
 	return websocketAddr{}
 }
 
-func (nc *netConn) LocalAddr() net.Addr {
+func (c *netConn) LocalAddr() net.Addr {
 	return websocketAddr{}
 }
 
-func (nc *netConn) SetDeadline(t time.Time) error {
-	nc.SetWriteDeadline(t)
-	nc.SetReadDeadline(t)
+func (c *netConn) SetDeadline(t time.Time) error {
+	c.SetWriteDeadline(t)
+	c.SetReadDeadline(t)
 	return nil
 }
 
-func (nc *netConn) SetWriteDeadline(t time.Time) error {
-	atomic.StoreInt64(&nc.writeExpired, 0)
+func (c *netConn) SetWriteDeadline(t time.Time) error {
 	if t.IsZero() {
-		nc.writeTimer.Stop()
+		c.writeTimer.Stop()
 	} else {
-		nc.writeTimer.Reset(t.Sub(time.Now()))
+		c.writeTimer.Reset(t.Sub(time.Now()))
 	}
 	return nil
 }
 
-func (nc *netConn) SetReadDeadline(t time.Time) error {
-	atomic.StoreInt64(&nc.readExpired, 0)
+func (c *netConn) SetReadDeadline(t time.Time) error {
 	if t.IsZero() {
-		nc.readTimer.Stop()
+		c.readTimer.Stop()
 	} else {
-		nc.readTimer.Reset(t.Sub(time.Now()))
+		c.readTimer.Reset(t.Sub(time.Now()))
 	}
 	return nil
 }
diff --git a/read.go b/read.go
index 87e9346..97a4f98 100644
--- a/read.go
+++ b/read.go
@@ -1,4 +1,3 @@
-//go:build !js
 // +build !js
 
 package websocket
@@ -9,7 +8,6 @@ import (
 	"errors"
 	"fmt"
 	"io"
-	"io/ioutil"
 	"strings"
 	"time"
 
@@ -27,11 +25,6 @@ import (
 // Call CloseRead if you do not expect any data messages from the peer.
 //
 // Only one Reader may be open at a time.
-//
-// If you need a separate timeout on the Reader call and the Read itself,
-// use time.AfterFunc to cancel the context passed in.
-// See https://github.com/nhooyr/websocket/issues/87#issue-451703332
-// Most users should not need this.
 func (c *Conn) Reader(ctx context.Context) (MessageType, io.Reader, error) {
 	return c.reader(ctx)
 }
@@ -44,7 +37,7 @@ func (c *Conn) Read(ctx context.Context) (MessageType, []byte, error) {
 		return 0, nil, err
 	}
 
-	b, err := ioutil.ReadAll(r)
+	b, err := io.ReadAll(r)
 	return typ, b, err
 }
 
@@ -75,16 +68,10 @@ func (c *Conn) CloseRead(ctx context.Context) context.Context {
 // By default, the connection has a message read limit of 32768 bytes.
 //
 // When the limit is hit, the connection will be closed with StatusMessageTooBig.
-//
-// Set to -1 to disable.
 func (c *Conn) SetReadLimit(n int64) {
-	if n >= 0 {
-		// We read one more byte than the limit in case
-		// there is a fin frame that needs to be read.
-		n++
-	}
-
-	c.msgReader.limitReader.limit.Store(n)
+	// We add read one more byte than the limit in case
+	// there is a fin frame that needs to be read.
+	c.msgReader.limitReader.limit.Store(n + 1)
 }
 
 const defaultReadLimit = 32768
@@ -465,11 +452,7 @@ func (lr *limitReader) reset(r io.Reader) {
 }
 
 func (lr *limitReader) Read(p []byte) (int, error) {
-	if lr.n < 0 {
-		return lr.r.Read(p)
-	}
-
-	if lr.n == 0 {
+	if lr.n <= 0 {
 		err := fmt.Errorf("read limited at %v bytes", lr.limit.Load())
 		lr.c.writeError(StatusMessageTooBig, err)
 		return 0, err
@@ -480,9 +463,6 @@ func (lr *limitReader) Read(p []byte) (int, error) {
 	}
 	n, err := lr.r.Read(p)
 	lr.n -= int64(n)
-	if lr.n < 0 {
-		lr.n = 0
-	}
 	return n, err
 }
 
diff --git a/write.go b/write.go
index 7921eac..2210cf8 100644
--- a/write.go
+++ b/write.go
@@ -1,4 +1,3 @@
-//go:build !js
 // +build !js
 
 package websocket
@@ -13,7 +12,7 @@ import (
 	"io"
 	"time"
 
-	"compress/flate"
+	"github.com/klauspost/compress/flate"
 
 	"nhooyr.io/websocket/internal/errd"
 )
@@ -77,8 +76,8 @@ type msgWriterState struct {
 	opcode opcode
 	flate  bool
 
-	trimWriter  *trimLastFourBytesWriter
-	flateWriter *flate.Writer
+	trimWriter *trimLastFourBytesWriter
+	dict       slidingWindow
 }
 
 func newMsgWriterState(c *Conn) *msgWriterState {
@@ -97,9 +96,7 @@ func (mw *msgWriterState) ensureFlate() {
 		}
 	}
 
-	if mw.flateWriter == nil {
-		mw.flateWriter = getFlateWriter(mw.trimWriter)
-	}
+	mw.dict.init(8192)
 	mw.flate = true
 }
 
@@ -156,13 +153,6 @@ func (mw *msgWriterState) reset(ctx context.Context, typ MessageType) error {
 	return nil
 }
 
-func (mw *msgWriterState) putFlateWriter() {
-	if mw.flateWriter != nil {
-		putFlateWriter(mw.flateWriter)
-		mw.flateWriter = nil
-	}
-}
-
 // Write writes the given bytes to the WebSocket connection.
 func (mw *msgWriterState) Write(p []byte) (_ int, err error) {
 	err = mw.writeMu.lock(mw.ctx)
@@ -187,7 +177,12 @@ func (mw *msgWriterState) Write(p []byte) (_ int, err error) {
 	}
 
 	if mw.flate {
-		return mw.flateWriter.Write(p)
+		err = flate.StatelessDeflate(mw.trimWriter, p, false, mw.dict.buf)
+		if err != nil {
+			return 0, err
+		}
+		mw.dict.write(p)
+		return len(p), nil
 	}
 
 	return mw.write(p)
@@ -212,20 +207,13 @@ func (mw *msgWriterState) Close() (err error) {
 	}
 	defer mw.writeMu.unlock()
 
-	if mw.flate {
-		err = mw.flateWriter.Flush()
-		if err != nil {
-			return fmt.Errorf("failed to flush flate: %w", err)
-		}
-	}
-
 	_, err = mw.c.writeFrame(mw.ctx, true, mw.flate, mw.opcode, nil)
 	if err != nil {
 		return fmt.Errorf("failed to write fin frame: %w", err)
 	}
 
 	if mw.flate && !mw.flateContextTakeover() {
-		mw.putFlateWriter()
+		mw.dict.close()
 	}
 	mw.mu.unlock()
 	return nil
@@ -238,7 +226,7 @@ func (mw *msgWriterState) close() {
 	}
 
 	mw.writeMu.forceLock()
-	mw.putFlateWriter()
+	mw.dict.close()
 }
 
 func (c *Conn) writeControl(ctx context.Context, opcode opcode, p []byte) error {
@@ -258,6 +246,7 @@ func (c *Conn) writeFrame(ctx context.Context, fin bool, flate bool, opcode opco
 	if err != nil {
 		return 0, err
 	}
+	defer c.writeFrameMu.unlock()
 
 	// If the state says a close has already been written, we wait until
 	// the connection is closed and return that error.
@@ -268,7 +257,6 @@ func (c *Conn) writeFrame(ctx context.Context, fin bool, flate bool, opcode opco
 	wroteClose := c.wroteClose
 	c.closeMu.Unlock()
 	if wroteClose && opcode != opClose {
-		c.writeFrameMu.unlock()
 		select {
 		case <-ctx.Done():
 			return 0, ctx.Err()
@@ -276,7 +264,6 @@ func (c *Conn) writeFrame(ctx context.Context, fin bool, flate bool, opcode opco
 			return 0, c.closeErr
 		}
 	}
-	defer c.writeFrameMu.unlock()
 
 	select {
 	case <-c.closed:
diff --git a/ws_js.go b/ws_js.go
index d136132..b87e32c 100644
--- a/ws_js.go
+++ b/ws_js.go
@@ -377,169 +377,3 @@ func (c *Conn) isClosed() bool {
 		return false
 	}
 }
-
-// AcceptOptions represents Accept's options.
-type AcceptOptions struct {
-	Subprotocols         []string
-	InsecureSkipVerify   bool
-	OriginPatterns       []string
-	CompressionMode      CompressionMode
-	CompressionThreshold int
-}
-
-// Accept is stubbed out for Wasm.
-func Accept(w http.ResponseWriter, r *http.Request, opts *AcceptOptions) (*Conn, error) {
-	return nil, errors.New("unimplemented")
-}
-
-// StatusCode represents a WebSocket status code.
-// https://tools.ietf.org/html/rfc6455#section-7.4
-type StatusCode int
-
-// https://www.iana.org/assignments/websocket/websocket.xhtml#close-code-number
-//
-// These are only the status codes defined by the protocol.
-//
-// You can define custom codes in the 3000-4999 range.
-// The 3000-3999 range is reserved for use by libraries, frameworks and applications.
-// The 4000-4999 range is reserved for private use.
-const (
-	StatusNormalClosure   StatusCode = 1000
-	StatusGoingAway       StatusCode = 1001
-	StatusProtocolError   StatusCode = 1002
-	StatusUnsupportedData StatusCode = 1003
-
-	// 1004 is reserved and so unexported.
-	statusReserved StatusCode = 1004
-
-	// StatusNoStatusRcvd cannot be sent in a close message.
-	// It is reserved for when a close message is received without
-	// a status code.
-	StatusNoStatusRcvd StatusCode = 1005
-
-	// StatusAbnormalClosure is exported for use only with Wasm.
-	// In non Wasm Go, the returned error will indicate whether the
-	// connection was closed abnormally.
-	StatusAbnormalClosure StatusCode = 1006
-
-	StatusInvalidFramePayloadData StatusCode = 1007
-	StatusPolicyViolation         StatusCode = 1008
-	StatusMessageTooBig           StatusCode = 1009
-	StatusMandatoryExtension      StatusCode = 1010
-	StatusInternalError           StatusCode = 1011
-	StatusServiceRestart          StatusCode = 1012
-	StatusTryAgainLater           StatusCode = 1013
-	StatusBadGateway              StatusCode = 1014
-
-	// StatusTLSHandshake is only exported for use with Wasm.
-	// In non Wasm Go, the returned error will indicate whether there was
-	// a TLS handshake failure.
-	StatusTLSHandshake StatusCode = 1015
-)
-
-// CloseError is returned when the connection is closed with a status and reason.
-//
-// Use Go 1.13's errors.As to check for this error.
-// Also see the CloseStatus helper.
-type CloseError struct {
-	Code   StatusCode
-	Reason string
-}
-
-func (ce CloseError) Error() string {
-	return fmt.Sprintf("status = %v and reason = %q", ce.Code, ce.Reason)
-}
-
-// CloseStatus is a convenience wrapper around Go 1.13's errors.As to grab
-// the status code from a CloseError.
-//
-// -1 will be returned if the passed error is nil or not a CloseError.
-func CloseStatus(err error) StatusCode {
-	var ce CloseError
-	if errors.As(err, &ce) {
-		return ce.Code
-	}
-	return -1
-}
-
-// CompressionMode represents the modes available to the deflate extension.
-// See https://tools.ietf.org/html/rfc7692
-//
-// A compatibility layer is implemented for the older deflate-frame extension used
-// by safari. See https://tools.ietf.org/html/draft-tyoshino-hybi-websocket-perframe-deflate-06
-// It will work the same in every way except that we cannot signal to the peer we
-// want to use no context takeover on our side, we can only signal that they should.
-// It is however currently disabled due to Safari bugs. See https://github.com/nhooyr/websocket/issues/218
-type CompressionMode int
-
-const (
-	// CompressionNoContextTakeover grabs a new flate.Reader and flate.Writer as needed
-	// for every message. This applies to both server and client side.
-	//
-	// This means less efficient compression as the sliding window from previous messages
-	// will not be used but the memory overhead will be lower if the connections
-	// are long lived and seldom used.
-	//
-	// The message will only be compressed if greater than 512 bytes.
-	CompressionNoContextTakeover CompressionMode = iota
-
-	// CompressionContextTakeover uses a flate.Reader and flate.Writer per connection.
-	// This enables reusing the sliding window from previous messages.
-	// As most WebSocket protocols are repetitive, this can be very efficient.
-	// It carries an overhead of 8 kB for every connection compared to CompressionNoContextTakeover.
-	//
-	// If the peer negotiates NoContextTakeover on the client or server side, it will be
-	// used instead as this is required by the RFC.
-	CompressionContextTakeover
-
-	// CompressionDisabled disables the deflate extension.
-	//
-	// Use this if you are using a predominantly binary protocol with very
-	// little duplication in between messages or CPU and memory are more
-	// important than bandwidth.
-	CompressionDisabled
-)
-
-// MessageType represents the type of a WebSocket message.
-// See https://tools.ietf.org/html/rfc6455#section-5.6
-type MessageType int
-
-// MessageType constants.
-const (
-	// MessageText is for UTF-8 encoded text messages like JSON.
-	MessageText MessageType = iota + 1
-	// MessageBinary is for binary messages like protobufs.
-	MessageBinary
-)
-
-type mu struct {
-	c  *Conn
-	ch chan struct{}
-}
-
-func newMu(c *Conn) *mu {
-	return &mu{
-		c:  c,
-		ch: make(chan struct{}, 1),
-	}
-}
-
-func (m *mu) forceLock() {
-	m.ch <- struct{}{}
-}
-
-func (m *mu) tryLock() bool {
-	select {
-	case m.ch <- struct{}{}:
-		return true
-	default:
-		return false
-	}
-}
-
-func (m *mu) unlock() {
-	select {
-	case <-m.ch:
-	default:
-	}
-}


Test patch:
diff --git a/accept_test.go b/accept_test.go
index ae17c0b..e114d1a 100644
--- a/accept_test.go
+++ b/accept_test.go
@@ -1,4 +1,3 @@
-//go:build !js
 // +build !js
 
 package websocket
@@ -40,23 +39,7 @@ func TestAccept(t *testing.T) {
 		r.Header.Set("Origin", "harhar.com")
 
 		_, err := Accept(w, r, nil)
-		assert.Contains(t, err, `request Origin "harhar.com" is not a valid URL with a host`)
-	})
-
-	// #247
-	t.Run("unauthorizedOriginErrorMessage", func(t *testing.T) {
-		t.Parallel()
-
-		w := httptest.NewRecorder()
-		r := httptest.NewRequest("GET", "/", nil)
-		r.Header.Set("Connection", "Upgrade")
-		r.Header.Set("Upgrade", "websocket")
-		r.Header.Set("Sec-WebSocket-Version", "13")
-		r.Header.Set("Sec-WebSocket-Key", "meow123")
-		r.Header.Set("Origin", "https://harhar.com")
-
-		_, err := Accept(w, r, nil)
-		assert.Contains(t, err, `request Origin "harhar.com" is not authorized for Host "example.com"`)
+		assert.Contains(t, err, `request Origin "harhar.com" is not authorized for Host`)
 	})
 
 	t.Run("badCompression", func(t *testing.T) {
@@ -72,9 +55,7 @@ func TestAccept(t *testing.T) {
 		r.Header.Set("Sec-WebSocket-Key", "meow123")
 		r.Header.Set("Sec-WebSocket-Extensions", "permessage-deflate; harharhar")
 
-		_, err := Accept(w, r, &AcceptOptions{
-			CompressionMode: CompressionContextTakeover,
-		})
+		_, err := Accept(w, r, nil)
 		assert.Contains(t, err, `unsupported permessage-deflate parameter`)
 	})
 
diff --git a/autobahn_test.go b/autobahn_test.go
index 23723b5..1bfb141 100644
--- a/autobahn_test.go
+++ b/autobahn_test.go
@@ -1,4 +1,3 @@
-//go:build !js
 // +build !js
 
 package websocket_test
@@ -7,7 +6,7 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
-	"io/ioutil"
+	"io"
 	"net"
 	"os"
 	"os/exec"
@@ -29,39 +28,25 @@ var excludedAutobahnCases = []string{
 
 	// We skip the tests related to requestMaxWindowBits as that is unimplemented due
 	// to limitations in compress/flate. See https://github.com/golang/go/issues/3155
+	// Same with klauspost/compress which doesn't allow adjusting the sliding window size.
 	"13.3.*", "13.4.*", "13.5.*", "13.6.*",
 }
 
 var autobahnCases = []string{"*"}
 
-// Used to run individual test cases. autobahnCases runs only those cases matched
-// and not excluded by excludedAutobahnCases. Adding cases here means excludedAutobahnCases
-// is niled.
-// TODO:
-var forceAutobahnCases = []string{}
-
 func TestAutobahn(t *testing.T) {
 	t.Parallel()
 
-	if os.Getenv("AUTOBAHN") == "" {
+	if os.Getenv("AUTOBAHN_TEST") == "" {
 		t.SkipNow()
 	}
 
-	if os.Getenv("AUTOBAHN") == "fast" {
-		// These are the slow tests.
-		excludedAutobahnCases = append(excludedAutobahnCases,
-			"9.*", "12.*", "13.*",
-		)
-	}
-
-	ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
+	ctx, cancel := context.WithTimeout(context.Background(), time.Minute*15)
 	defer cancel()
 
-	wstestURL, closeFn, err := wstestServer(ctx)
+	wstestURL, closeFn, err := wstestClientServer(ctx)
 	assert.Success(t, err)
-	defer func() {
-		assert.Success(t, closeFn())
-	}()
+	defer closeFn()
 
 	err = waitWS(ctx, wstestURL)
 	assert.Success(t, err)
@@ -76,9 +61,7 @@ func TestAutobahn(t *testing.T) {
 				ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5)
 				defer cancel()
 
-				c, _, err := websocket.Dial(ctx, fmt.Sprintf(wstestURL+"/runCase?case=%v&agent=main", i), &websocket.DialOptions{
-					CompressionMode: websocket.CompressionContextTakeover,
-				})
+				c, _, err := websocket.Dial(ctx, fmt.Sprintf(wstestURL+"/runCase?case=%v&agent=main", i), nil)
 				assert.Success(t, err)
 				err = wstest.EchoLoop(ctx, c)
 				t.Logf("echoLoop: %v", err)
@@ -109,26 +92,17 @@ func waitWS(ctx context.Context, url string) error {
 	return ctx.Err()
 }
 
-// TODO: Let docker pick the port and use docker port to find it.
-// Does mean we can't use -i but that's fine.
-func wstestServer(ctx context.Context) (url string, closeFn func() error, err error) {
-	defer errd.Wrap(&err, "failed to start autobahn wstest server")
-
+func wstestClientServer(ctx context.Context) (url string, closeFn func(), err error) {
 	serverAddr, err := unusedListenAddr()
 	if err != nil {
 		return "", nil, err
 	}
-	_, serverPort, err := net.SplitHostPort(serverAddr)
-	if err != nil {
-		return "", nil, err
-	}
 
 	url = "ws://" + serverAddr
-	const outDir = "ci/out/wstestClientReports"
 
 	specFile, err := tempJSONFile(map[string]interface{}{
 		"url":           url,
-		"outdir":        outDir,
+		"outdir":        "ci/out/wstestClientReports",
 		"cases":         autobahnCases,
 		"exclude-cases": excludedAutobahnCases,
 	})
@@ -136,56 +110,26 @@ func wstestServer(ctx context.Context) (url string, closeFn func() error, err er
 		return "", nil, fmt.Errorf("failed to write spec: %w", err)
 	}
 
-	ctx, cancel := context.WithTimeout(ctx, time.Hour)
+	ctx, cancel := context.WithTimeout(context.Background(), time.Minute*15)
 	defer func() {
 		if err != nil {
 			cancel()
 		}
 	}()
 
-	dockerPull := exec.CommandContext(ctx, "docker", "pull", "crossbario/autobahn-testsuite")
-	// TODO: log to *testing.T
-	dockerPull.Stdout = os.Stdout
-	dockerPull.Stderr = os.Stderr
-	err = dockerPull.Run()
-	if err != nil {
-		return "", nil, fmt.Errorf("failed to pull docker image: %w", err)
-	}
-
-	wd, err := os.Getwd()
-	if err != nil {
-		return "", nil, err
-	}
-
-	var args []string
-	args = append(args, "run", "-i", "--rm",
-		"-v", fmt.Sprintf("%s:%[1]s", specFile),
-		"-v", fmt.Sprintf("%s/ci:/ci", wd),
-		fmt.Sprintf("-p=%s:%s", serverAddr, serverPort),
-		"crossbario/autobahn-testsuite",
-	)
-	args = append(args, "wstest", "--mode", "fuzzingserver", "--spec", specFile,
+	args := []string{"--mode", "fuzzingserver", "--spec", specFile,
 		// Disables some server that runs as part of fuzzingserver mode.
 		// See https://github.com/crossbario/autobahn-testsuite/blob/058db3a36b7c3a1edf68c282307c6b899ca4857f/autobahntestsuite/autobahntestsuite/wstest.py#L124
 		"--webport=0",
-	)
-	fmt.Println(strings.Join(args, " "))
-	wstest := exec.CommandContext(ctx, "docker", args...)
-	// TODO: log to *testing.T
-	wstest.Stdout = os.Stdout
-	wstest.Stderr = os.Stderr
+	}
+	wstest := exec.CommandContext(ctx, "wstest", args...)
 	err = wstest.Start()
 	if err != nil {
 		return "", nil, fmt.Errorf("failed to start wstest: %w", err)
 	}
 
-	// TODO: kill
-	return url, func() error {
-		err = wstest.Process.Kill()
-		if err != nil {
-			return fmt.Errorf("failed to kill wstest: %w", err)
-		}
-		return nil
+	return url, func() {
+		wstest.Process.Kill()
 	}, nil
 }
 
@@ -202,7 +146,7 @@ func wstestCaseCount(ctx context.Context, url string) (cases int, err error) {
 	if err != nil {
 		return 0, err
 	}
-	b, err := ioutil.ReadAll(r)
+	b, err := io.ReadAll(r)
 	if err != nil {
 		return 0, err
 	}
@@ -217,7 +161,7 @@ func wstestCaseCount(ctx context.Context, url string) (cases int, err error) {
 }
 
 func checkWSTestIndex(t *testing.T, path string) {
-	wstestOut, err := ioutil.ReadFile(path)
+	wstestOut, err := os.ReadFile(path)
 	assert.Success(t, err)
 
 	var indexJSON map[string]map[string]struct {
@@ -262,7 +206,7 @@ func unusedListenAddr() (_ string, err error) {
 }
 
 func tempJSONFile(v interface{}) (string, error) {
-	f, err := ioutil.TempFile("", "temp.json")
+	f, err := os.CreateTemp("", "temp.json")
 	if err != nil {
 		return "", fmt.Errorf("temp file: %w", err)
 	}
diff --git a/ci/test.sh b/ci/test.sh
index 1b3d6cc..95ef710 100755
--- a/ci/test.sh
+++ b/ci/test.sh
@@ -1,14 +1,25 @@
-#!/bin/sh
-set -eu
-cd -- "$(dirname "$0")/.."
+#!/usr/bin/env bash
+set -euo pipefail
 
-go install github.com/agnivade/wasmbrowsertest@latest
-go test --race --timeout=1h --covermode=atomic --coverprofile=ci/out/coverage.prof --coverpkg=./... "$@" ./...
-sed -i.bak '/stringer\.go/d' ci/out/coverage.prof
-sed -i.bak '/nhooyr.io\/websocket\/internal\/test/d' ci/out/coverage.prof
-sed -i.bak '/examples/d' ci/out/coverage.prof
+main() {
+  cd "$(dirname "$0")/.."
 
-# Last line is the total coverage.
-go tool cover -func ci/out/coverage.prof | tail -n1
+  go test -timeout=30m -covermode=atomic -coverprofile=ci/out/coverage.prof -coverpkg=./... "$@" ./...
+  sed -i '/stringer\.go/d' ci/out/coverage.prof
+  sed -i '/nhooyr.io\/websocket\/internal\/test/d' ci/out/coverage.prof
+  sed -i '/examples/d' ci/out/coverage.prof
 
-go tool cover -html=ci/out/coverage.prof -o=ci/out/coverage.html
+  # Last line is the total coverage.
+  go tool cover -func ci/out/coverage.prof | tail -n1
+
+  go tool cover -html=ci/out/coverage.prof -o=ci/out/coverage.html
+
+  if [[ ${CI-} && ${GITHUB_REF-} == *master ]]; then
+    local deployDir
+    deployDir="$(mktemp -d)"
+    cp ci/out/coverage.html "$deployDir/index.html"
+    netlify deploy --prod "--dir=$deployDir"
+  fi
+}
+
+main "$@"
diff --git a/close_test.go b/close_test.go
index 6bf3c25..00a48d9 100644
--- a/close_test.go
+++ b/close_test.go
@@ -1,4 +1,3 @@
-//go:build !js
 // +build !js
 
 package websocket
diff --git a/compress_test.go b/compress_test.go
index 7b0e3a6..2c4c896 100644
--- a/compress_test.go
+++ b/compress_test.go
@@ -1,4 +1,3 @@
-//go:build !js
 // +build !js
 
 package websocket
diff --git a/conn_test.go b/conn_test.go
index 19961d1..d972368 100644
--- a/conn_test.go
+++ b/conn_test.go
@@ -1,4 +1,3 @@
-//go:build !js
 // +build !js
 
 package websocket_test
@@ -8,7 +7,6 @@ import (
 	"context"
 	"fmt"
 	"io"
-	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
 	"os"
@@ -38,7 +36,7 @@ func TestConn(t *testing.T) {
 		t.Parallel()
 
 		compressionMode := func() websocket.CompressionMode {
-			return websocket.CompressionMode(xrand.Int(int(websocket.CompressionContextTakeover) + 1))
+			return websocket.CompressionMode(xrand.Int(int(websocket.CompressionDisabled) + 1))
 		}
 
 		for i := 0; i < 5; i++ {
@@ -50,6 +48,7 @@ func TestConn(t *testing.T) {
 					CompressionMode:      compressionMode(),
 					CompressionThreshold: xrand.Int(9999),
 				})
+				defer tt.cleanup()
 
 				tt.goEchoLoop(c2)
 
@@ -67,9 +66,8 @@ func TestConn(t *testing.T) {
 	})
 
 	t.Run("badClose", func(t *testing.T) {
-		tt, c1, c2 := newConnTest(t, nil, nil)
-
-		c2.CloseRead(tt.ctx)
+		tt, c1, _ := newConnTest(t, nil, nil)
+		defer tt.cleanup()
 
 		err := c1.Close(-1, "")
 		assert.Contains(t, err, "failed to marshal close frame: status code StatusCode(-1) cannot be set")
@@ -77,6 +75,7 @@ func TestConn(t *testing.T) {
 
 	t.Run("ping", func(t *testing.T) {
 		tt, c1, c2 := newConnTest(t, nil, nil)
+		defer tt.cleanup()
 
 		c1.CloseRead(tt.ctx)
 		c2.CloseRead(tt.ctx)
@@ -92,6 +91,7 @@ func TestConn(t *testing.T) {
 
 	t.Run("badPing", func(t *testing.T) {
 		tt, c1, c2 := newConnTest(t, nil, nil)
+		defer tt.cleanup()
 
 		c2.CloseRead(tt.ctx)
 
@@ -104,6 +104,7 @@ func TestConn(t *testing.T) {
 
 	t.Run("concurrentWrite", func(t *testing.T) {
 		tt, c1, c2 := newConnTest(t, nil, nil)
+		defer tt.cleanup()
 
 		tt.goDiscardLoop(c2)
 
@@ -136,6 +137,7 @@ func TestConn(t *testing.T) {
 
 	t.Run("concurrentWriteError", func(t *testing.T) {
 		tt, c1, _ := newConnTest(t, nil, nil)
+		defer tt.cleanup()
 
 		_, err := c1.Writer(tt.ctx, websocket.MessageText)
 		assert.Success(t, err)
@@ -149,6 +151,7 @@ func TestConn(t *testing.T) {
 
 	t.Run("netConn", func(t *testing.T) {
 		tt, c1, c2 := newConnTest(t, nil, nil)
+		defer tt.cleanup()
 
 		n1 := websocket.NetConn(tt.ctx, c1, websocket.MessageBinary)
 		n2 := websocket.NetConn(tt.ctx, c2, websocket.MessageBinary)
@@ -170,7 +173,7 @@ func TestConn(t *testing.T) {
 			return n2.Close()
 		})
 
-		b, err := ioutil.ReadAll(n1)
+		b, err := io.ReadAll(n1)
 		assert.Success(t, err)
 
 		_, err = n1.Read(nil)
@@ -188,47 +191,21 @@ func TestConn(t *testing.T) {
 
 	t.Run("netConn/BadMsg", func(t *testing.T) {
 		tt, c1, c2 := newConnTest(t, nil, nil)
+		defer tt.cleanup()
 
 		n1 := websocket.NetConn(tt.ctx, c1, websocket.MessageBinary)
 		n2 := websocket.NetConn(tt.ctx, c2, websocket.MessageText)
 
-		c2.CloseRead(tt.ctx)
 		errs := xsync.Go(func() error {
 			_, err := n2.Write([]byte("hello"))
-			return err
-		})
-
-		_, err := ioutil.ReadAll(n1)
-		assert.Contains(t, err, `unexpected frame type read (expected MessageBinary): MessageText`)
-
-		select {
-		case err := <-errs:
-			assert.Success(t, err)
-		case <-tt.ctx.Done():
-			t.Fatal(tt.ctx.Err())
-		}
-	})
-
-	t.Run("netConn/readLimit", func(t *testing.T) {
-		tt, c1, c2 := newConnTest(t, nil, nil)
-
-		n1 := websocket.NetConn(tt.ctx, c1, websocket.MessageBinary)
-		n2 := websocket.NetConn(tt.ctx, c2, websocket.MessageBinary)
-
-		s := strings.Repeat("papa", 1<<20)
-		errs := xsync.Go(func() error {
-			_, err := n2.Write([]byte(s))
 			if err != nil {
 				return err
 			}
-			return n2.Close()
+			return nil
 		})
 
-		b, err := ioutil.ReadAll(n1)
-		assert.Success(t, err)
-
-		_, err = n1.Read(nil)
-		assert.Equal(t, "read error", err, io.EOF)
+		_, err := io.ReadAll(n1)
+		assert.Contains(t, err, `unexpected frame type read (expected MessageBinary): MessageText`)
 
 		select {
 		case err := <-errs:
@@ -236,12 +213,11 @@ func TestConn(t *testing.T) {
 		case <-tt.ctx.Done():
 			t.Fatal(tt.ctx.Err())
 		}
-
-		assert.Equal(t, "read msg", s, string(b))
 	})
 
 	t.Run("wsjson", func(t *testing.T) {
 		tt, c1, c2 := newConnTest(t, nil, nil)
+		defer tt.cleanup()
 
 		tt.goEchoLoop(c2)
 
@@ -271,6 +247,7 @@ func TestConn(t *testing.T) {
 
 	t.Run("wspb", func(t *testing.T) {
 		tt, c1, c2 := newConnTest(t, nil, nil)
+		defer tt.cleanup()
 
 		tt.goEchoLoop(c2)
 
@@ -327,6 +304,8 @@ func assertCloseStatus(exp websocket.StatusCode, err error) error {
 type connTest struct {
 	t   testing.TB
 	ctx context.Context
+
+	doneFuncs []func()
 }
 
 func newConnTest(t testing.TB, dialOpts *websocket.DialOptions, acceptOpts *websocket.AcceptOptions) (tt *connTest, c1, c2 *websocket.Conn) {
@@ -337,22 +316,30 @@ func newConnTest(t testing.TB, dialOpts *websocket.DialOptions, acceptOpts *webs
 
 	ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
 	tt = &connTest{t: t, ctx: ctx}
-	t.Cleanup(cancel)
+	tt.appendDone(cancel)
 
 	c1, c2 = wstest.Pipe(dialOpts, acceptOpts)
 	if xrand.Bool() {
 		c1, c2 = c2, c1
 	}
-	t.Cleanup(func() {
-		// We don't actually care whether this succeeds so we just run it in a separate goroutine to avoid
-		// blocking the test shutting down.
-		go c2.Close(websocket.StatusInternalError, "")
-		go c1.Close(websocket.StatusInternalError, "")
+	tt.appendDone(func() {
+		c2.Close(websocket.StatusInternalError, "")
+		c1.Close(websocket.StatusInternalError, "")
 	})
 
 	return tt, c1, c2
 }
 
+func (tt *connTest) appendDone(f func()) {
+	tt.doneFuncs = append(tt.doneFuncs, f)
+}
+
+func (tt *connTest) cleanup() {
+	for i := len(tt.doneFuncs) - 1; i >= 0; i-- {
+		tt.doneFuncs[i]()
+	}
+}
+
 func (tt *connTest) goEchoLoop(c *websocket.Conn) {
 	ctx, cancel := context.WithCancel(tt.ctx)
 
@@ -360,7 +347,7 @@ func (tt *connTest) goEchoLoop(c *websocket.Conn) {
 		err := wstest.EchoLoop(ctx, c)
 		return assertCloseStatus(websocket.StatusNormalClosure, err)
 	})
-	tt.t.Cleanup(func() {
+	tt.appendDone(func() {
 		cancel()
 		err := <-echoLoopErr
 		if err != nil {
@@ -382,7 +369,7 @@ func (tt *connTest) goDiscardLoop(c *websocket.Conn) {
 			}
 		}
 	})
-	tt.t.Cleanup(func() {
+	tt.appendDone(func() {
 		cancel()
 		err := <-discardLoopErr
 		if err != nil {
@@ -401,7 +388,7 @@ func BenchmarkConn(b *testing.B) {
 			mode: websocket.CompressionDisabled,
 		},
 		{
-			name: "compressContextTakeover",
+			name: "compress",
 			mode: websocket.CompressionContextTakeover,
 		},
 		{
@@ -416,6 +403,7 @@ func BenchmarkConn(b *testing.B) {
 			}, &websocket.AcceptOptions{
 				CompressionMode: bc.mode,
 			})
+			defer bb.cleanup()
 
 			bb.goEchoLoop(c2)
 
diff --git a/dial_test.go b/dial_test.go
index e5f8ab3..89ca307 100644
--- a/dial_test.go
+++ b/dial_test.go
@@ -1,4 +1,3 @@
-//go:build !js
 // +build !js
 
 package websocket
@@ -7,7 +6,6 @@ import (
 	"context"
 	"crypto/rand"
 	"io"
-	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
 	"strings"
@@ -76,7 +74,7 @@ func TestBadDials(t *testing.T) {
 		_, _, err := Dial(ctx, "ws://example.com", &DialOptions{
 			HTTPClient: mockHTTPClient(func(*http.Request) (*http.Response, error) {
 				return &http.Response{
-					Body: ioutil.NopCloser(strings.NewReader("hi")),
+					Body: io.NopCloser(strings.NewReader("hi")),
 				}, nil
 			}),
 		})
@@ -98,7 +96,7 @@ func TestBadDials(t *testing.T) {
 			return &http.Response{
 				StatusCode: http.StatusSwitchingProtocols,
 				Header:     h,
-				Body:       ioutil.NopCloser(strings.NewReader("hi")),
+				Body:       io.NopCloser(strings.NewReader("hi")),
 			}, nil
 		}
 
diff --git a/example_test.go b/example_test.go
index 2e55eb9..632c4d6 100644
--- a/example_test.go
+++ b/example_test.go
@@ -135,30 +135,57 @@ func Example_crossOrigin() {
 	log.Fatal(err)
 }
 
-func ExampleConn_Ping() {
-	// Dials a server and pings it 5 times.
-
-	ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
-	defer cancel()
-
-	c, _, err := websocket.Dial(ctx, "ws://localhost:8080", nil)
-	if err != nil {
-		log.Fatal(err)
-	}
-	defer c.Close(websocket.StatusInternalError, "the sky is falling")
-
-	// Required to read the Pongs from the server.
-	ctx = c.CloseRead(ctx)
-
-	for i := 0; i < 5; i++ {
-		err = c.Ping(ctx)
-		if err != nil {
-			log.Fatal(err)
-		}
-	}
-
-	c.Close(websocket.StatusNormalClosure, "")
-}
+// This example demonstrates how to create a WebSocket server
+// that gracefully exits when sent a signal.
+//
+// It starts a WebSocket server that keeps every connection open
+// for 10 seconds.
+// If you CTRL+C while a connection is open, it will wait at most 30s
+// for all connections to terminate before shutting down.
+// func ExampleGrace() {
+// 	fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+// 		c, err := websocket.Accept(w, r, nil)
+// 		if err != nil {
+// 			log.Println(err)
+// 			return
+// 		}
+// 		defer c.Close(websocket.StatusInternalError, "the sky is falling")
+//
+// 		ctx := c.CloseRead(r.Context())
+// 		select {
+// 		case <-ctx.Done():
+// 		case <-time.After(time.Second * 10):
+// 		}
+//
+// 		c.Close(websocket.StatusNormalClosure, "")
+// 	})
+//
+// 	var g websocket.Grace
+// 	s := &http.Server{
+// 		Handler:      g.Handler(fn),
+// 		ReadTimeout:  time.Second * 15,
+// 		WriteTimeout: time.Second * 15,
+// 	}
+//
+// 	errc := make(chan error, 1)
+// 	go func() {
+// 		errc <- s.ListenAndServe()
+// 	}()
+//
+// 	sigs := make(chan os.Signal, 1)
+// 	signal.Notify(sigs, os.Interrupt)
+// 	select {
+// 	case err := <-errc:
+// 		log.Printf("failed to listen and serve: %v", err)
+// 	case sig := <-sigs:
+// 		log.Printf("terminating: %v", sig)
+// 	}
+//
+// 	ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
+// 	defer cancel()
+// 	s.Shutdown(ctx)
+// 	g.Shutdown(ctx)
+// }
 
 // This example demonstrates full stack chat with an automated test.
 func Example_fullStackChat() {
diff --git a/export_test.go b/export_test.go
index d618a15..88b82c9 100644
--- a/export_test.go
+++ b/export_test.go
@@ -1,4 +1,3 @@
-//go:build !js
 // +build !js
 
 package websocket
diff --git a/frame_test.go b/frame_test.go
index 93ad8b5..7682624 100644
--- a/frame_test.go
+++ b/frame_test.go
@@ -1,4 +1,3 @@
-//go:build !js
 // +build !js
 
 package websocket
diff --git a/internal/test/wstest/echo.go b/internal/test/wstest/echo.go
index 0938a13..8f4e47c 100644
--- a/internal/test/wstest/echo.go
+++ b/internal/test/wstest/echo.go
@@ -21,7 +21,7 @@ func EchoLoop(ctx context.Context, c *websocket.Conn) error {
 
 	c.SetReadLimit(1 << 30)
 
-	ctx, cancel := context.WithTimeout(ctx, time.Minute*5)
+	ctx, cancel := context.WithTimeout(ctx, time.Minute)
 	defer cancel()
 
 	b := make([]byte, 32<<10)
diff --git a/internal/test/wstest/pipe.go b/internal/test/wstest/pipe.go
index 8e1deb4..1534f31 100644
--- a/internal/test/wstest/pipe.go
+++ b/internal/test/wstest/pipe.go
@@ -1,4 +1,3 @@
-//go:build !js
 // +build !js
 
 package wstest
@@ -25,8 +24,7 @@ func Pipe(dialOpts *websocket.DialOptions, acceptOpts *websocket.AcceptOptions)
 	if dialOpts == nil {
 		dialOpts = &websocket.DialOptions{}
 	}
-	_dialOpts := *dialOpts
-	dialOpts = &_dialOpts
+	dialOpts = &*dialOpts
 	dialOpts.HTTPClient = &http.Client{
 		Transport: tt,
 	}
diff --git a/ws_js_test.go b/ws_js_test.go
index ba98b9a..e6be618 100644
--- a/ws_js_test.go
+++ b/ws_js_test.go
@@ -36,19 +36,3 @@ func TestWasm(t *testing.T) {
 	err = c.Close(websocket.StatusNormalClosure, "")
 	assert.Success(t, err)
 }
-
-func TestWasmDialTimeout(t *testing.T) {
-	t.Parallel()
-
-	ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
-	defer cancel()
-
-	beforeDial := time.Now()
-	_, _, err := websocket.Dial(ctx, "ws://example.com:9893", &websocket.DialOptions{
-		Subprotocols: []string{"echo"},
-	})
-	assert.Error(t, err)
-	if time.Since(beforeDial) >= time.Second {
-		t.Fatal("wasm context dial timeout is not working", time.Since(beforeDial))
-	}
-}