Documentation
¶
Overview ¶
Package websocket — the closing handshake's payload (RFC 6455 §5.5.1): a two-byte status code and an optional UTF-8 reason, written and read here. The close code and the predicates that govern it — Sendable, Echoable — are the domain's (corenet.WSCloseCode); this is their wire form (ADR 0160 §4).
Package websocket — the frame header and its wire form (RFC 6455 §5).
Reading and writing frames is this package's mechanism, not the domain's contract (ADR 0160 §4): it moved here from internal/core/net, which keeps what a second implementation would share — the opcode, the close code, the message and the control-frame ceiling (corenet.WSMaxControlPayload).
Package websocket — the RFC 6455 §4.2 opening handshake.
Package websocket — the opening handshake's key exchange (RFC 6455 §4.1, §4.2.2): the check that a Sec-WebSocket-Key is a nonce, and the digest that answers it. The protocol's constants — the GUID, the version, the header names — are the domain's (corenet.WSGUID and its neighbours).
Package websocket — the connection's functional options and their defaults.
Package websocket — the UTF-8 rule a text message must satisfy (RFC 6455 §8.1), checked on the bytes as they come off and go onto the wire. The message itself is the domain's value (corenet.WSMessageValue).
Package websocket is the server side of RFC 6455: an HTTP request upgraded into a bidirectional, message-oriented connection over the same socket.
It is written against net/http's own interfaces, not against this SDK's listener engine, so it works inside any http.Handler. Mounted on the SDK engine it additionally observes the server's drain signal, which is what stops a connection that by design never ends from being severed under its handler on every deployment.
permessage-deflate and every other extension are deliberately NOT negotiated; the reserved frame bits an extension would use are refused as protocol errors. See the package CLAUDE.md.
Index ¶
- Constants
- func AcceptKey(key string) string
- func AppendClosePayload(dst []byte, code corenet.WSCloseCode, reason string) (wire []byte, err error)
- func AppendFrame(dst []byte, op corenet.WSOpCode, final bool, payload []byte) (wire []byte, err error)
- func ApplyMask(payload []byte, key [MaskLen]byte)
- func FrameHeaderLen(b []byte) int
- func ParseClosePayload(b []byte) (code corenet.WSCloseCode, reason string, err error)
- func ValidateKey(key string) error
- func ValidateText(b []byte) error
- type Conn
- func (c *Conn) Close() error
- func (c *Conn) CloseWith(code corenet.WSCloseCode, reason string) error
- func (c *Conn) Done() <-chan struct{}
- func (c *Conn) PeerCloseCode() corenet.WSCloseCode
- func (c *Conn) Ping(payload []byte) error
- func (c *Conn) Receive() (message corenet.WSMessageValue, err error)
- func (c *Conn) Send(message corenet.WSMessageValue) error
- func (c *Conn) SendBinary(data []byte) error
- func (c *Conn) SendText(text string) error
- func (c *Conn) Subprotocol() string
- type FrameHeaderValue
- type Option
Constants ¶
const CloseCodeLen int = 2
CloseCodeLen is the width of the status code that opens a Close frame's payload. A Close payload is therefore either empty or at least this long — one byte is not "a truncated code", it is a protocol error.
const DefaultMaxFrameSize int64 = 1 << 20 // 1 MiB
DefaultMaxFrameSize is the ceiling on a single frame's announced payload. It is checked before any buffer is sized, which is the whole point: the number being checked is the number the attacker wrote.
const DefaultMaxMessageSize int64 = 1 << 20 // 1 MiB
DefaultMaxMessageSize is the ceiling on one reassembled message. It exists because a message's size is chosen by the PEER, one 64-bit field at a time, and fragmentation lets that peer keep choosing until the process dies.
const DefaultPingInterval time.Duration = 30 * time.Second
DefaultPingInterval is how often an idle connection proves the peer is still there. It sits under the idle timeout of every proxy worth naming (nginx and most cloud load balancers default to 60 s), so a connection that has nothing to say still survives the intermediaries between it and its peer.
const DefaultWriteTimeout time.Duration = 10 * time.Second
DefaultWriteTimeout bounds ONE frame's write. A connection has no total write budget by nature; this is what stops a peer that has stopped reading from pinning a goroutine and a socket buffer forever.
const MaskLen int = 4
MaskLen is the width of the masking key a client prefixes to every frame.
const MaxHeaderLen int = MinHeaderLen + length64Width + MaskLen
MaxHeaderLen is the longest possible frame header: the two fixed bytes, a 64-bit extended length, and a masking key.
const MinHeaderLen int = 2
MinHeaderLen is the shortest possible frame header: flags/opcode and the mask bit with a 7-bit length.
Variables ¶
This section is empty.
Functions ¶
func AcceptKey ¶
AcceptKey computes the Sec-WebSocket-Accept value for a client's Sec-WebSocket-Key (RFC 6455 §4.2.2 step 5).
The digest is SHA-1 by the RFC's own instruction. It is not a security primitive here and is not used as one: its job is to prove the server parsed the handshake rather than replaying it, so that a cache holding a stale 101 cannot be mistaken for a live upgrade.
func AppendClosePayload ¶
func AppendClosePayload(dst []byte, code corenet.WSCloseCode, reason string) (wire []byte, err error)
AppendClosePayload appends a Close frame's payload — a two-byte status code followed by an optional UTF-8 reason — to dst.
It refuses a code that must not appear on the wire and a reason that would push the control frame past its 125-byte ceiling. Both refusals happen before anything is appended.
func AppendFrame ¶
func AppendFrame(dst []byte, op corenet.WSOpCode, final bool, payload []byte) (wire []byte, err error)
AppendFrame appends one server frame to dst and returns the extended slice.
The frame is never masked, because RFC 6455 §5.1 forbids a server from masking and a client that receives a masked frame fails the connection. That is expressed by the absence of a parameter rather than by a documented default: there is no argument a caller could pass to get it wrong.
Appending rather than allocating is what lets a connection reuse one buffer for its whole life.
func ApplyMask ¶
ApplyMask XORs payload in place with the frame's masking key.
The transform is its own inverse, so the same call unmasks what it masked. It is done in place because the payload has already been read into the buffer that will be handed to the application: copying it out to unmask would double the cost of every frame for no gain.
It runs over EVERY inbound byte and there is no way to opt out — RFC 6455 §5.1 requires every client-to-server frame to be masked — so it moves a machine word at a time rather than a byte at a time. A CPU profile of a 4 KiB receive attributed 89 % of the whole path to the byte-at-a-time form; the word form is measured at sixteen times its throughput at that size, and the compiler renders each word as one memory-destination XOR with no vector instruction, no assembly and no unsafe.pointer anywhere.
Why the byte order cancels ¶
binary.LittleEndian is used for BOTH the payload word and the key word, and that is the entire endianness argument. A fixed-order decode E is a bijection between eight bytes and a uint64 under which XOR is bytewise — E(a)^E(b) = E(a XOR b), because each byte occupies its own bit field — so decoding, XORing and re-encoding with ONE order reproduces the byte-for-byte XOR on every machine, big-endian included. What is unsafe is not the choice of order but MIXING two, or reinterpreting the slice as words directly, which is native-order and would silently disagree with a little-endian key on a big-endian host. Neither appears here, and neither can be added without changing the one order this function names.
func FrameHeaderLen ¶
FrameHeaderLen returns the full header length announced by the first two bytes of a frame, or 0 when fewer than two bytes are available.
It exists so a reader can take exactly two bytes, learn how many more the header needs, and take exactly those — never speculating past the header into a payload it has not yet bounded.
func ParseClosePayload ¶
func ParseClosePayload(b []byte) (code corenet.WSCloseCode, reason string, err error)
ParseClosePayload reads a received Close frame's payload.
An empty payload is legal and reports corenet.WSCloseNoStatus — the value that exists to describe exactly this, and that must never be sent. A ONE-byte payload is not a truncated code to be salvaged: it is a protocol error, and treating it as anything else would let a peer choose which half of the code this endpoint reads.
func ValidateKey ¶
ValidateKey reports whether a Sec-WebSocket-Key is what RFC 6455 §4.1 says it is: base64 of exactly sixteen bytes.
It is checked rather than merely echoed through the digest because a key of the wrong length is a request that was not produced by a WebSocket client at all — most often a scanner, sometimes a proxy replaying a fragment — and answering it with a 101 hands a socket to something that will never speak the protocol.
func ValidateText ¶
ValidateText reports whether a payload can travel as a text frame.
Validation is performed on the REASSEMBLED message rather than per frame, and that is a correctness requirement rather than an optimisation: a multi-byte sequence may straddle a fragment boundary, so a per-frame check would reject perfectly valid messages whose only fault is where the sender chose to split them.
Types ¶
type Conn ¶
type Conn struct {
// contains filtered or unexported fields
}
Conn is one upgraded WebSocket connection.
Every connection needs exactly ONE goroutine looping on Conn.Receive for its whole life — not two, and not zero — and that includes a connection the handler only ever writes to.
Not two, because the protocol is a single ordered frame stream: two concurrent readers would each take half of a message.
Not zero, because that loop is more than the way messages arrive. It is where the peer's Ping is answered (RFC 6455 §5.5.2), where its Close is replied to (§5.5.1), and the only place the heartbeat can see that the peer is alive: the heartbeat counts frames the handler has READ, so a Pong the peer sent on time but nobody read is, to it, silence. A handler that only pushes therefore runs the loop in a goroutine of its own and discards what it reads. Without one, the heartbeat ends the connection within two ping intervals — about a minute at DefaultPingInterval — however healthy the peer is. Nothing here starts that reader for you: it would have to own the buffer Receive hands out, and owning it is what makes a steady-state read free.
Writes are safe for concurrent use — the heartbeat writes from its own goroutine while the handler writes from another, and a handler fanning messages in from several producers is the normal shape.
func NewConn ¶
func NewConn(socket stdnet.Conn, buffered *bufio.Reader, subprotocol string, cfg *config, draining <-chan struct{}) *Conn
NewConn assembles the connection and starts the goroutines it owns.
It is exported only because the struct-constructor lint requires a New-prefixed constructor beside every exported struct; it takes an unexported option set, so nothing outside this package can call it. Upgrade is the real entry point, and the public façade re-exports only that.
func Upgrade ¶
Upgrade completes the RFC 6455 opening handshake and returns the connection.
On failure it has already written an HTTP response — a 426 carrying the version this server speaks, a 400, a 403 or a 405 as the fault requires — so the handler has nothing left to answer and should simply return.
The socket is taken over from net/http, which means it is no longer the HTTP server's to close, nor this SDK's listener engine's. Both are told, and the connection is the handler's until it closes it.
func (*Conn) Close ¶
Close ends the connection with a normal-closure status.
A handler should defer it. It returns nil even when the socket has already gone: by the time a handler is closing, the peer having left first is the expected ending, not a fault to report. Use Conn.CloseWith to say something more specific.
func (*Conn) CloseWith ¶
func (c *Conn) CloseWith(code corenet.WSCloseCode, reason string) error
CloseWith ends the connection with a specific status code and reason.
It reports an error only when the CODE or the reason is unusable — a code the RFC forbids on the wire, a reason that is not UTF-8 or does not fit a control frame. Those are caller mistakes and must surface whatever the socket is doing; a transport failure at this point is not, because there is nothing left to do about it.
func (*Conn) Done ¶
func (c *Conn) Done() <-chan struct{}
Done returns the channel closed when the connection has ended: the peer sent Close, the socket died, the heartbeat found the peer silent, the server began draining, or the handler closed it.
Done is for the goroutines that are NOT reading. The one goroutine every connection needs looping on Receive (see Conn) learns of the end from Receive's terminal error at the same moment. A handler that pushes application events selects on Done beside its event source, so it stops between events rather than on the next failed Send.
Watching Done does not replace the reader. A connection nobody reads answers no Ping, replies to no Close, and is ended by the heartbeat within two ping intervals, however healthy the peer is.
func (*Conn) PeerCloseCode ¶
func (c *Conn) PeerCloseCode() corenet.WSCloseCode
PeerCloseCode returns the status code the peer's Close frame carried.
It reports corenet.WSCloseNoStatus when the peer closed without one — the code that exists to describe exactly that — and zero when no Close frame was ever received, which is the case that matters most: it distinguishes a peer that said goodbye from one that vanished.
func (*Conn) Ping ¶
Ping sends a Ping frame carrying payload.
It is exported because the automatic heartbeat answers "is the peer alive on its own schedule"; a caller sometimes needs to ask at a moment of its own — right after a long computation, or before committing to expensive work on the peer's behalf. A payload past corenet.WSMaxControlPayload is refused: a control frame must fit a buffer that is always available.
func (*Conn) Receive ¶
func (c *Conn) Receive() (message corenet.WSMessageValue, err error)
Receive reads the next complete message, answering control frames on the way.
It is called in a loop, by one goroutine, for the connection's whole life — on a connection with nothing to read as much as on any other. Control frames are served INSIDE this call: the Pong a Ping is owed, the Close a Close is owed, and the count the heartbeat reads to decide the peer is alive. Stop calling it and all three stop with it; see Conn.
It returns a message only when one is whole: fragmentation is the sender's private choice of chunk size, not a semantic boundary, so surfacing it would put an implementation detail of the peer's writer into every consumer.
The returned Data aliases the connection's own reassembly buffer and is valid until the next call to Receive. A handler that keeps a message past that must copy it — the same rule the engine's Conn.Buffer() carries, for the same reason: reusing one buffer is what makes a steady-state read allocate nothing.
func (*Conn) Send ¶
func (c *Conn) Send(message corenet.WSMessageValue) error
Send writes one message as a single frame and returns once it is on the wire.
The SDK never fragments what it sends. Fragmentation exists so a sender can begin a message whose length it does not yet know; every message this API can express is already in memory, so splitting it would add a failure mode (a half-sent message when the socket dies mid-sequence) in exchange for nothing.
func (*Conn) SendBinary ¶
SendBinary writes one binary message.
func (*Conn) Subprotocol ¶
Subprotocol returns the subprotocol the handshake agreed on, or "" when none was.
type FrameHeaderValue ¶
type FrameHeaderValue struct {
// Final is the FIN bit: this frame completes the message.
Final bool `json:"final"`
// OpCode is the frame type.
OpCode corenet.WSOpCode `json:"opcode"`
// Masked is the MASK bit. RFC 6455 §5.1 requires it on every client frame
// and forbids it on every server frame.
Masked bool `json:"masked"`
// MaskKey is the four-byte key, meaningful only when Masked.
MaskKey [MaskLen]byte `json:"mask_key"`
// Length is the payload length the frame announces. It is what the peer
// SAYS, not what it has sent — every bound must be checked against it
// BEFORE any buffer is sized from it.
Length uint64 `json:"length"`
}
FrameHeaderValue is one parsed frame header (RFC 6455 §5.2).
The RSV bits are absent on purpose. They only mean anything once an extension has been negotiated, this domain negotiates none, and a set RSV bit is therefore a protocol error rather than a value to carry — see ParseFrameHeader.
func ParseFrameHeader ¶
func ParseFrameHeader(b []byte) (header FrameHeaderValue, err error)
ParseFrameHeader parses a COMPLETE frame header and enforces every rule RFC 6455 states about it that does not depend on which side sent it.
The masking requirement is deliberately NOT here: it is the one rule whose answer depends on the direction of travel, so it lives in FrameHeaderValue.ValidateFromClient where the direction is named. Every other rule — reserved bits, reserved opcodes, control-frame shape, minimal length encoding — is absolute and is checked here, once.
func (FrameHeaderValue) ValidateFromClient ¶
func (h FrameHeaderValue) ValidateFromClient() error
ValidateFromClient enforces the one framing rule whose answer depends on the direction of travel: RFC 6455 §5.1 requires every client-to-server frame to be masked.
An unmasked client frame FAILS THE CONNECTION. That is not pedantry about a key that protects nothing cryptographically: masking exists so a hostile script cannot steer a browser into emitting bytes that a transparent intermediary would read as a second, attacker-chosen HTTP request. An endpoint that accepted unmasked frames would let that script skip the one mechanism the design has against cache poisoning.
type Option ¶
type Option func(*config)
Option configures a Conn.
func AllowAnyOrigin ¶
func AllowAnyOrigin() Option
AllowAnyOrigin disables the origin check.
It has to be written out because the browser's same-origin policy does NOT apply to WebSocket: a page on any site can open a connection to this server and the browser will attach the user's cookies to the handshake. An upgrader that accepted every origin by default would be a cross-site request forgery primitive with a default-on switch, so the switch is default-off and named.
Reach for it when authentication does not ride on ambient credentials — a bearer token in the subprotocol, a signed ticket in the query — which is exactly the case where the origin proves nothing anyway.
func AllowOrigins ¶
AllowOrigins replaces the default same-origin rule with an exact allowlist.
The comparison is on the whole Origin header, case-insensitively — scheme, host and port together. Comparing only the host would accept http://app.example.com for an https server, which is precisely the downgrade an origin check exists to notice. Behind a proxy that terminates TLS this is the only way to have the scheme checked at all: the request arrives in plaintext there, so the default rule cannot see which scheme the browser used (see sameOrigin).
The list is copied here, for the same reason Subprotocols copies its own.
func MaxFrameSize ¶
MaxFrameSize bounds one frame's ANNOUNCED payload length.
It is a separate bound from MaxMessageSize because it is enforced at a different moment: the frame ceiling is checked against the header, before a single payload byte is read or a single byte allocated, whereas the message ceiling is checked as fragments accumulate. A connection that only had the second would have to allocate the first frame to discover it was too big.
func MaxMessageSize ¶
MaxMessageSize bounds one reassembled message.
Zero is clamped to DefaultMaxMessageSize and a negative value is refused (ADR 0031). There is deliberately no way to spell "unbounded": the length is announced by the peer in a 64-bit field, and fragmentation lets it keep announcing more, so an unbounded ceiling is not a configuration choice — it is a remote memory allocator.
func PingInterval ¶
PingInterval sets how often the connection pings an otherwise silent peer.
Zero does NOT mean "never" (ADR 0031). A connection with the heartbeat silently disabled works perfectly on a developer's loopback and dies at one minute behind a real proxy — and worse, it stops noticing a peer that has vanished without closing, which is the normal way a mobile client leaves. Zero is clamped to DefaultPingInterval; a negative interval is refused; to disable it, say so with WithoutPing.
Every interval the heartbeat sends a Ping, and one interval later it ends the connection if the handler has READ no frame since. It counts frames Receive has read, never frames that merely arrived, so it keeps a connection open only while one goroutine loops on Receive — see Conn.
func Subprotocols ¶
Subprotocols declares the subprotocols this server speaks, most preferred first.
The SERVER's order decides, not the client's: the client advertises what it can do, and choosing among those is the server's call — a client that listed a deprecated dialect first should not be able to pin the server to it.
When the client offers none this server can speak, the upgrade still succeeds with no Sec-WebSocket-Protocol header, which RFC 6455 §4.2.2 names as the way to say "none agreed". Failing the handshake instead would be a stricter rule than the protocol has, and it would break every client that advertises an optional dialect.
Each name must be an RFC 7230 token, which is what RFC 6455 §4.1 requires of a subprotocol: the chosen one is written verbatim into the response, so a name carrying a space, a comma or a quote would put a malformed handshake on the wire — it is refused at Upgrade instead. The list is copied here, so a caller reusing the slice it passed cannot change later handshakes, or race with the ones in flight.
func WithoutPing ¶
func WithoutPing() Option
WithoutPing disables the heartbeat entirely.
It exists so that "never" is something a caller writes on purpose rather than something a zero value does to them. It also disables the only liveness check this connection has: without it, a peer that disappears without closing — a laptop lid, a NAT rebinding — leaves a goroutine parked on a read that will never return until something else closes the socket.
func WriteTimeout ¶
WriteTimeout bounds how long one frame's write may take.
It is per-frame on purpose. A group's WriteTimeout is an absolute deadline for the whole HTTP response, which for a connection that outlives the response entirely would cut it at that instant; the upgrade therefore clears the inherited deadline and replaces it with this bound, refreshed per frame. Zero is clamped to DefaultWriteTimeout, negative is refused.