Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: CI

on:
push:
branches:
- main
pull_request:

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-Dwarnings"

jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets
- run: cargo test --workspace

wasm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2
- name: Check browser crates
run: >-
cargo check
-p websock
-p websock-mux
-p websock-wasm
-p websock-wasm-mux
-p websock-wasm-demo
--target wasm32-unknown-unknown
22 changes: 0 additions & 22 deletions .github/workflows/rust.yml

This file was deleted.

20 changes: 13 additions & 7 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,28 @@ members = [
"websock-tungstenite",
"websock-wasm",
"websock-wasm-demo",
"websock-browser-test-server",
"websock-mux",
"websock-tungstenite-mux",
"websock-wasm-mux",
"websock-mux-proto"
]
exclude = ["fuzz"]

[workspace.package]
version = "0.4.0"
version = "0.5.0"
edition = "2024"
rust-version = "1.88"
authors = ["shellrow <shellrow@foctal.com>"]

[workspace.dependencies]
websock-proto = { path = "websock-proto", version = "0.4.0" }
websock-tungstenite = { path = "websock-tungstenite", version = "0.4.0" }
websock-wasm = { path = "websock-wasm", version = "0.4.0" }
websock-mux-proto = { path = "websock-mux-proto", version = "0.4.0" }
websock-tungstenite-mux = { path = "websock-tungstenite-mux", version = "0.4.0" }
websock-wasm-mux = { path = "websock-wasm-mux", version = "0.4.0" }
websock-proto = { path = "websock-proto", version = "0.5.0" }
websock-tungstenite = { path = "websock-tungstenite", version = "0.5.0" }
websock-wasm = { path = "websock-wasm", version = "0.5.0" }
websock-mux-proto = { path = "websock-mux-proto", version = "0.5.0" }
websock-tungstenite-mux = { path = "websock-tungstenite-mux", version = "0.5.0" }
websock-wasm-mux = { path = "websock-wasm-mux", version = "0.5.0" }
bytes = "1"
wasm-bindgen = "=0.2.126"
wasm-bindgen-futures = "=0.4.76"
wasm-bindgen-test = "=0.3.76"
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

A minimal WebSocket library for native and WebAssembly.

The minimum supported Rust version (MSRV) is 1.88.

## Workspace crates

- `websock`: top-level facade that selects native (`websock-tungstenite`) or browser (`websock-wasm`) transport.
Expand Down Expand Up @@ -38,6 +40,38 @@ See [examples][examples-url].

The `websock-wasm-demo` crate includes a small browser app that connects to an echo server.

## Resource limits

WebSocket clients and servers use conservative message, frame, and write-buffer
limits by default. Customize them with `WebSocketLimits`:

```rust
use websock::{ClientBuilder, WebSocketLimits};

let client = ClientBuilder::new()
.with_limits(WebSocketLimits {
max_message_size: 2 * 1024 * 1024,
max_frame_size: 512 * 1024,
max_write_buffer_size: 2 * 1024 * 1024,
})
.build();
```

The mux transports additionally expose `Limits` for stream counts, queue
capacities, batching, and per-stream flow-control windows. Invalid or
inconsistent limits return a protocol error rather than panicking.

The multiplexing wire format, stream lifecycle, flow control, compatibility
policy, and error codes are specified in [docs/mux-protocol.md](docs/mux-protocol.md).

## Error handling

Native I/O failures retain their original `std::io::Error`, including the
`ErrorKind`, in `websock_proto::Error::Io`. TLS and underlying WebSocket
transport failures retain their concrete errors as standard error sources.
Call `std::error::Error::source` to inspect or downcast those sources, and use
`Error::io_kind` when only the I/O classification is needed.

## Benchmarking

Criterion benchmarks are available for `websock-mux-proto`.
Expand Down
24 changes: 24 additions & 0 deletions deny.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[advisories]
yanked = "deny"

[licenses]
confidence-threshold = 0.8
allow = [
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"CDLA-Permissive-2.0",
"ISC",
"MIT",
"Unicode-3.0",
]

[bans]
multiple-versions = "warn"
wildcards = "allow"

