good morning!!!!

Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • github/nhooyr/websocket
  • open/websocket
2 results
Show changes
Commits on Source (343)
* @nhooyr
github: nhooyr
<!--
Please be as descriptive as possible.
-->
version: 2
updates:
# Track in case we ever add dependencies.
- package-ecosystem: 'gomod'
directory: '/'
schedule:
interval: 'weekly'
commit-message:
prefix: 'chore'
# Keep example and test/benchmark deps up-to-date.
- package-ecosystem: 'gomod'
directories:
- '/internal/examples'
- '/internal/thirdparty'
schedule:
interval: 'monthly'
commit-message:
prefix: 'chore'
labels: []
groups:
internal-deps:
patterns:
- '*'
name: ci
on: [push, pull_request]
on:
push:
branches:
- master
pull_request:
branches:
- master
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@v1
- uses: actions/cache@v1
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: make fmt
uses: ./ci/image
with:
args: make fmt
go-version-file: ./go.mod
- run: make fmt
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/cache@v1
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: make lint
uses: ./ci/image
- uses: actions/checkout@v4
- run: go version
- uses: actions/setup-go@v5
with:
args: make lint
go-version-file: ./go.mod
- run: make lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/cache@v1
- name: Disable AppArmor
if: runner.os == 'Linux'
run: |
# Disable AppArmor for Ubuntu 23.10+.
# https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md
echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: make test
uses: ./ci/image
go-version-file: ./go.mod
- run: make test
- uses: actions/upload-artifact@v4
with:
args: make test
env:
COVERALLS_TOKEN: ${{ secrets.github_token }}
- name: Upload coverage.html
uses: actions/upload-artifact@master
name: coverage.html
path: ./ci/out/coverage.html
bench:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
name: coverage
path: ci/out/coverage.html
go-version-file: ./go.mod
- run: make bench
name: daily
on:
workflow_dispatch:
schedule:
- cron: '42 0 * * *' # daily at 00:42
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
bench:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: ./go.mod
- run: AUTOBAHN=1 make bench
test:
runs-on: ubuntu-latest
steps:
- name: Disable AppArmor
if: runner.os == 'Linux'
run: |
# Disable AppArmor for Ubuntu 23.10+.
# https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md
echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: ./go.mod
- run: AUTOBAHN=1 make test
- uses: actions/upload-artifact@v4
with:
name: coverage.html
path: ./ci/out/coverage.html
bench-dev:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: dev
- uses: actions/setup-go@v5
with:
go-version-file: ./go.mod
- run: AUTOBAHN=1 make bench
test-dev:
runs-on: ubuntu-latest
steps:
- name: Disable AppArmor
if: runner.os == 'Linux'
run: |
# Disable AppArmor for Ubuntu 23.10+.
# https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md
echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns
- uses: actions/checkout@v4
with:
ref: dev
- uses: actions/setup-go@v5
with:
go-version-file: ./go.mod
- run: AUTOBAHN=1 make test
- uses: actions/upload-artifact@v4
with:
name: coverage-dev.html
path: ./ci/out/coverage.html
name: static
on:
push:
branches: ['master']
workflow_dispatch:
# Set permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages.
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Disable AppArmor
if: runner.os == 'Linux'
run: |
# Disable AppArmor for Ubuntu 23.10+.
# https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md
echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns
- name: Checkout
uses: actions/checkout@v4
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: ./go.mod
- name: Generate coverage and badge
run: |
make test
mkdir -p ./ci/out/static
cp ./ci/out/coverage.html ./ci/out/static/coverage.html
percent=$(go tool cover -func ./ci/out/coverage.prof | tail -n1 | awk '{print $3}' | tr -d '%')
wget -O ./ci/out/static/coverage.svg "https://img.shields.io/badge/coverage-${percent}%25-success"
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./ci/out/static/
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
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.
Copyright (c) 2025 Coder
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.
.PHONY: all
all: fmt lint test
.SILENT:
.PHONY: fmt
fmt:
./ci/fmt.sh
.PHONY: *
.PHONY: lint
lint:
./ci/lint.sh
.ONESHELL:
SHELL = bash
.SHELLFLAGS = -ceuo pipefail
.PHONY: test
test:
./ci/test.sh
include ci/fmt.mk
include ci/lint.mk
include ci/test.mk
.PHONY: bench
bench:
./ci/bench.sh
\ No newline at end of file
# websocket
[![version](https://img.shields.io/github/v/release/nhooyr/websocket?color=6b9ded&sort=semver)](https://github.com/nhooyr/websocket/releases)
[![docs](https://godoc.org/nhooyr.io/websocket?status.svg)](https://godoc.org/nhooyr.io/websocket)
[![coverage](https://img.shields.io/coveralls/github/nhooyr/websocket?color=65d6a4)](https://coveralls.io/github/nhooyr/websocket)
[![ci](https://github.com/nhooyr/websocket/workflows/ci/badge.svg)](https://github.com/nhooyr/websocket/actions)
[![Go Reference](https://pkg.go.dev/badge/github.com/coder/websocket.svg)](https://pkg.go.dev/github.com/coder/websocket)
[![Go Coverage](https://coder.github.io/websocket/coverage.svg)](https://coder.github.io/websocket/coverage.html)
websocket is a minimal and idiomatic WebSocket library for Go.
## Install
```bash
go get nhooyr.io/websocket
```sh
go get github.com/coder/websocket
```
## Features
> [!NOTE]
> Coder now maintains this project as explained in [this blog post](https://coder.com/blog/websocket).
> We're grateful to [nhooyr](https://github.com/nhooyr) for authoring and maintaining this project from
> 2019 to 2024.
## Highlights
- Minimal and idiomatic API
- Tiny codebase at 2200 lines
- First class [context.Context](https://blog.golang.org/context) support
- Thorough tests, fully passes the [autobahn-testsuite](https://github.com/crossbario/autobahn-testsuite)
- [Zero dependencies](https://godoc.org/nhooyr.io/websocket?imports)
- JSON and ProtoBuf helpers in the [wsjson](https://godoc.org/nhooyr.io/websocket/wsjson) and [wspb](https://godoc.org/nhooyr.io/websocket/wspb) subpackages
- Highly optimized by default
- Zero alloc reads and writes
- Concurrent writes out of the box
- [Complete Wasm](https://godoc.org/nhooyr.io/websocket#hdr-Wasm) support
- [Close handshake](https://godoc.org/nhooyr.io/websocket#Conn.Close)
- Full support of [RFC 7692](https://tools.ietf.org/html/rfc7692) permessage-deflate compression extension
- Fully passes the WebSocket [autobahn-testsuite](https://github.com/crossbario/autobahn-testsuite)
- [Zero dependencies](https://pkg.go.dev/github.com/coder/websocket?tab=imports)
- JSON helpers in the [wsjson](https://pkg.go.dev/github.com/coder/websocket/wsjson) subpackage
- Zero alloc reads and writes
- Concurrent writes
- [Close handshake](https://pkg.go.dev/github.com/coder/websocket#Conn.Close)
- [net.Conn](https://pkg.go.dev/github.com/coder/websocket#NetConn) wrapper
- [Ping pong](https://pkg.go.dev/github.com/coder/websocket#Conn.Ping) API
- [RFC 7692](https://tools.ietf.org/html/rfc7692) permessage-deflate compression
- [CloseRead](https://pkg.go.dev/github.com/coder/websocket#Conn.CloseRead) helper for write only connections
- Compile to [Wasm](https://pkg.go.dev/github.com/coder/websocket#hdr-Wasm)
## Roadmap
See GitHub issues for minor issues but the major future enhancements are:
- [ ] Perfect examples [#217](https://github.com/nhooyr/websocket/issues/217)
- [ ] wstest.Pipe for in memory testing [#340](https://github.com/nhooyr/websocket/issues/340)
- [ ] Ping pong heartbeat helper [#267](https://github.com/nhooyr/websocket/issues/267)
- [ ] Ping pong instrumentation callbacks [#246](https://github.com/nhooyr/websocket/issues/246)
- [ ] Graceful shutdown helpers [#209](https://github.com/nhooyr/websocket/issues/209)
- [ ] Assembly for WebSocket masking [#16](https://github.com/nhooyr/websocket/issues/16)
- WIP at [#326](https://github.com/nhooyr/websocket/pull/326), about 3x faster
- [ ] HTTP/2 [#4](https://github.com/nhooyr/websocket/issues/4)
- [ ] The holy grail [#402](https://github.com/nhooyr/websocket/issues/402)
## Examples
For a production quality example that shows off the full API, see the [echo example on the godoc](https://godoc.org/nhooyr.io/websocket#example-package--Echo). On github, the example is at [example_echo_test.go](./example_echo_test.go).
For a production quality example that demonstrates the complete API, see the
[echo example](./internal/examples/echo).
Use the [errors.As](https://golang.org/pkg/errors/#As) function [new in Go 1.13](https://golang.org/doc/go1.13#error_wrapping) to check for [websocket.CloseError](https://godoc.org/nhooyr.io/websocket#CloseError).
There is also [websocket.CloseStatus](https://godoc.org/nhooyr.io/websocket#CloseStatus) to quickly grab the close status code out of a [websocket.CloseError](https://godoc.org/nhooyr.io/websocket#CloseError).
See the [CloseStatus godoc example](https://godoc.org/nhooyr.io/websocket#example-CloseStatus).
For a full stack example, see the [chat example](./internal/examples/chat).
### Server
......@@ -48,9 +61,11 @@ http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) {
if err != nil {
// ...
}
defer c.Close(websocket.StatusInternalError, "the sky is falling")
defer c.CloseNow()
ctx, cancel := context.WithTimeout(r.Context(), time.Second*10)
// Set the context as needed. Use of r.Context() is not recommended
// to avoid surprising behavior (see http.Hijacker).
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
var v interface{}
......@@ -75,7 +90,7 @@ c, _, err := websocket.Dial(ctx, "ws://localhost:8080", nil)
if err != nil {
// ...
}
defer c.Close(websocket.StatusInternalError, "the sky is falling")
defer c.CloseNow()
err = wsjson.Write(ctx, c, "hi")
if err != nil {
......@@ -87,87 +102,61 @@ c.Close(websocket.StatusNormalClosure, "")
## Comparison
Before the comparison, I want to point out that gorilla/websocket was extremely useful in implementing the
WebSocket protocol correctly so _big thanks_ to its authors. In particular, I made sure to go through the
issue tracker of gorilla/websocket to ensure I implemented details correctly and understood how people were
using WebSockets in production.
### gorilla/websocket
https://github.com/gorilla/websocket
The implementation of gorilla/websocket is 6 years old. As such, it is
widely used and very mature compared to nhooyr.io/websocket.
On the other hand, it has grown organically and now there are too many ways to do
the same thing. Compare the godoc of
[nhooyr/websocket](https://godoc.org/nhooyr.io/websocket) with
[gorilla/websocket](https://godoc.org/github.com/gorilla/websocket) side by side.
The API for nhooyr.io/websocket has been designed such that there is only one way to do things.
This makes it easy to use correctly. Not only is the API simpler, the implementation is
only 2200 lines whereas gorilla/websocket is at 3500 lines. That's more code to maintain,
more code to test, more code to document and more surface area for bugs.
Moreover, nhooyr.io/websocket supports newer Go idioms such as context.Context.
It also uses net/http's Client and ResponseWriter directly for WebSocket handshakes.
gorilla/websocket writes its handshakes to the underlying net.Conn.
Thus it has to reinvent hooks for TLS and proxies and prevents easy support of HTTP/2.
Advantages of [gorilla/websocket](https://github.com/gorilla/websocket):
Some more advantages of nhooyr.io/websocket are that it supports concurrent writes and
makes it very easy to close the connection with a status code and reason. In fact,
nhooyr.io/websocket even implements the complete WebSocket close handshake for you whereas
with gorilla/websocket you have to perform it manually. See [gorilla/websocket#448](https://github.com/gorilla/websocket/issues/448).
- Mature and widely used
- [Prepared writes](https://pkg.go.dev/github.com/gorilla/websocket#PreparedMessage)
- Configurable [buffer sizes](https://pkg.go.dev/github.com/gorilla/websocket#hdr-Buffers)
- No extra goroutine per connection to support cancellation with context.Context. This costs github.com/coder/websocket 2 KB of memory per connection.
- Will be removed soon with [context.AfterFunc](https://github.com/golang/go/issues/57928). See [#411](https://github.com/nhooyr/websocket/issues/411)
The ping API is also nicer. gorilla/websocket requires registering a pong handler on the Conn
which results in awkward control flow. With nhooyr.io/websocket you use the Ping method on the Conn
that sends a ping and also waits for the pong.
Advantages of github.com/coder/websocket:
Additionally, nhooyr.io/websocket can compile to [Wasm](https://godoc.org/nhooyr.io/websocket#hdr-Wasm) for the browser.
In terms of performance, the differences mostly depend on your application code. nhooyr.io/websocket
reuses message buffers out of the box if you use the wsjson and wspb subpackages.
As mentioned above, nhooyr.io/websocket also supports concurrent writers.
The WebSocket masking algorithm used by this package is [1.75x](https://github.com/nhooyr/websocket/releases/tag/v1.7.4)
faster than gorilla/websocket while using only pure safe Go.
The [permessage-deflate compression extension](https://tools.ietf.org/html/rfc7692) is fully supported by this library
whereas gorilla only supports no context takeover mode. See our godoc for the differences. This will make a big
difference on bandwidth used in most use cases.
The only performance con to nhooyr.io/websocket is that it uses a goroutine to support
cancellation with context.Context. This costs 2 KB of memory which is cheap compared to
the benefits.
### x/net/websocket
https://godoc.org/golang.org/x/net/websocket
- Minimal and idiomatic API
- Compare godoc of [github.com/coder/websocket](https://pkg.go.dev/github.com/coder/websocket) with [gorilla/websocket](https://pkg.go.dev/github.com/gorilla/websocket) side by side.
- [net.Conn](https://pkg.go.dev/github.com/coder/websocket#NetConn) wrapper
- Zero alloc reads and writes ([gorilla/websocket#535](https://github.com/gorilla/websocket/issues/535))
- Full [context.Context](https://blog.golang.org/context) support
- Dial uses [net/http.Client](https://golang.org/pkg/net/http/#Client)
- Will enable easy HTTP/2 support in the future
- Gorilla writes directly to a net.Conn and so duplicates features of net/http.Client.
- Concurrent writes
- Close handshake ([gorilla/websocket#448](https://github.com/gorilla/websocket/issues/448))
- Idiomatic [ping pong](https://pkg.go.dev/github.com/coder/websocket#Conn.Ping) API
- Gorilla requires registering a pong callback before sending a Ping
- Can target Wasm ([gorilla/websocket#432](https://github.com/gorilla/websocket/issues/432))
- Transparent message buffer reuse with [wsjson](https://pkg.go.dev/github.com/coder/websocket/wsjson) subpackage
- [1.75x](https://github.com/nhooyr/websocket/releases/tag/v1.7.4) faster WebSocket masking implementation in pure Go
- Gorilla's implementation is slower and uses [unsafe](https://golang.org/pkg/unsafe/).
Soon we'll have assembly and be 3x faster [#326](https://github.com/nhooyr/websocket/pull/326)
- Full [permessage-deflate](https://tools.ietf.org/html/rfc7692) compression extension support
- Gorilla only supports no context takeover mode
- [CloseRead](https://pkg.go.dev/github.com/coder/websocket#Conn.CloseRead) helper for write only connections ([gorilla/websocket#492](https://github.com/gorilla/websocket/issues/492))
Unmaintained and the API does not reflect WebSocket semantics. Should never be used.
#### golang.org/x/net/websocket
See https://github.com/golang/go/issues/18152
[golang.org/x/net/websocket](https://pkg.go.dev/golang.org/x/net/websocket) is deprecated.
See [golang/go/issues/18152](https://github.com/golang/go/issues/18152).
### gobwas/ws
The [net.Conn](https://pkg.go.dev/github.com/coder/websocket#NetConn) can help in transitioning
to github.com/coder/websocket.
https://github.com/gobwas/ws
#### gobwas/ws
This library has an extremely flexible API but that comes at the cost of usability
and clarity.
[gobwas/ws](https://github.com/gobwas/ws) has an extremely flexible API that allows it to be used
in an event driven style for performance. See the author's [blog post](https://medium.freecodecamp.org/million-websockets-and-go-cc58418460bb).
Due to its flexibility, it can be used in a event driven style for performance.
Definitely check out his fantastic [blog post](https://medium.freecodecamp.org/million-websockets-and-go-cc58418460bb) about performant WebSocket servers.
However it is quite bloated. See https://pkg.go.dev/github.com/gobwas/ws
If you want a library that gives you absolute control over everything, this is the library.
But for 99.9% of use cases, nhooyr.io/websocket will fit better as it is both easier and
faster for normal idiomatic Go. The masking implementation is [1.75x](https://github.com/nhooyr/websocket/releases/tag/v1.7.4)
faster, the compression extensions are fully supported and as much as possible is reused by default.
When writing idiomatic Go, github.com/coder/websocket will be faster and easier to use.
See the gorilla/websocket comparison for more performance details.
#### lesismal/nbio
## Users
[lesismal/nbio](https://github.com/lesismal/nbio) is similar to gobwas/ws in that the API is
event driven for performance reasons.
If your company or project is using this library, feel free to open an issue or PR to amend this list.
However it is quite bloated. See https://pkg.go.dev/github.com/lesismal/nbio
- [Coder](https://github.com/cdr)
- [Tatsu Works](https://github.com/tatsuworks) - Ingresses 20 TB in WebSocket data every month on their Discord bot.
When writing idiomatic Go, github.com/coder/websocket will be faster and easier to use.
//go:build !js
// +build !js
package websocket
import (
"bytes"
"context"
"crypto/sha1"
"encoding/base64"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/textproto"
"net/url"
"path"
"strings"
"nhooyr.io/websocket/internal/errd"
"github.com/coder/websocket/internal/errd"
)
// AcceptOptions represents Accept's options.
......@@ -22,31 +28,77 @@ type AcceptOptions struct {
// reject it, close the connection when c.Subprotocol() == "".
Subprotocols []string
// InsecureSkipVerify disables Accept's origin verification behaviour. By default,
// the connection will only be accepted if the request origin is equal to the request
// host.
// InsecureSkipVerify is used to disable Accept's origin verification behaviour.
//
// You probably want to use OriginPatterns instead.
InsecureSkipVerify bool
// OriginPatterns lists the host patterns for authorized origins.
// The request host is always authorized.
// Use this to enable cross origin WebSockets.
//
// This is only required if you want javascript served from a different domain
// to access your WebSocket server.
// i.e javascript running on example.com wants to access a WebSocket server at chat.example.com.
// In such a case, example.com is the origin and chat.example.com is the request host.
// One would set this field to []string{"example.com"} to authorize example.com to connect.
//
// See https://stackoverflow.com/a/37837709/4283659
// Each pattern is matched case insensitively against the request origin host
// with path.Match.
// See https://golang.org/pkg/path/#Match
//
// Please ensure you understand the ramifications of enabling this.
// If used incorrectly your WebSocket server will be open to CSRF attacks.
InsecureSkipVerify bool
//
// Do not use * as a pattern to allow any origin, prefer to use InsecureSkipVerify instead
// to bring attention to the danger of such a setting.
OriginPatterns []string
// CompressionMode sets the compression mode.
// See the docs on CompressionMode.
// CompressionMode controls the compression mode.
// Defaults to CompressionDisabled.
//
// See docs on CompressionMode for details.
CompressionMode CompressionMode
// CompressionThreshold controls the minimum size of a message before compression is applied.
//
// Defaults to 512 bytes for CompressionNoContextTakeover and 128 bytes
// for CompressionContextTakeover.
CompressionThreshold int
// OnPingReceived is an optional callback invoked synchronously when a ping frame is received.
//
// The payload contains the application data of the ping frame.
// If the callback returns false, the subsequent pong frame will not be sent.
// To avoid blocking, any expensive processing should be performed asynchronously using a goroutine.
OnPingReceived func(ctx context.Context, payload []byte) bool
// OnPongReceived is an optional callback invoked synchronously when a pong frame is received.
//
// The payload contains the application data of the pong frame.
// To avoid blocking, any expensive processing should be performed asynchronously using a goroutine.
//
// Unlike OnPingReceived, this callback does not return a value because a pong frame
// is a response to a ping and does not trigger any further frame transmission.
OnPongReceived func(ctx context.Context, payload []byte)
}
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.
//
// Accept will not allow cross origin requests by default.
// See the InsecureSkipVerify option to allow cross origin requests.
// See the InsecureSkipVerify and OriginPatterns options to allow cross origin requests.
//
// Accept will write a response to w on all errors.
//
// Note that using the http.Request Context after Accept returns may lead to
// unexpected behavior (see http.Hijacker).
func Accept(w http.ResponseWriter, r *http.Request, opts *AcceptOptions) (*Conn, error) {
return accept(w, r, opts)
}
......@@ -54,25 +106,26 @@ 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{}
}
err = verifyClientRequest(r)
errCode, err := verifyClientRequest(w, r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
http.Error(w, err.Error(), errCode)
return nil, err
}
opts = opts.cloneWithDefaults()
if !opts.InsecureSkipVerify {
err = authenticateOrigin(r)
err = authenticateOrigin(r, opts.OriginPatterns)
if err != nil {
if errors.Is(err, path.ErrBadPattern) {
log.Printf("websocket: %v", err)
err = errors.New(http.StatusText(http.StatusForbidden))
}
http.Error(w, err.Error(), http.StatusForbidden)
return nil, err
}
}
hj, ok := w.(http.Hijacker)
hj, ok := hijacker(w)
if !ok {
err = errors.New("http.ResponseWriter does not implement http.Hijacker")
http.Error(w, http.StatusText(http.StatusNotImplemented), http.StatusNotImplemented)
......@@ -90,12 +143,18 @@ func accept(w http.ResponseWriter, r *http.Request, opts *AcceptOptions) (_ *Con
w.Header().Set("Sec-WebSocket-Protocol", subproto)
}
copts, err := acceptCompression(r, w, opts.CompressionMode)
if err != nil {
return nil, err
copts, ok := selectDeflate(websocketExtensions(r.Header), opts.CompressionMode)
if ok {
w.Header().Set("Sec-WebSocket-Extensions", copts.String())
}
w.WriteHeader(http.StatusSwitchingProtocols)
// See https://github.com/nhooyr/websocket/issues/166
if ginWriter, ok := w.(interface {
WriteHeaderNow()
}); ok {
ginWriter.WriteHeaderNow()
}
netConn, brw, err := hj.Hijack()
if err != nil {
......@@ -109,55 +168,95 @@ func accept(w http.ResponseWriter, r *http.Request, opts *AcceptOptions) (_ *Con
brw.Reader.Reset(io.MultiReader(bytes.NewReader(b), netConn))
return newConn(connConfig{
subprotocol: w.Header().Get("Sec-WebSocket-Protocol"),
rwc: netConn,
client: false,
copts: copts,
br: brw.Reader,
bw: brw.Writer,
subprotocol: w.Header().Get("Sec-WebSocket-Protocol"),
rwc: netConn,
client: false,
copts: copts,
flateThreshold: opts.CompressionThreshold,
onPingReceived: opts.OnPingReceived,
onPongReceived: opts.OnPongReceived,
br: brw.Reader,
bw: brw.Writer,
}), nil
}
func verifyClientRequest(r *http.Request) error {
func verifyClientRequest(w http.ResponseWriter, r *http.Request) (errCode int, _ error) {
if !r.ProtoAtLeast(1, 1) {
return fmt.Errorf("WebSocket protocol violation: handshake request must be at least HTTP/1.1: %q", r.Proto)
return http.StatusUpgradeRequired, fmt.Errorf("WebSocket protocol violation: handshake request must be at least HTTP/1.1: %q", r.Proto)
}
if !headerContainsToken(r.Header, "Connection", "Upgrade") {
return fmt.Errorf("WebSocket protocol violation: Connection header %q does not contain Upgrade", r.Header.Get("Connection"))
if !headerContainsTokenIgnoreCase(r.Header, "Connection", "Upgrade") {
w.Header().Set("Connection", "Upgrade")
w.Header().Set("Upgrade", "websocket")
return http.StatusUpgradeRequired, fmt.Errorf("WebSocket protocol violation: Connection header %q does not contain Upgrade", r.Header.Get("Connection"))
}
if !headerContainsToken(r.Header, "Upgrade", "websocket") {
return fmt.Errorf("WebSocket protocol violation: Upgrade header %q does not contain websocket", r.Header.Get("Upgrade"))
if !headerContainsTokenIgnoreCase(r.Header, "Upgrade", "websocket") {
w.Header().Set("Connection", "Upgrade")
w.Header().Set("Upgrade", "websocket")
return http.StatusUpgradeRequired, fmt.Errorf("WebSocket protocol violation: Upgrade header %q does not contain websocket", r.Header.Get("Upgrade"))
}
if r.Method != "GET" {
return fmt.Errorf("WebSocket protocol violation: handshake request method is not GET but %q", r.Method)
return http.StatusMethodNotAllowed, fmt.Errorf("WebSocket protocol violation: handshake request method is not GET but %q", r.Method)
}
if r.Header.Get("Sec-WebSocket-Version") != "13" {
return fmt.Errorf("unsupported WebSocket protocol version (only 13 is supported): %q", r.Header.Get("Sec-WebSocket-Version"))
w.Header().Set("Sec-WebSocket-Version", "13")
return http.StatusBadRequest, fmt.Errorf("unsupported WebSocket protocol version (only 13 is supported): %q", r.Header.Get("Sec-WebSocket-Version"))
}
websocketSecKeys := r.Header.Values("Sec-WebSocket-Key")
if len(websocketSecKeys) == 0 {
return http.StatusBadRequest, errors.New("WebSocket protocol violation: missing Sec-WebSocket-Key")
}
if r.Header.Get("Sec-WebSocket-Key") == "" {
return errors.New("WebSocket protocol violation: missing Sec-WebSocket-Key")
if len(websocketSecKeys) > 1 {
return http.StatusBadRequest, errors.New("WebSocket protocol violation: multiple Sec-WebSocket-Key headers")
}
return nil
// The RFC states to remove any leading or trailing whitespace.
websocketSecKey := strings.TrimSpace(websocketSecKeys[0])
if v, err := base64.StdEncoding.DecodeString(websocketSecKey); err != nil || len(v) != 16 {
return http.StatusBadRequest, fmt.Errorf("WebSocket protocol violation: invalid Sec-WebSocket-Key %q, must be a 16 byte base64 encoded string", websocketSecKey)
}
return 0, nil
}
func authenticateOrigin(r *http.Request) error {
func authenticateOrigin(r *http.Request, originHosts []string) error {
origin := r.Header.Get("Origin")
if origin != "" {
u, err := url.Parse(origin)
if origin == "" {
return nil
}
u, err := url.Parse(origin)
if err != nil {
return fmt.Errorf("failed to parse Origin header %q: %w", origin, err)
}
if strings.EqualFold(r.Host, u.Host) {
return nil
}
for _, hostPattern := range originHosts {
matched, err := match(hostPattern, u.Host)
if err != nil {
return fmt.Errorf("failed to parse Origin header %q: %w", origin, err)
return fmt.Errorf("failed to parse path pattern %q: %w", hostPattern, err)
}
if !strings.EqualFold(u.Host, r.Host) {
return fmt.Errorf("request Origin %q is not authorized for Host %q", origin, r.Host)
if matched {
return nil
}
}
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)
}
func match(pattern, s string) (bool, error) {
return path.Match(strings.ToLower(pattern), strings.ToLower(s))
}
func selectSubprotocol(r *http.Request, subprotocols []string) string {
......@@ -172,25 +271,26 @@ func selectSubprotocol(r *http.Request, subprotocols []string) string {
return ""
}
func acceptCompression(r *http.Request, w http.ResponseWriter, mode CompressionMode) (*compressionOptions, error) {
func selectDeflate(extensions []websocketExtension, mode CompressionMode) (*compressionOptions, bool) {
if mode == CompressionDisabled {
return nil, nil
return nil, false
}
for _, ext := range websocketExtensions(r.Header) {
for _, ext := range extensions {
switch ext.name {
// We used to implement x-webkit-deflate-frame too for Safari but Safari has bugs...
// See https://github.com/nhooyr/websocket/issues/218
case "permessage-deflate":
return acceptDeflate(w, ext, mode)
case "x-webkit-deflate-frame":
return acceptWebkitDeflate(w, ext, mode)
copts, ok := acceptDeflate(ext, mode)
if ok {
return copts, true
}
}
}
return nil, nil
return nil, false
}
func acceptDeflate(w http.ResponseWriter, ext websocketExtension, mode CompressionMode) (*compressionOptions, error) {
func acceptDeflate(ext websocketExtension, mode CompressionMode) (*compressionOptions, bool) {
copts := mode.opts()
for _, p := range ext.params {
switch p {
case "client_no_context_takeover":
......@@ -199,59 +299,23 @@ func acceptDeflate(w http.ResponseWriter, ext websocketExtension, mode Compressi
case "server_no_context_takeover":
copts.serverNoContextTakeover = true
continue
case "client_max_window_bits", "server-max-window-bits":
case "client_max_window_bits",
"server_max_window_bits=15":
continue
}
err := fmt.Errorf("unsupported permessage-deflate parameter: %q", p)
http.Error(w, err.Error(), http.StatusBadRequest)
return nil, err
}
copts.setHeader(w.Header())
return copts, nil
}
func acceptWebkitDeflate(w http.ResponseWriter, ext websocketExtension, mode CompressionMode) (*compressionOptions, error) {
copts := mode.opts()
// The peer must explicitly request it.
copts.serverNoContextTakeover = false
for _, p := range ext.params {
if p == "no_context_takeover" {
copts.serverNoContextTakeover = true
if strings.HasPrefix(p, "client_max_window_bits=") {
// We can't adjust the deflate window, but decoding with a larger window is acceptable.
continue
}
// We explicitly fail on x-webkit-deflate-frame's max_window_bits parameter instead
// of ignoring it as the draft spec is unclear. It says the server can ignore it
// but the server has no way of signalling to the client it was ignored as the parameters
// are set one way.
// Thus us ignoring it would make the client think we understood it which would cause issues.
// See https://tools.ietf.org/html/draft-tyoshino-hybi-websocket-perframe-deflate-06#section-4.1
//
// Either way, we're only implementing this for webkit which never sends the max_window_bits
// parameter so we don't need to worry about it.
err := fmt.Errorf("unsupported x-webkit-deflate-frame parameter: %q", p)
http.Error(w, err.Error(), http.StatusBadRequest)
return nil, err
return nil, false
}
s := "x-webkit-deflate-frame"
if copts.clientNoContextTakeover {
s += "; no_context_takeover"
}
w.Header().Set("Sec-WebSocket-Extensions", s)
return copts, nil
return copts, true
}
func headerContainsToken(h http.Header, key, token string) bool {
token = strings.ToLower(token)
func headerContainsTokenIgnoreCase(h http.Header, key, token string) bool {
for _, t := range headerTokens(h, key) {
if t == token {
if strings.EqualFold(t, token) {
return true
}
}
......@@ -292,7 +356,7 @@ func headerTokens(h http.Header, key string) []string {
for _, v := range h[key] {
v = strings.TrimSpace(v)
for _, t := range strings.Split(v, ",") {
t = strings.ToLower(t)
t = strings.TrimSpace(t)
tokens = append(tokens, t)
}
}
......
//go:build !js
// +build !js
package websocket
import (
"bufio"
"errors"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"nhooyr.io/websocket/internal/assert"
"github.com/coder/websocket/internal/test/assert"
"github.com/coder/websocket/internal/test/xrand"
)
func TestAccept(t *testing.T) {
......@@ -18,7 +27,87 @@ func TestAccept(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
_, err := Accept(w, r, nil)
assert.ErrorContains(t, err, "protocol violation")
assert.Contains(t, err, "protocol violation")
})
t.Run("badOrigin", 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", xrand.Base64(16))
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", xrand.Base64(16))
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"`)
})
t.Run("badCompression", func(t *testing.T) {
t.Parallel()
newRequest := func(extensions string) *http.Request {
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", xrand.Base64(16))
r.Header.Set("Sec-WebSocket-Extensions", extensions)
return r
}
errHijack := errors.New("hijack error")
newResponseWriter := func() http.ResponseWriter {
return mockHijacker{
ResponseWriter: httptest.NewRecorder(),
hijack: func() (net.Conn, *bufio.ReadWriter, error) {
return nil, nil, errHijack
},
}
}
t.Run("withoutFallback", func(t *testing.T) {
t.Parallel()
w := newResponseWriter()
r := newRequest("permessage-deflate; harharhar")
_, err := Accept(w, r, &AcceptOptions{
CompressionMode: CompressionNoContextTakeover,
})
assert.ErrorIs(t, errHijack, err)
assert.Equal(t, "extension header", w.Header().Get("Sec-WebSocket-Extensions"), "")
})
t.Run("withFallback", func(t *testing.T) {
t.Parallel()
w := newResponseWriter()
r := newRequest("permessage-deflate; harharhar, permessage-deflate")
_, err := Accept(w, r, &AcceptOptions{
CompressionMode: CompressionNoContextTakeover,
})
assert.ErrorIs(t, errHijack, err)
assert.Equal(t, "extension header",
w.Header().Get("Sec-WebSocket-Extensions"),
CompressionNoContextTakeover.opts().String(),
)
})
})
t.Run("requireHttpHijacker", func(t *testing.T) {
......@@ -29,10 +118,93 @@ func TestAccept(t *testing.T) {
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("Sec-WebSocket-Key", xrand.Base64(16))
_, err := Accept(w, r, nil)
assert.ErrorContains(t, err, "http.ResponseWriter does not implement http.Hijacker")
assert.Contains(t, err, `http.ResponseWriter does not implement http.Hijacker`)
})
t.Run("badHijack", func(t *testing.T) {
t.Parallel()
w := mockHijacker{
ResponseWriter: httptest.NewRecorder(),
hijack: func() (conn net.Conn, writer *bufio.ReadWriter, err error) {
return nil, nil, errors.New("haha")
},
}
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", xrand.Base64(16))
_, err := Accept(w, r, nil)
assert.Contains(t, err, `failed to hijack connection`)
})
t.Run("wrapperHijackerIsUnwrapped", func(t *testing.T) {
t.Parallel()
rr := httptest.NewRecorder()
w := mockUnwrapper{
ResponseWriter: rr,
unwrap: func() http.ResponseWriter {
return mockHijacker{
ResponseWriter: rr,
hijack: func() (conn net.Conn, writer *bufio.ReadWriter, err error) {
return nil, nil, errors.New("haha")
},
}
},
}
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", xrand.Base64(16))
_, err := Accept(w, r, nil)
assert.Contains(t, err, "failed to hijack connection")
})
t.Run("closeRace", func(t *testing.T) {
t.Parallel()
server, _ := net.Pipe()
rw := bufio.NewReadWriter(bufio.NewReader(server), bufio.NewWriter(server))
newResponseWriter := func() http.ResponseWriter {
return mockHijacker{
ResponseWriter: httptest.NewRecorder(),
hijack: func() (net.Conn, *bufio.ReadWriter, error) {
return server, rw, nil
},
}
}
w := newResponseWriter()
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", xrand.Base64(16))
c, err := Accept(w, r, nil)
wg := &sync.WaitGroup{}
wg.Add(2)
go func() {
c.Close(StatusInternalError, "the sky is falling")
wg.Done()
}()
go func() {
c.CloseNow()
wg.Done()
}()
wg.Wait()
assert.Success(t, err)
})
}
......@@ -76,7 +248,15 @@ func Test_verifyClientHandshake(t *testing.T) {
},
},
{
name: "badWebSocketKey",
name: "missingWebSocketKey",
h: map[string]string{
"Connection": "Upgrade",
"Upgrade": "websocket",
"Sec-WebSocket-Version": "13",
},
},
{
name: "emptyWebSocketKey",
h: map[string]string{
"Connection": "Upgrade",
"Upgrade": "websocket",
......@@ -84,23 +264,63 @@ func Test_verifyClientHandshake(t *testing.T) {
"Sec-WebSocket-Key": "",
},
},
{
name: "shortWebSocketKey",
h: map[string]string{
"Connection": "Upgrade",
"Upgrade": "websocket",
"Sec-WebSocket-Version": "13",
"Sec-WebSocket-Key": xrand.Base64(15),
},
},
{
name: "invalidWebSocketKey",
h: map[string]string{
"Connection": "Upgrade",
"Upgrade": "websocket",
"Sec-WebSocket-Version": "13",
"Sec-WebSocket-Key": "notbase64",
},
},
{
name: "extraWebSocketKey",
h: map[string]string{
"Connection": "Upgrade",
"Upgrade": "websocket",
"Sec-WebSocket-Version": "13",
// Kinda cheeky, but http headers are case-insensitive.
// If 2 sec keys are present, this is a failure condition.
"Sec-WebSocket-Key": xrand.Base64(16),
"sec-webSocket-key": xrand.Base64(16),
},
},
{
name: "badHTTPVersion",
h: map[string]string{
"Connection": "Upgrade",
"Upgrade": "websocket",
"Sec-WebSocket-Version": "13",
"Sec-WebSocket-Key": "meow123",
"Sec-WebSocket-Key": xrand.Base64(16),
},
http1: true,
},
{
name: "success",
h: map[string]string{
"Connection": "Upgrade",
"Connection": "keep-alive, Upgrade",
"Upgrade": "websocket",
"Sec-WebSocket-Version": "13",
"Sec-WebSocket-Key": xrand.Base64(16),
},
success: true,
},
{
name: "successSecKeyExtraSpace",
h: map[string]string{
"Connection": "keep-alive, Upgrade",
"Upgrade": "websocket",
"Sec-WebSocket-Version": "13",
"Sec-WebSocket-Key": "meow123",
"Sec-WebSocket-Key": " " + xrand.Base64(16) + " ",
},
success: true,
},
......@@ -120,10 +340,10 @@ func Test_verifyClientHandshake(t *testing.T) {
}
for k, v := range tc.h {
r.Header.Set(k, v)
r.Header.Add(k, v)
}
err := verifyClientRequest(r)
_, err := verifyClientRequest(httptest.NewRecorder(), r)
if tc.success {
assert.Success(t, err)
} else {
......@@ -166,6 +386,12 @@ func Test_selectSubprotocol(t *testing.T) {
serverProtocols: []string{"echo2", "echo3"},
negotiated: "echo3",
},
{
name: "clientCasePresered",
clientProtocols: []string{"Echo1"},
serverProtocols: []string{"echo1"},
negotiated: "Echo1",
},
}
for _, tc := range testCases {
......@@ -177,7 +403,7 @@ func Test_selectSubprotocol(t *testing.T) {
r.Header.Set("Sec-WebSocket-Protocol", strings.Join(tc.clientProtocols, ","))
negotiated := selectSubprotocol(r, tc.serverProtocols)
assert.Equal(t, tc.negotiated, negotiated, "negotiated")
assert.Equal(t, "negotiated", tc.negotiated, negotiated)
})
}
}
......@@ -186,10 +412,11 @@ func Test_authenticateOrigin(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
origin string
host string
success bool
name string
origin string
host string
originPatterns []string
success bool
}{
{
name: "none",
......@@ -220,6 +447,26 @@ func Test_authenticateOrigin(t *testing.T) {
host: "example.com",
success: true,
},
{
name: "originPatterns",
origin: "https://two.examplE.com",
host: "example.com",
originPatterns: []string{
"*.example.com",
"bar.com",
},
success: true,
},
{
name: "originPatternsUnauthorized",
origin: "https://two.examplE.com",
host: "example.com",
originPatterns: []string{
"exam3.com",
"bar.com",
},
success: false,
},
}
for _, tc := range testCases {
......@@ -230,7 +477,7 @@ func Test_authenticateOrigin(t *testing.T) {
r := httptest.NewRequest("GET", "http://"+tc.host+"/", nil)
r.Header.Set("Origin", tc.origin)
err := authenticateOrigin(r)
err := authenticateOrigin(r, tc.originPatterns)
if tc.success {
assert.Success(t, err)
} else {
......@@ -239,3 +486,89 @@ func Test_authenticateOrigin(t *testing.T) {
})
}
}
func Test_selectDeflate(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
mode CompressionMode
header string
expCopts *compressionOptions
expOK bool
}{
{
name: "disabled",
mode: CompressionDisabled,
expCopts: nil,
expOK: false,
},
{
name: "noClientSupport",
mode: CompressionNoContextTakeover,
expCopts: nil,
expOK: false,
},
{
name: "permessage-deflate",
mode: CompressionNoContextTakeover,
header: "permessage-deflate; client_max_window_bits",
expCopts: &compressionOptions{
clientNoContextTakeover: true,
serverNoContextTakeover: true,
},
expOK: true,
},
{
name: "permessage-deflate/unknown-parameter",
mode: CompressionNoContextTakeover,
header: "permessage-deflate; meow",
expOK: false,
},
{
name: "permessage-deflate/unknown-parameter",
mode: CompressionNoContextTakeover,
header: "permessage-deflate; meow, permessage-deflate; client_max_window_bits",
expCopts: &compressionOptions{
clientNoContextTakeover: true,
serverNoContextTakeover: true,
},
expOK: true,
},
}
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
h := http.Header{}
h.Set("Sec-WebSocket-Extensions", tc.header)
copts, ok := selectDeflate(websocketExtensions(h), tc.mode)
assert.Equal(t, "selected options", tc.expOK, ok)
assert.Equal(t, "compression options", tc.expCopts, copts)
})
}
}
type mockHijacker struct {
http.ResponseWriter
hijack func() (net.Conn, *bufio.ReadWriter, error)
}
var _ http.Hijacker = mockHijacker{}
func (mj mockHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return mj.hijack()
}
type mockUnwrapper struct {
http.ResponseWriter
unwrap func() http.ResponseWriter
}
var _ rwUnwrapper = mockUnwrapper{}
func (mu mockUnwrapper) Unwrap() http.ResponseWriter {
return mu.unwrap()
}
package websocket_test
import (
"context"
"math/rand"
"strings"
"testing"
"time"
"nhooyr.io/websocket"
"nhooyr.io/websocket/internal/assert"
"nhooyr.io/websocket/wsjson"
)
func init() {
rand.Seed(time.Now().UnixNano())
}
func randBytes(n int) []byte {
b := make([]byte, n)
rand.Read(b)
return b
}
func assertJSONEcho(t *testing.T, ctx context.Context, c *websocket.Conn, n int) {
t.Helper()
exp := randString(n)
err := wsjson.Write(ctx, c, exp)
assert.Success(t, err)
var act interface{}
err = wsjson.Read(ctx, c, &act)
assert.Success(t, err)
assert.Equal(t, exp, act, "unexpected JSON")
}
func assertJSONRead(t *testing.T, ctx context.Context, c *websocket.Conn, exp interface{}) {
t.Helper()
var act interface{}
err := wsjson.Read(ctx, c, &act)
assert.Success(t, err)
assert.Equal(t, exp, act, "unexpected JSON")
}
func randString(n int) string {
s := strings.ToValidUTF8(string(randBytes(n)), "_")
if len(s) > n {
return s[:n]
}
if len(s) < n {
// Pad with =
extra := n - len(s)
return s + strings.Repeat("=", extra)
}
return s
}
func assertEcho(t *testing.T, ctx context.Context, c *websocket.Conn, typ websocket.MessageType, n int) {
t.Helper()
p := randBytes(n)
err := c.Write(ctx, typ, p)
assert.Success(t, err)
typ2, p2, err := c.Read(ctx)
assert.Success(t, err)
assert.Equal(t, typ, typ2, "unexpected data type")
assert.Equal(t, p, p2, "unexpected payload")
}
func assertSubprotocol(t *testing.T, c *websocket.Conn, exp string) {
t.Helper()
assert.Equal(t, exp, c.Subprotocol(), "unexpected subprotocol")
}
func assertCloseStatus(t *testing.T, exp websocket.StatusCode, err error) {
t.Helper()
assert.Equal(t, exp, websocket.CloseStatus(err), "unexpected status code")
}
//go:build !js
// +build !js
package websocket_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"strconv"
......@@ -15,232 +17,287 @@ import (
"testing"
"time"
"nhooyr.io/websocket"
"github.com/coder/websocket"
"github.com/coder/websocket/internal/errd"
"github.com/coder/websocket/internal/test/assert"
"github.com/coder/websocket/internal/test/wstest"
"github.com/coder/websocket/internal/util"
)
// https://github.com/crossbario/autobahn-python/tree/master/wstest
func TestAutobahn(t *testing.T) {
t.Parallel()
if os.Getenv("AUTOBAHN") == "" {
t.Skip("Set $AUTOBAHN to run tests against the autobahn test suite")
}
var excludedAutobahnCases = []string{
// We skip the UTF-8 handling tests as there isn't any reason to reject invalid UTF-8, just
// more performance overhead.
"6.*", "7.5.1",
t.Run("server", testServerAutobahn)
t.Run("client", testClientAutobahn)
// 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
"13.3.*", "13.4.*", "13.5.*", "13.6.*",
}
func testServerAutobahn(t *testing.T) {
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.
var onlyAutobahnCases = []string{}
func TestAutobahn(t *testing.T) {
t.Parallel()
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
Subprotocols: []string{"echo"},
})
if err != nil {
t.Logf("server handshake failed: %+v", err)
return
}
echoLoop(r.Context(), c)
}))
defer s.Close()
spec := map[string]interface{}{
"outdir": "ci/out/wstestServerReports",
"servers": []interface{}{
map[string]interface{}{
"agent": "main",
"url": strings.Replace(s.URL, "http", "ws", 1),
},
},
"cases": []string{"*"},
// We skip the UTF-8 handling tests as there isn't any reason to reject invalid UTF-8, just
// more performance overhead. 7.5.1 is the same.
"exclude-cases": []string{"6.*", "7.5.1"},
}
specFile, err := ioutil.TempFile("", "websocketFuzzingClient.json")
if err != nil {
t.Fatalf("failed to create temp file for fuzzingclient.json: %v", err)
if os.Getenv("AUTOBAHN") == "" {
t.SkipNow()
}
defer specFile.Close()
e := json.NewEncoder(specFile)
e.SetIndent("", "\t")
err = e.Encode(spec)
if err != nil {
t.Fatalf("failed to write spec: %v", err)
if os.Getenv("AUTOBAHN") == "fast" {
// These are the slow tests.
excludedAutobahnCases = append(excludedAutobahnCases,
"9.*", "12.*", "13.*",
)
}
err = specFile.Close()
if err != nil {
t.Fatalf("failed to close file: %v", err)
if len(onlyAutobahnCases) > 0 {
excludedAutobahnCases = []string{}
autobahnCases = onlyAutobahnCases
}
ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, time.Minute*10)
ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
defer cancel()
args := []string{"--mode", "fuzzingclient", "--spec", specFile.Name()}
wstest := exec.CommandContext(ctx, "wstest", args...)
out, err := wstest.CombinedOutput()
if err != nil {
t.Fatalf("failed to run wstest: %v\nout:\n%s", err, out)
}
wstestURL, closeFn, err := wstestServer(t, ctx)
assert.Success(t, err)
defer func() {
assert.Success(t, closeFn())
}()
err = waitWS(ctx, wstestURL)
assert.Success(t, err)
cases, err := wstestCaseCount(ctx, wstestURL)
assert.Success(t, err)
t.Run("cases", func(t *testing.T) {
for i := 1; i <= cases; i++ {
i := i
t.Run("", func(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,
})
assert.Success(t, err)
err = wstest.EchoLoop(ctx, c)
t.Logf("echoLoop: %v", err)
})
}
})
c, _, err := websocket.Dial(ctx, wstestURL+"/updateReports?agent=main", nil)
assert.Success(t, err)
c.Close(websocket.StatusNormalClosure, "")
checkWSTestIndex(t, "./ci/out/wstestServerReports/index.json")
checkWSTestIndex(t, "./ci/out/autobahn-report/index.json")
}
func unusedListenAddr() (string, error) {
l, err := net.Listen("tcp", "localhost:0")
if err != nil {
return "", err
func waitWS(ctx context.Context, url string) error {
ctx, cancel := context.WithTimeout(ctx, time.Second*5)
defer cancel()
for ctx.Err() == nil {
c, _, err := websocket.Dial(ctx, url, nil)
if err != nil {
continue
}
c.Close(websocket.StatusNormalClosure, "")
return nil
}
l.Close()
return l.Addr().String(), nil
return ctx.Err()
}
func testClientAutobahn(t *testing.T) {
t.Parallel()
func wstestServer(tb testing.TB, ctx context.Context) (url string, closeFn func() error, err error) {
defer errd.Wrap(&err, "failed to start autobahn wstest server")
serverAddr, err := unusedListenAddr()
if err != nil {
t.Fatalf("failed to get unused listen addr for wstest: %v", err)
return "", nil, err
}
_, serverPort, err := net.SplitHostPort(serverAddr)
if err != nil {
return "", nil, err
}
wsServerURL := "ws://" + serverAddr
url = "ws://" + serverAddr
const outDir = "ci/out/autobahn-report"
spec := map[string]interface{}{
"url": wsServerURL,
"outdir": "ci/out/wstestClientReports",
"cases": []string{"*"},
// See TestAutobahnServer for the reasons why we exclude these.
"exclude-cases": []string{"6.*", "7.5.1"},
}
specFile, err := ioutil.TempFile("", "websocketFuzzingServer.json")
specFile, err := tempJSONFile(map[string]interface{}{
"url": url,
"outdir": outDir,
"cases": autobahnCases,
"exclude-cases": excludedAutobahnCases,
})
if err != nil {
t.Fatalf("failed to create temp file for fuzzingserver.json: %v", err)
return "", nil, fmt.Errorf("failed to write spec: %w", err)
}
defer specFile.Close()
e := json.NewEncoder(specFile)
e.SetIndent("", "\t")
err = e.Encode(spec)
ctx, cancel := context.WithTimeout(ctx, time.Hour)
defer func() {
if err != nil {
cancel()
}
}()
dockerPull := exec.CommandContext(ctx, "docker", "pull", "crossbario/autobahn-testsuite")
dockerPull.Stdout = util.WriterFunc(func(p []byte) (int, error) {
tb.Log(string(p))
return len(p), nil
})
dockerPull.Stderr = util.WriterFunc(func(p []byte) (int, error) {
tb.Log(string(p))
return len(p), nil
})
tb.Log(dockerPull)
err = dockerPull.Run()
if err != nil {
t.Fatalf("failed to write spec: %v", err)
return "", nil, fmt.Errorf("failed to pull docker image: %w", err)
}
err = specFile.Close()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to close file: %v", err)
return "", nil, err
}
ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, time.Minute*10)
defer cancel()
args := []string{"--mode", "fuzzingserver", "--spec", specFile.Name(),
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,
// 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",
}
wstest := exec.CommandContext(ctx, "wstest", args...)
)
wstest := exec.CommandContext(ctx, "docker", args...)
wstest.Stdout = util.WriterFunc(func(p []byte) (int, error) {
tb.Log(string(p))
return len(p), nil
})
wstest.Stderr = util.WriterFunc(func(p []byte) (int, error) {
tb.Log(string(p))
return len(p), nil
})
tb.Log(wstest)
err = wstest.Start()
if err != nil {
t.Fatal(err)
return "", nil, fmt.Errorf("failed to start wstest: %w", err)
}
defer func() {
err := wstest.Process.Kill()
if err != nil {
t.Error(err)
}
}()
// Let it come up.
time.Sleep(time.Second * 5)
var cases int
func() {
c, _, err := websocket.Dial(ctx, wsServerURL+"/getCaseCount", nil)
return url, func() error {
err = wstest.Process.Kill()
if err != nil {
t.Fatal(err)
return fmt.Errorf("failed to kill wstest: %w", err)
}
defer c.Close(websocket.StatusInternalError, "")
_, r, err := c.Reader(ctx)
if err != nil {
t.Fatal(err)
}
b, err := ioutil.ReadAll(r)
if err != nil {
t.Fatal(err)
}
cases, err = strconv.Atoi(string(b))
if err != nil {
t.Fatal(err)
err = wstest.Wait()
var ee *exec.ExitError
if errors.As(err, &ee) && ee.ExitCode() == -1 {
return nil
}
return err
}, nil
}
c.Close(websocket.StatusNormalClosure, "")
}()
for i := 1; i <= cases; i++ {
func() {
ctx, cancel := context.WithTimeout(ctx, time.Second*45)
defer cancel()
func wstestCaseCount(ctx context.Context, url string) (cases int, err error) {
defer errd.Wrap(&err, "failed to get case count")
c, _, err := websocket.Dial(ctx, fmt.Sprintf(wsServerURL+"/runCase?case=%v&agent=main", i), nil)
if err != nil {
t.Fatal(err)
}
echoLoop(ctx, c)
}()
c, _, err := websocket.Dial(ctx, url+"/getCaseCount", nil)
if err != nil {
return 0, err
}
defer c.Close(websocket.StatusInternalError, "")
c, _, err := websocket.Dial(ctx, fmt.Sprintf(wsServerURL+"/updateReports?agent=main"), nil)
_, r, err := c.Reader(ctx)
if err != nil {
return 0, err
}
b, err := io.ReadAll(r)
if err != nil {
return 0, err
}
cases, err = strconv.Atoi(string(b))
if err != nil {
t.Fatal(err)
return 0, err
}
c.Close(websocket.StatusNormalClosure, "")
checkWSTestIndex(t, "./ci/out/wstestClientReports/index.json")
return cases, nil
}
func checkWSTestIndex(t *testing.T, path string) {
wstestOut, err := ioutil.ReadFile(path)
if err != nil {
t.Fatalf("failed to read index.json: %v", err)
}
wstestOut, err := os.ReadFile(path)
assert.Success(t, err)
var indexJSON map[string]map[string]struct {
Behavior string `json:"behavior"`
BehaviorClose string `json:"behaviorClose"`
}
err = json.Unmarshal(wstestOut, &indexJSON)
if err != nil {
t.Fatalf("failed to unmarshal index.json: %v", err)
}
assert.Success(t, err)
var failed bool
for _, tests := range indexJSON {
for test, result := range tests {
switch result.Behavior {
case "OK", "NON-STRICT", "INFORMATIONAL":
default:
failed = true
t.Errorf("test %v failed", test)
}
switch result.BehaviorClose {
case "OK", "INFORMATIONAL":
default:
failed = true
t.Errorf("bad close behaviour for test %v", test)
}
t.Run(test, func(t *testing.T) {
switch result.BehaviorClose {
case "OK", "INFORMATIONAL":
default:
t.Errorf("bad close behaviour")
}
switch result.Behavior {
case "OK", "NON-STRICT", "INFORMATIONAL":
default:
t.Errorf("failed")
}
})
}
}
if failed {
path = strings.Replace(path, ".json", ".html", 1)
if os.Getenv("CI") == "" {
t.Errorf("wstest found failure, see %q (output as an artifact in CI)", path)
}
if t.Failed() {
htmlPath := strings.Replace(path, ".json", ".html", 1)
t.Errorf("detected autobahn violation, see %q", htmlPath)
}
}
func unusedListenAddr() (_ string, err error) {
defer errd.Wrap(&err, "failed to get unused listen address")
l, err := net.Listen("tcp", "localhost:0")
if err != nil {
return "", err
}
l.Close()
return l.Addr().String(), nil
}
func tempJSONFile(v interface{}) (string, error) {
f, err := os.CreateTemp("", "temp.json")
if err != nil {
return "", fmt.Errorf("temp file: %w", err)
}
defer f.Close()
e := json.NewEncoder(f)
e.SetIndent("", "\t")
err = e.Encode(v)
if err != nil {
return "", fmt.Errorf("json encode: %w", err)
}
err = f.Close()
if err != nil {
return "", fmt.Errorf("close temp file: %w", err)
}
return f.Name(), nil
}
#!/bin/sh
set -eu
cd -- "$(dirname "$0")/.."
go test --run=^$ --bench=. --benchmem "$@" ./...
# For profiling add: --memprofile ci/out/prof.mem --cpuprofile ci/out/prof.cpu -o ci/out/websocket.test
(
cd ./internal/thirdparty
go test --run=^$ --bench=. --benchmem "$@" .
GOARCH=arm64 go test -c -o ../../ci/out/thirdparty-arm64.test "$@" .
if [ "$#" -eq 0 ]; then
if [ "${CI-}" ]; then
sudo apt-get update
sudo apt-get install -y qemu-user-static
ln -s /usr/bin/qemu-aarch64-static /usr/local/bin/qemu-aarch64
fi
qemu-aarch64 ../../ci/out/thirdparty-arm64.test --test.run=^$ --test.bench=Benchmark_mask --test.benchmem
fi
)
fmt: modtidy gofmt goimports prettier
ifdef CI
if [[ $$(git ls-files --other --modified --exclude-standard) != "" ]]; then
echo "Files need generation or are formatted incorrectly:"
git -c color.ui=always status | grep --color=no '\e\[31m'
echo "Please run the following locally:"
echo " make fmt"
exit 1
fi
endif
modtidy: gen
go mod tidy
gofmt: gen
gofmt -w -s .
goimports: gen
goimports -w "-local=$$(go list -m)" .
prettier:
prettier --write --print-width=120 --no-semi --trailing-comma=all --loglevel=warn $$(git ls-files "*.yml" "*.md")
gen:
stringer -type=opcode,MessageType,StatusCode -output=stringer.go
#!/bin/sh
set -eu
cd -- "$(dirname "$0")/.."
X_TOOLS_VERSION=v0.31.0
go mod tidy
(cd ./internal/thirdparty && go mod tidy)
(cd ./internal/examples && go mod tidy)
gofmt -w -s .
go run golang.org/x/tools/cmd/goimports@${X_TOOLS_VERSION} -w "-local=$(go list -m)" .
git ls-files "*.yml" "*.md" "*.js" "*.css" "*.html" | xargs npx prettier@3.3.3 \
--check \
--log-level=warn \
--print-width=90 \
--no-semi \
--single-quote \
--arrow-parens=avoid
go run golang.org/x/tools/cmd/stringer@${X_TOOLS_VERSION} -type=opcode,MessageType,StatusCode -output=stringer.go
if [ "${CI-}" ]; then
git diff --exit-code
fi
FROM golang:1
RUN apt-get update
RUN apt-get install -y chromium
RUN apt-get install -y npm
RUN apt-get install -y jq
ENV GOFLAGS="-mod=readonly"
ENV PAGER=cat
ENV CI=true
ENV MAKEFLAGS="--jobs=8 --output-sync=target"
RUN npm install -g prettier
RUN go get golang.org/x/tools/cmd/stringer
RUN go get golang.org/x/tools/cmd/goimports
RUN go get golang.org/x/lint/golint
RUN go get github.com/agnivade/wasmbrowsertest
RUN go get github.com/mattn/goveralls
lint: govet golint govet-wasm golint-wasm
govet:
go vet ./...
govet-wasm:
GOOS=js GOARCH=wasm go vet ./...
golint:
golint -set_exit_status ./...
golint-wasm:
GOOS=js GOARCH=wasm golint -set_exit_status ./...
#!/bin/sh
set -eu
cd -- "$(dirname "$0")/.."
STATICCHECK_VERSION=v0.6.1
GOVULNCHECK_VERSION=v1.1.4
go vet ./...
GOOS=js GOARCH=wasm go vet ./...
go install honnef.co/go/tools/cmd/staticcheck@${STATICCHECK_VERSION}
staticcheck ./...
GOOS=js GOARCH=wasm staticcheck ./...
govulncheck() {
tmpf=$(mktemp)
if ! command govulncheck "$@" >"$tmpf" 2>&1; then
cat "$tmpf"
fi
}
go install golang.org/x/vuln/cmd/govulncheck@${GOVULNCHECK_VERSION}
govulncheck ./...
GOOS=js GOARCH=wasm govulncheck ./...
(
cd ./internal/examples
go vet ./...
staticcheck ./...
govulncheck ./...
)
(
cd ./internal/thirdparty
go vet ./...
staticcheck ./...
govulncheck ./...
)