diff --git a/README.md b/README.md index cafb0b975fba09278df6a13eb315fe162847c2cf..a019fbd5f5fc4cb6d269ba202167c37a61d38740 100644 --- a/README.md +++ b/README.md @@ -4,41 +4,139 @@ [](https://codecov.io/gh/nhooyr/ws) [](https://github.com/nhooyr/ws/releases) -ws is a clean and idiomatic WebSocket library for Go. +ws is a minimal and idiomatic WebSocket library for Go. This library is in heavy development. ## Install ```bash -go get nhooyr.io/ws +go get nhooyr.io/ws@master ``` -## Why +## Example + +### Server + +```go +func main() { + fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := ws.Accept(w, r, + ws.AcceptSubprotocols("echo"), + ) + if err != nil { + log.Printf("server handshake failed: %v", err) + return + } + defer c.Close(ws.StatusInternalError, "") + + ctx, cancel := context.WithTimeout(r.Context(), time.Second*10) + defer cancel() + + type myJsonStruct struct { + MyField string `json:"my_field"` + } + err = wsjson.Write(ctx, c, myJsonStruct{ + MyField: "foo", + }) + if err != nil { + log.Printf("failed to write json struct: %v", err) + return + } + + c.Close(ws.StatusNormalClosure, "") + }) + // For production deployments, use a net/http.Server configured + // with the appropriate timeouts. + err := http.ListenAndServe("localhost:8080", fn) + if err != nil { + log.Fatalf("failed to listen and serve: %v", err) + } +} +``` -There is no other Go WebSocket library with a clean API. +### Client + +```go +func main() { + ctx := context.Background() + ctx, cancel := context.WithTimeout(ctx, time.Minute) + defer cancel() + + c, _, err := ws.Dial(ctx, "ws://localhost:8080") + if err != nil { + log.Fatalf("failed to ws dial: %v", err) + } + defer c.Close(ws.StatusInternalError, "") + + type myJsonStruct struct { + MyField string `json:"my_field"` + } + err = wsjson.Write(ctx, c, myJsonStruct{ + MyField: "foo", + }) + if err != nil { + log.Fatalf("failed to write json struct: %v", err) + } + + c.Close(ws.StatusNormalClosure, "") +} +``` -Comparisons with existing WebSocket libraries below. +See [example_test.go](example_test.go) for more examples. -### [x/net/websocket](https://godoc.org/golang.org/x/net/websocket) +## Features +- Full support of the WebSocket protocol +- Simple to use because of the minimal API +- Uses the context package for cancellation +- Uses net/http's Client to do WebSocket dials +- JSON and Protobuf helpers in wsjson and wspb subpackages +- Compression extension is supported +- Highly optimized +- API will be ready for WebSockets over HTTP/2 -Unmaintained and the API does not reflect WebSocket semantics. +## Design considerations -See https://github.com/golang/go/issues/18152 +- Minimal API is easier to maintain and for others to learn +- Context based cancellation is more ergonomic and robust than setting deadlines +- No pings or pongs because TCP keep alives work fine for HTTP/1.1 and they do not make + sense with HTTP/2 +- net.Conn is never exposed as WebSocket's over HTTP/2 will not have a net.Conn. +- Functional options make the API very clean and easy to extend +- Compression is very useful for JSON payloads +- Protobuf and JSON helpers make code terse +- Using net/http's Client for dialing means we do not have to reinvent dialing hooks + and configurations. Just pass in a custom net/http client if you want custom dialing. + +## Comparison ### [gorilla/websocket](https://github.com/gorilla/websocket) -This package is the community standard but it is very old and over time +This package is the community standard but it is very old and over timennn has accumulated cruft. There are many ways to do the same thing and the API -overall is just not very clear. +overall is just not very clear. Just compare the godoc of +[nhooyr/ws](godoc.org/github.com/nhooyr/ws) side by side with +[gorilla/websocket](godoc.org/github.com/gorilla/websocket). -The callback hooks are also confusing. The API for this library has been designed -such that there is only one way to do things and callbacks have been avoided. +The API for nhooyr/ws has been designed such that there is only one way to do things +and with HTTP/2 in mind which makes using it correctly and safely much easier. -Performance sensitive applications should use ws/wscore directly. +### [x/net/websocket](https://godoc.org/golang.org/x/net/websocket) + +Unmaintained and the API does not reflect WebSocket semantics. Should never be used. + +See https://github.com/golang/go/issues/18152 ### [gobwas/ws](https://github.com/gobwas/ws) -This library has an extremely flexible API but that comes at a cost of usability -and clarity. Its just not clear and simple how to do things in a safe manner. +This library has an extremely flexible API but that comes at the cost of usability +and clarity. Its just not clear how to do things in a safe manner. + +This library is fantastic in terms of performance though. The author put in significant +effort to ensure its speed and I have tried to apply as many of its teachings as +I could into nhooyr/ws. + +If you want a library that gives you absolute control over everything, this is the library, +but for most users, the API provided by nhooyr/ws will definitely fit better as it will +be just as performant but much easier to use. diff --git a/accept.go b/accept.go index fb227eb3ddf2541e02024289fc4dbe068937c574..6ab95ee7528162b098c93dc57ad95100f217ca47 100644 --- a/accept.go +++ b/accept.go @@ -24,6 +24,7 @@ func AcceptSubprotocols(subprotocols ...string) AcceptOption { // Use this option with caution to avoid exposing your WebSocket // server to a CSRF attack. // See https://stackoverflow.com/a/37837709/4283659 +// You can use a * to specify wildcards in domain names. func AcceptOrigins(origins ...string) AcceptOption { panic("TODO") } diff --git a/datatype.go b/datatype.go index ae62f0eabd57d94ea0877eee6660e0bd1ff8c25b..98e1a42ee695ba4d3c7ce9c42915860e9d0b213c 100644 --- a/datatype.go +++ b/datatype.go @@ -1,15 +1,11 @@ package ws -import ( - "nhooyr.io/ws/wscore" -) - // DataType represents the Opcode of a WebSocket data frame. //go:generate stringer -type=DataType type DataType int // DataType constants. const ( - Text DataType = DataType(wscore.OpText) - Binary DataType = DataType(wscore.OpBinary) + Text DataType = DataType(opText) + Binary DataType = DataType(opBinary) ) diff --git a/dial.go b/dial.go index 119dbff92b1ef04856ed33f1dfa32bbb5c84815e..36cb964b01ad489d98d2d619868875edde1f0081 100644 --- a/dial.go +++ b/dial.go @@ -33,9 +33,10 @@ func DialSubprotocols(subprotocols ...string) DialOption { // We use this key for all client requests as the Sec-WebSocket-Key header is useless. // See https://stackoverflow.com/a/37074398/4283659. +// We also use the same mask key for every message as it too does not make a difference. var secWebSocketKey = base64.StdEncoding.EncodeToString(make([]byte, 16)) -// Dial performs a websocket handshake on the given url with the given options. +// Dial performs a WebSocket handshake on the given url with the given options. func Dial(ctx context.Context, u string, opts ...DialOption) (*Conn, *http.Response, error) { panic("TODO") } diff --git a/doc.go b/doc.go index d746de69f4e5a50ae89268b35a73be1d1d9c1ab6..f54a122f77898780443e23a89a9a52375db0f4d1 100644 --- a/doc.go +++ b/doc.go @@ -1,2 +1,4 @@ -// Package ws implements the WebSocket protocol defined in RFC 6455. +// Package ws is a minimal and idiomatic implementation of the WebSocket protocol. +// +// For now the docs are at https://github.com/nhooyr/ws#ws. I will move them here later. package ws diff --git a/example_test.go b/example_test.go index f0c2303a60113c4f62dde2a122b83338bf2ba5ad..882302a3050a4bbb7bacd793e8964d828329c6c9 100644 --- a/example_test.go +++ b/example_test.go @@ -41,7 +41,8 @@ func ExampleAccept_echo() { r.SetContext(ctx) r.Limit(32768) - w := c.MessageWriter(ctx, typ) + w := c.MessageWriter(typ) + w.SetContext(ctx) _, err = io.Copy(w, r) if err != nil { return err @@ -83,10 +84,13 @@ func ExampleAccept() { } defer c.Close(ws.StatusInternalError, "") + ctx, cancel := context.WithTimeout(r.Context(), time.Second*10) + defer cancel() + type myJsonStruct struct { MyField string `json:"my_field"` } - err = wsjson.Write(r.Context(), c, myJsonStruct{ + err = wsjson.Write(ctx, c, myJsonStruct{ MyField: "foo", }) if err != nil { @@ -112,7 +116,6 @@ func ExampleDial() { c, _, err := ws.Dial(ctx, "ws://localhost:8080") if err != nil { log.Fatalf("failed to ws dial: %v", err) - return } defer c.Close(ws.StatusInternalError, "") @@ -124,7 +127,6 @@ func ExampleDial() { }) if err != nil { log.Fatalf("failed to write json struct: %v", err) - return } c.Close(ws.StatusNormalClosure, "") diff --git a/wscore/header.go b/header.go similarity index 64% rename from wscore/header.go rename to header.go index bcbd41eb02b2db5d0564f7cf6e80e5e6559fed50..42eb6143deeca179b62648ff1c8e334405a6ea5a 100644 --- a/wscore/header.go +++ b/header.go @@ -1,17 +1,18 @@ -package wscore +package ws import ( "io" ) -// Header represents a WebSocket frame header. +// header represents a WebSocket frame header. // See https://tools.ietf.org/html/rfc6455#section-5.2 -type Header struct { +// The fields are exported for easy printing for debugging. +type header struct { Fin bool Rsv1 bool Rsv2 bool Rsv3 bool - Opcode Opcode + Opcode opcode PayloadLength int64 @@ -20,7 +21,7 @@ type Header struct { } // Bytes returns the bytes of the header. -func (h Header) Bytes() []byte { +func (h header) Bytes() []byte { panic("TODO") } diff --git a/wscore/mask.go b/mask.go similarity index 77% rename from wscore/mask.go rename to mask.go index c6c08ccd1e85ca8ff821ec957c64f2303fd20326..e3679cb860994d7d00eec4833eedc7404b7e6fa6 100644 --- a/wscore/mask.go +++ b/mask.go @@ -1,6 +1,6 @@ -package wscore +package ws -// Mask applies the websocket masking algorithm to p +// Mask applies the WebSocket masking algorithm to p // with the given key where the first 3 bits of pos // are the starting position in the key. // See https://tools.ietf.org/html/rfc6455#section-5.3 @@ -10,6 +10,6 @@ package wscore // // For targets that do not support unsafe, please report an issue. // There is a mask by byte function below that will be used for such targets. -func Mask(key [4]byte, pos int, p []byte) int { +func mask(key [4]byte, pos int, p []byte) int { panic("TODO") } diff --git a/opcode.go b/opcode.go new file mode 100644 index 0000000000000000000000000000000000000000..5616bb578b15bbbebcd6531aef73545ede875202 --- /dev/null +++ b/opcode.go @@ -0,0 +1,17 @@ +package ws + +// opcode represents a WebSocket Opcode. +//go:generate stringer -type=opcode +type opcode int + +// opcode constants. +const ( + opContinuation opcode = iota + opText + opBinary + // 3 - 7 are reserved for further non-control frames. + opClose opcode = 8 + iota + opPing + opPong + // 11-16 are reserved for further control frames. +) diff --git a/opcode_string.go b/opcode_string.go new file mode 100644 index 0000000000000000000000000000000000000000..84afd17a09da3f4a5a33ee219a099a9dd83b5d1f --- /dev/null +++ b/opcode_string.go @@ -0,0 +1,39 @@ +// Code generated by "stringer -type=opcode"; DO NOT EDIT. + +package ws + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[opContinuation-0] + _ = x[opText-1] + _ = x[opBinary-2] + _ = x[opClose-11] + _ = x[opPing-12] + _ = x[opPong-13] +} + +const ( + _opcode_name_0 = "opContinuationopTextopBinary" + _opcode_name_1 = "opCloseopPingopPong" +) + +var ( + _opcode_index_0 = [...]uint8{0, 14, 20, 28} + _opcode_index_1 = [...]uint8{0, 7, 13, 19} +) + +func (i opcode) String() string { + switch { + case 0 <= i && i <= 2: + return _opcode_name_0[_opcode_index_0[i]:_opcode_index_0[i+1]] + case 11 <= i && i <= 13: + i -= 11 + return _opcode_name_1[_opcode_index_1[i]:_opcode_index_1[i+1]] + default: + return "opcode(" + strconv.FormatInt(int64(i), 10) + ")" + } +} diff --git a/ws.go b/ws.go index 7ec54ed7a82e48c934e37886a38b4cae3eab13c8..964a31843e332ba9d12f2f5adf02a5fe70071cd1 100644 --- a/ws.go +++ b/ws.go @@ -2,7 +2,6 @@ package ws import ( "context" - "net" ) const ( @@ -10,8 +9,7 @@ const ( ) // Conn represents a WebSocket connection. -type Conn struct { -} +type Conn struct{} // Subprotocol returns the negotiated subprotocol. // An empty string means the default protocol. @@ -19,16 +17,11 @@ func (c *Conn) Subprotocol() string { panic("TODO") } -// NetConn returns the net.Conn underlying the Conn. -func (c *Conn) NetConn() net.Conn { - panic("TODO") -} - // MessageWriter returns a writer bounded by the context that will write // a WebSocket data frame of type dataType to the connection. // Ensure you close the MessageWriter once you have written to entire message. // Concurrent calls to MessageWriter are ok. -func (c *Conn) MessageWriter(ctx context.Context, dataType DataType) *MessageWriter { +func (c *Conn) MessageWriter(dataType DataType) *MessageWriter { panic("TODO") } @@ -42,13 +35,15 @@ func (c *Conn) ReadMessage(ctx context.Context) (DataType, *MessageReader, error // Close closes the WebSocket connection with the given status code and reason. // It will write a WebSocket close frame with a timeout of 5 seconds. func (c *Conn) Close(code StatusCode, reason string) error { + // This function also will not wait for a close frame from the peer like the RFC + // wants because that makes no sense and I don't think anyone actually follows that. + // Definitely worth seeing what popular browsers do later. panic("TODO") } // MessageWriter enables writing to a WebSocket connection. // Ensure you close the MessageWriter once you have written to entire message. -type MessageWriter struct { -} +type MessageWriter struct{} // Write writes the given bytes to the WebSocket connection. // The frame will automatically be fragmented as appropriate @@ -58,6 +53,18 @@ func (w *MessageWriter) Write(p []byte) (n int, err error) { panic("TODO") } +// SetContext bounds the writer to the context. +// This must be called before any write. +func (w *MessageWriter) SetContext(ctx context.Context) { + panic("TODO") +} + +// Compress marks the message to be compressed. +// This must be called before any write. +func (w *MessageWriter) Compress() { + panic("TODO") +} + // Close flushes the frame to the connection. // This must be called for every MessageWriter. func (w *MessageWriter) Close() error { @@ -65,17 +72,18 @@ func (w *MessageWriter) Close() error { } // MessageReader enables reading a data frame from the WebSocket connection. -type MessageReader struct { -} +type MessageReader struct{} // SetContext bounds the read operation to the ctx. // By default, the context is the one passed to conn.ReadMessage. // You still almost always want a separate context for reading the message though. +// Must be called before any read. func (r *MessageReader) SetContext(ctx context.Context) { panic("TODO") } // Limit limits the number of bytes read by the reader. +// Must be called before any read. func (r *MessageReader) Limit(bytes int) { panic("TODO") } diff --git a/wscore/opcode.go b/wscore/opcode.go deleted file mode 100644 index 878eb80ec93a7150b34989792e3e52a7a5c9f7b2..0000000000000000000000000000000000000000 --- a/wscore/opcode.go +++ /dev/null @@ -1,17 +0,0 @@ -package wscore - -// Opcode represents a WebSocket Opcode. -//go:generate stringer -type=Opcode -type Opcode int - -// Opcode constants. -const ( - OpContinuation Opcode = iota - OpText - OpBinary - // 3 - 7 are reserved for further non-control frames. - OpClose Opcode = 8 + iota - OpPing - OpPong - // 11-16 are reserved for further control frames. -) diff --git a/wscore/opcode_string.go b/wscore/opcode_string.go deleted file mode 100644 index 8ed5c1c700689582e53dee8fb6963726ad686fea..0000000000000000000000000000000000000000 --- a/wscore/opcode_string.go +++ /dev/null @@ -1,39 +0,0 @@ -// Code generated by "stringer -type=Opcode"; DO NOT EDIT. - -package wscore - -import "strconv" - -func _() { - // An "invalid array index" compiler error signifies that the constant values have changed. - // Re-run the stringer command to generate them again. - var x [1]struct{} - _ = x[OpContinuation-0] - _ = x[OpText-1] - _ = x[OpBinary-2] - _ = x[OpClose-11] - _ = x[OpPing-12] - _ = x[OpPong-13] -} - -const ( - _Opcode_name_0 = "OpContinuationOpTextOpBinary" - _Opcode_name_1 = "OpCloseOpPingOpPong" -) - -var ( - _Opcode_index_0 = [...]uint8{0, 14, 20, 28} - _Opcode_index_1 = [...]uint8{0, 7, 13, 19} -) - -func (i Opcode) String() string { - switch { - case 0 <= i && i <= 2: - return _Opcode_name_0[_Opcode_index_0[i]:_Opcode_index_0[i+1]] - case 11 <= i && i <= 13: - i -= 11 - return _Opcode_name_1[_Opcode_index_1[i]:_Opcode_index_1[i+1]] - default: - return "Opcode(" + strconv.FormatInt(int64(i), 10) + ")" - } -}