[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
98 changes: 98 additions & 0 deletions docs/mux-protocol.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# WebSocket Multiplexing Protocol

This document specifies version 1 of the `websock` multiplexing protocol. A
connection using this protocol MUST negotiate the WebSocket subprotocol
`websock-mux-1`. Peers MUST use binary WebSocket messages; text messages are a
protocol error.

## Compatibility

The subprotocol name carries the wire-protocol major version. Implementations
with incompatible framing or stream semantics MUST use a different
subprotocol. Additive behavior that old peers can safely ignore may remain
within version 1, but version 1 currently defines no ignorable frame types.
Unknown frame types are therefore a protocol error.

One binary WebSocket message may contain one or more complete mux frames.
Frames MUST NOT span WebSocket messages. All integers use the QUIC
variable-length integer encoding and are limited to the inclusive range
`0..2^62-1`.

## Stream identifiers

A stream identifier encodes three fields:

```text
stream_id = stream_counter * 4 | direction_bit * 2 | initiator_bit
```

- `initiator_bit`: client is `0`; server is `1`.
- `direction_bit`: bidirectional is `0`; unidirectional is `1`.
- `stream_counter`: a monotonically increasing counter maintained separately
for each direction.

A peer MUST open its streams with monotonically increasing identifiers. An
identifier with the wrong initiator or direction is a protocol error.

## Frames

Each frame starts with a varint tag followed by the listed fields:

| Tag | Frame | Fields |
| ---: | --- | --- |
| 0 | `OpenUni` | `stream_id` |
| 1 | `OpenBi` | `stream_id` |
| 2 | `Stream` | `stream_id`, `fin`, `length`, `length` data bytes |
| 3 | `ResetStream` | `stream_id`, application error code |
| 4 | `StopSending` | `stream_id`, application error code |
| 5 | `ConnectionClose` | connection error code, UTF-8 reason length, reason bytes |
| 6 | `MaxStreamData` | `stream_id`, cumulative byte limit |

The `fin` field MUST be `0` or `1`. Length-prefixed data MUST be fully present
in the containing WebSocket message. A malformed field, invalid UTF-8 reason,
unknown tag, or truncated frame is a protocol error.

## Stream state

`OpenUni` creates a receive stream for the peer. `OpenBi` creates both a send
and a receive side. Stream data may be sent only after the corresponding open
frame and MUST NOT be sent after FIN or reset.

A `Stream` frame with `fin = 1` closes only the sender's direction. Its data,
if any, remains readable before end-of-stream is reported. The other direction
of a bidirectional stream remains usable.

`ResetStream` abruptly terminates the sender's direction. `StopSending` asks
the peer to stop its sender direction; the peer then treats that send side as
closed. Application error codes are opaque to the protocol and MUST fit in a
varint.

## Flow control

New send streams start with zero credit. A receiver grants credit with
`MaxStreamData`, whose `max` field is the cumulative number of stream-data
bytes the sender may transmit. Values MUST be monotonic. A sender MUST block
when it has exhausted the latest advertised limit.

The receiver counts only `Stream` payload bytes, not frame overhead. Receiving
bytes beyond the advertised cumulative limit is a connection-level flow
control error. Implementations issue further cumulative credit as the
application consumes buffered bytes.

## Connection closure and error codes

`ConnectionClose` is terminal. After receiving it, a peer closes all streams,
wakes blocked operations, and closes the WebSocket session.

The following connection error codes are defined:

| Code | Meaning |
| ---: | --- |
| 0 | Graceful or unspecified closure |
| 1 | Malformed frame or stream state violation |
| 2 | Size or flow-control violation |
| 3 | Resource limit exceeded |

Other codes are reserved for future versions. WebSocket close frames remain
part of the underlying transport; either a mux `ConnectionClose` or an
underlying WebSocket closure terminates the session.
34 changes: 34 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
[package]
name = "websock-fuzz"
version = "0.0.0"
edition = "2024"
publish = false

[package.metadata]
cargo-fuzz = true

[dependencies]
bytes = "1"
libfuzzer-sys = "0.4"
websock-mux-proto = { path = "../websock-mux-proto" }

[[bin]]
name = "varint_decode"
path = "fuzz_targets/varint_decode.rs"
test = false
doc = false
bench = false

[[bin]]
name = "frame_decode"
path = "fuzz_targets/frame_decode.rs"
test = false
doc = false
bench = false

[[bin]]
name = "mux_sequence"
path = "fuzz_targets/mux_sequence.rs"
test = false
doc = false
bench = false
14 changes: 14 additions & 0 deletions fuzz/fuzz_targets/frame_decode.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#![no_main]

use bytes::{Buf, Bytes};
use libfuzzer_sys::fuzz_target;
use websock_mux_proto::Frame;

fuzz_target!(|data: &[u8]| {
let mut input = Bytes::copy_from_slice(data);
while input.has_remaining() {
if Frame::decode(&mut input).is_err() {
break;
}
}
});
47 changes: 47 additions & 0 deletions fuzz/fuzz_targets/mux_sequence.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#![no_main]

use std::collections::HashSet;

use bytes::{Buf, Bytes};
use libfuzzer_sys::fuzz_target;
use websock_mux_proto::{Frame, StreamId};

fuzz_target!(|data: &[u8]| {
let mut input = Bytes::copy_from_slice(data);
let mut receive_streams = HashSet::<StreamId>::new();
let mut send_streams = HashSet::<StreamId>::new();

while input.has_remaining() {
let Ok(frame) = Frame::decode(&mut input) else {
break;
};
match frame {
Frame::OpenUni { id } => {
receive_streams.insert(id);
}
Frame::OpenBi { id } => {
receive_streams.insert(id);
send_streams.insert(id);
}
Frame::Stream { id, fin, .. } => {
if fin {
receive_streams.remove(&id);
}
}
Frame::ResetStream { id, .. } => {
receive_streams.remove(&id);
}
Frame::StopSending { id, .. } => {
send_streams.remove(&id);
}
Frame::MaxStreamData { id, .. } => {
let _ = send_streams.contains(&id);
}
Frame::ConnectionClose { .. } => {
receive_streams.clear();
send_streams.clear();
break;
}
}
}
});
14 changes: 14 additions & 0 deletions fuzz/fuzz_targets/varint_decode.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#![no_main]

use bytes::{Buf, Bytes};
use libfuzzer_sys::fuzz_target;
use websock_mux_proto::VarInt;

fuzz_target!(|data: &[u8]| {
let mut input = Bytes::copy_from_slice(data);
while input.has_remaining() {
if VarInt::decode(&mut input).is_err() {
break;
}
}
});
Loading
Loading