protocol-websocket 0.20.2 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 359985418f2c1304957337e8d10ca42c7dec2225fa60e5db0e030e40ccdfbc30
4
- data.tar.gz: fc29187e933f95505987ff87616c61877809fe3a0a94600b782d4d1728edef87
3
+ metadata.gz: 90750bdfa4f0cafe292f5b7faf67cecc3ad8dd599ef30e6be28bf6594405f424
4
+ data.tar.gz: 16d01a6e7e451a9444698cc852f937f1f50b9898bdb072d90cf3c93be2ce8b95
5
5
  SHA512:
6
- metadata.gz: ef23fec7204ea0b9d965c3972b94fa39e615b5581470703f16564e5031d9819acd720618027274fd900b77864bf662c8adb44e895ce74b505c95c4cb3a7393ed
7
- data.tar.gz: 5fb3eefc7a49e405fa7c8cd729812cf79095e8b8addf8af27a8aa35b4d3f759901de6c41a7233aceb2a76df43166dba43440e54d5bda5b765225480b21fd4057
6
+ metadata.gz: 49fdf1a4d58cc4a0b994a763b792cb576a1833d4c50c4abbc7596b8ded83f2df873e13ecf1b5948af392ac42dee3afb6e16210a25ed128b1ca0206e734dc08b4
7
+ data.tar.gz: d3ded669f47a3a58a226ee186c58a58e3234c4bf2678cc9fb1633a4a8d58caac9d11715825049a359568af896a376a33c1a42d7b60e02e08d090dc49c2e4ecd9
checksums.yaml.gz.sig CHANGED
Binary file
@@ -0,0 +1,91 @@
1
+ # Extensions
2
+
3
+ This guide explains how to use `protocol-websocket` for implementing a websocket client and server using extensions.
4
+
5
+ ## Per-message Deflate
6
+
7
+ WebSockets have a mechanism for implementing extensions. At the time of writing, the only published extension is `permessage-deflate` for per-message compression. It operates on complete messages rather than individual frames.
8
+
9
+ Clients and servers can negotiate a set of extensions to use. The server can accept or reject these extensions. The client can then instantiate the extensions and apply them to the connection. More specifically, clients need to define a set of extensions they want to support:
10
+
11
+ ~~~ ruby
12
+ require 'protocol/websocket'
13
+ require 'protocol/websocket/extensions'
14
+
15
+ client_extensions = Protocol::WebSocket::Extensions::Client.new([
16
+ [Protocol::WebSocket::Extension::Compression, {}]
17
+ ])
18
+
19
+ offer_headers = []
20
+
21
+ client_extensions.offer do |header|
22
+ offer_headers << header.join(';')
23
+ end
24
+
25
+ offer_headers # => ["permessage-deflate;client_max_window_bits"]
26
+ ~~~
27
+
28
+ This is transmitted to the server via the `Sec-WebSocket-Extensions` header. The server processes this and returns a subset of accepted extensions. The client receives a list of accepted extensions and instantiates them:
29
+
30
+ ~~~ ruby
31
+ server_extensions = Protocol::WebSocket::Extensions::Server.new([
32
+ [Protocol::WebSocket::Extension::Compression, {}]
33
+ ])
34
+
35
+ accepted_headers = []
36
+
37
+ server_extensions.accept(offer_headers) do |header|
38
+ accepted_headers << header.join(';')
39
+ end
40
+
41
+ accepted_headers # => ["permessage-deflate;client_max_window_bits=15"]
42
+
43
+ client_extensions.accept(accepted_headers)
44
+ ~~~
45
+
46
+ We can check the extensions are accepted:
47
+
48
+ ~~~ ruby
49
+ server_extensions.accepted
50
+ # => [[Protocol::WebSocket::Extension::Compression, {:client_max_window_bits=>15}]]
51
+
52
+ client_extensions.accepted
53
+ # => [[Protocol::WebSocket::Extension::Compression, {:client_max_window_bits=>15}]]
54
+ ~~~
55
+
56
+ Once the extensions are negotiated, they can be applied to the connection:
57
+
58
+ ~~~ ruby
59
+ require 'protocol/websocket/connection'
60
+ require 'socket'
61
+
62
+ sockets = Socket.pair(Socket::PF_UNIX, Socket::SOCK_STREAM)
63
+
64
+ client = Protocol::WebSocket::Connection.new(Protocol::WebSocket::Framer.new(sockets.first))
65
+ server = Protocol::WebSocket::Connection.new(Protocol::WebSocket::Framer.new(sockets.last))
66
+
67
+ client_extensions.apply(client)
68
+ server_extensions.apply(server)
69
+
70
+ # We can see that the appropriate wrappers have been added to the connections:
71
+ client.reader.class # => Protocol::WebSocket::Extension::Compression::Inflate
72
+ client.writer.class # => Protocol::WebSocket::Extension::Compression::Deflate
73
+ server.reader.class # => Protocol::WebSocket::Extension::Compression::Inflate
74
+ server.writer.class # => Protocol::WebSocket::Extension::Compression::Deflate
75
+
76
+ client.send_text("Hello World")
77
+ # => #<Protocol::WebSocket::TextFrame:0x000000011d555460 @finished=true, @flags=4, @length=13, @mask=nil, @opcode=1, @payload="\xF2H\xCD\xC9\xC9W\b\xCF/\xCAI\x01\x00">
78
+
79
+ server.read
80
+ # => #<Protocol::WebSocket::TextMessage:0x000000011e1e5248 @buffer="Hello World">
81
+ ~~~
82
+
83
+ It's possible to disable compression on a per-message basis:
84
+
85
+ ~~~ ruby
86
+ client.send_text("Hello World", compress: false)
87
+ # => #<Protocol::WebSocket::TextFrame:0x00000001028945b0 @finished=true, @flags=0, @length=11, @mask=nil, @opcode=1, @payload="Hello World">
88
+
89
+ server.read
90
+ # => #<Protocol::WebSocket::TextMessage:0x000000011e77eb50 @buffer="Hello World">
91
+ ~~~
@@ -0,0 +1,73 @@
1
+ # Getting Started
2
+
3
+ This guide explains how to use `protocol-websocket` for implementing a websocket client and server.
4
+
5
+ ## Installation
6
+
7
+ Add the gem to your project:
8
+
9
+ ~~~ bash
10
+ $ bundle add protocol-websocket
11
+ ~~~
12
+
13
+ ## Core Concepts
14
+
15
+ `protocol-websocket` has several core concepts:
16
+
17
+ - A {ruby Protocol::WebSocket::Frame} is the base class which is used to represent protocol-specific structured frames.
18
+ - A {ruby Protocol::WebSocket::Framer} wraps an underlying {ruby Async::IO::Stream} for reading and writing binary data into structured frames.
19
+ - A {ruby Protocol::WebSocket::Connection} wraps a framer and implements for implementing connection specific interactions like reading and writing text.
20
+ - A {ruby Protocol::WebSocket::Message} is a higher-level abstraction for reading and writing messages.
21
+
22
+ ## Bi-directional Communication
23
+
24
+ We can create a small bi-directional WebSocket client server:
25
+
26
+ ~~~ ruby
27
+ require 'protocol/websocket'
28
+ require 'protocol/websocket/connection'
29
+ require 'socket'
30
+
31
+ sockets = Socket.pair(Socket::PF_UNIX, Socket::SOCK_STREAM)
32
+
33
+ client = Protocol::WebSocket::Connection.new(Protocol::WebSocket::Framer.new(sockets.first))
34
+ server = Protocol::WebSocket::Connection.new(Protocol::WebSocket::Framer.new(sockets.last))
35
+
36
+ client.send_text("Hello World")
37
+ server.read
38
+ # #<Protocol::WebSocket::TextMessage:0x000000011d2338e0 @buffer="Hello World">
39
+
40
+ client.send_binary("Hello World")
41
+ server.read
42
+ #<Protocol::WebSocket::BinaryMessage:0x000000011d371db0 @buffer="Hello World">
43
+ ~~~
44
+
45
+ ## Messages
46
+
47
+ We can also use the {ruby Protocol::WebSocket::Message} class to read and write messages:
48
+
49
+ ~~~ ruby
50
+ require 'protocol/websocket'
51
+ require 'protocol/websocket/connection'
52
+ require 'socket'
53
+
54
+ sockets = Socket.pair(Socket::PF_UNIX, Socket::SOCK_STREAM)
55
+
56
+ client = Protocol::WebSocket::Connection.new(Protocol::WebSocket::Framer.new(sockets.first))
57
+ server = Protocol::WebSocket::Connection.new(Protocol::WebSocket::Framer.new(sockets.last))
58
+
59
+ # Encode a value using JSON:
60
+ message = Protocol::WebSocket::TextMessage.generate({hello: "world"})
61
+
62
+ client.write(message)
63
+ server.read.to_h
64
+ # {:hello=>"world"}
65
+ ~~~
66
+
67
+ ### Text Messages
68
+
69
+ Text messages contain UTF-8 encoded text. Invalid UTF-8 sequences will result in errors. Text messages are useful for sending structured data like JSON.
70
+
71
+ ### Binary Messages
72
+
73
+ Binary messages contain arbitrary binary data. They can be used to send any kind of data. Binary messages are useful for sending files or other binary data, like images or video.
@@ -0,0 +1,16 @@
1
+ # Automatically generated context index for Utopia::Project guides.
2
+ # Do not edit then files in this directory directly, instead edit the guides and then run `bake utopia:project:agent:context:update`.
3
+ ---
4
+ description: A low level implementation of the WebSocket protocol.
5
+ metadata:
6
+ documentation_uri: https://socketry.github.io/protocol-websocket/
7
+ source_code_uri: https://github.com/socketry/protocol-websocket.git
8
+ files:
9
+ - path: getting-started.md
10
+ title: Getting Started
11
+ description: This guide explains how to use `protocol-websocket` for implementing
12
+ a websocket client and server.
13
+ - path: extensions.md
14
+ title: Extensions
15
+ description: This guide explains how to use `protocol-websocket` for implementing
16
+ a websocket client and server using extensions.
@@ -10,6 +10,8 @@ module Protocol
10
10
  module Coder
11
11
  # A JSON coder that uses the standard JSON library.
12
12
  class JSON
13
+ # Initialize a new JSON coder.
14
+ # @parameter options [Hash] Options to pass to the JSON library when parsing or generating.
13
15
  def initialize(**options)
14
16
  @options = options
15
17
  end
@@ -7,6 +7,7 @@ require_relative "coder/json"
7
7
 
8
8
  module Protocol
9
9
  module WebSocket
10
+ # @namespace
10
11
  module Coder
11
12
  # The default coder for WebSocket messages.
12
13
  DEFAULT = JSON::DEFAULT
@@ -281,7 +281,11 @@ module Protocol
281
281
 
282
282
  # The default implementation for reading a message buffer. This is used by the {#reader} interface.
283
283
  def unpack_frames(frames)
284
- frames.map(&:unpack).join("")
284
+ if frames.size == 1
285
+ frames[0].unpack
286
+ else
287
+ frames.map(&:unpack).join("")
288
+ end
285
289
  end
286
290
 
287
291
  # Read a message from the connection. If an error occurs while reading the message, the connection will be closed.
@@ -41,6 +41,9 @@ module Protocol
41
41
 
42
42
  # Raised by stream or connection handlers, results in GOAWAY frame which signals termination of the current connection. You *cannot* recover from this exception, or any exceptions subclassed from it.
43
43
  class ProtocolError < Error
44
+ # Initialize a protocol error with an optional status code.
45
+ # @parameter message [String] The error message.
46
+ # @parameter code [Integer] The WebSocket status code associated with the error.
44
47
  def initialize(message, code = PROTOCOL_ERROR)
45
48
  super(message)
46
49
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2022-2024, by Samuel Williams.
4
+ # Copyright, 2022-2026, by Samuel Williams.
5
5
 
6
6
  require "zlib"
7
7
 
@@ -10,7 +10,7 @@ module Protocol
10
10
  module Extension
11
11
  module Compression
12
12
  NAME = "permessage-deflate"
13
-
13
+
14
14
  # Zlib is not capable of handling < 9 window bits.
15
15
  MINIMUM_WINDOW_BITS = 9
16
16
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2022-2024, by Samuel Williams.
4
+ # Copyright, 2022-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "constants"
7
7
 
@@ -9,6 +9,7 @@ module Protocol
9
9
  module WebSocket
10
10
  module Extension
11
11
  module Compression
12
+ # Compresses outgoing WebSocket frames using the DEFLATE algorithm.
12
13
  class Deflate
13
14
  # Client writing to server.
14
15
  def self.client(parent, client_max_window_bits: 15, client_no_context_takeover: false, **options)
@@ -28,6 +29,13 @@ module Protocol
28
29
  )
29
30
  end
30
31
 
32
+ # Initialize a new deflate compressor.
33
+ # @parameter parent [Object] The parent framer to wrap.
34
+ # @parameter level [Integer] The compression level. Defaults to `Zlib::DEFAULT_COMPRESSION`.
35
+ # @parameter memory_level [Integer] The memory level for compression. Defaults to `Zlib::DEF_MEM_LEVEL`.
36
+ # @parameter strategy [Integer] The compression strategy. Defaults to `Zlib::DEFAULT_STRATEGY`.
37
+ # @parameter window_bits [Integer] The window size in bits for the DEFLATE algorithm.
38
+ # @parameter context_takeover [Boolean] Whether to reuse the compression context across messages.
31
39
  def initialize(parent, level: Zlib::DEFAULT_COMPRESSION, memory_level: Zlib::DEF_MEM_LEVEL, strategy: Zlib::DEFAULT_STRATEGY, window_bits: 15, context_takeover: true, **options)
32
40
  @parent = parent
33
41
 
@@ -36,7 +44,7 @@ module Protocol
36
44
  @level = level
37
45
  @memory_level = memory_level
38
46
  @strategy = strategy
39
-
47
+
40
48
  # This is handled during negotiation:
41
49
  # if window_bits < MINIMUM_WINDOW_BITS
42
50
  # window_bits = MINIMUM_WINDOW_BITS
@@ -46,13 +54,20 @@ module Protocol
46
54
  @context_takeover = context_takeover
47
55
  end
48
56
 
57
+ # @returns [String] A string representation including window bits and context takeover settings.
49
58
  def to_s
50
59
  "#<#{self.class} window_bits=#{@window_bits} context_takeover=#{@context_takeover}>"
51
60
  end
52
61
 
62
+ # @attribute [Integer] The window size in bits used for compression.
53
63
  attr :window_bits
64
+ # @attribute [Boolean] Whether the compression context is reused across messages.
54
65
  attr :context_takeover
55
66
 
67
+ # Pack a text frame, optionally compressing the buffer.
68
+ # @parameter buffer [String] The text payload to pack.
69
+ # @parameter compress [Boolean] Whether to compress the buffer. Defaults to `true`.
70
+ # @returns [Frame] The packed (and optionally compressed) text frame.
56
71
  def pack_text_frame(buffer, compress: true, **options)
57
72
  if compress
58
73
  buffer = self.deflate(buffer)
@@ -67,6 +82,10 @@ module Protocol
67
82
  return frame
68
83
  end
69
84
 
85
+ # Pack a binary frame, optionally compressing the buffer.
86
+ # @parameter buffer [String] The binary payload to pack.
87
+ # @parameter compress [Boolean] Whether to compress the buffer. Defaults to `false`.
88
+ # @returns [Frame] The packed (and optionally compressed) binary frame.
70
89
  def pack_binary_frame(buffer, compress: false, **options)
71
90
  if compress
72
91
  buffer = self.deflate(buffer)
@@ -9,6 +9,7 @@ module Protocol
9
9
  module WebSocket
10
10
  module Extension
11
11
  module Compression
12
+ # Decompresses incoming WebSocket frames using the DEFLATE algorithm.
12
13
  class Inflate
13
14
  # Client reading from server.
14
15
  def self.client(parent, server_max_window_bits: 15, server_no_context_takeover: false, **options)
@@ -28,6 +29,10 @@ module Protocol
28
29
 
29
30
  TRAILER = [0x00, 0x00, 0xff, 0xff].pack("C*")
30
31
 
32
+ # Initialize a new inflate decompressor.
33
+ # @parameter parent [Object] The parent framer to wrap.
34
+ # @parameter context_takeover [Boolean] Whether to reuse the decompression context across messages.
35
+ # @parameter window_bits [Integer] The window size in bits for the DEFLATE algorithm.
31
36
  def initialize(parent, context_takeover: true, window_bits: 15)
32
37
  @parent = parent
33
38
 
@@ -42,13 +47,19 @@ module Protocol
42
47
  @context_takeover = context_takeover
43
48
  end
44
49
 
50
+ # @returns [String] A string representation including window bits and context takeover settings.
45
51
  def to_s
46
52
  "#<#{self.class} window_bits=#{@window_bits} context_takeover=#{@context_takeover}>"
47
53
  end
48
54
 
55
+ # @attribute [Integer] The window size in bits used for decompression.
49
56
  attr :window_bits
57
+ # @attribute [Boolean] Whether the decompression context is reused across messages.
50
58
  attr :context_takeover
51
59
 
60
+ # Unpack and decompress frames into a buffer.
61
+ # @parameter frames [Array(Frame)] The frames to unpack.
62
+ # @returns [String] The decompressed payload buffer.
52
63
  def unpack_frames(frames, **options)
53
64
  buffer = @parent.unpack_frames(frames, **options)
54
65
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2022-2024, by Samuel Williams.
4
+ # Copyright, 2022-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "compression/constants"
7
7
  require_relative "compression/inflate"
@@ -9,6 +9,7 @@ require_relative "compression/deflate"
9
9
 
10
10
  module Protocol
11
11
  module WebSocket
12
+ # @namespace
12
13
  module Extension
13
14
  # Provides support for the permessage-deflate extension.
14
15
  module Compression
@@ -46,7 +47,7 @@ module Protocol
46
47
 
47
48
  return header
48
49
  end
49
-
50
+
50
51
  # Negotiate on the server a response to client based on the incoming client offer.
51
52
  # @parameter options [Hash] a hash of options which are accepted by the server.
52
53
  # @returns [Array(String)] a list of compression parameters suitable to send back to the client.
@@ -8,7 +8,13 @@ require_relative "headers"
8
8
 
9
9
  module Protocol
10
10
  module WebSocket
11
+ # Manages WebSocket extensions negotiated during the handshake.
11
12
  module Extensions
13
+ # Parse a list of extension header values into name and argument pairs.
14
+ # @parameter headers [Array(String)] The raw extension header values.
15
+ # @yields {|name, arguments| ...} Each parsed extension.
16
+ # @parameter name [String] The name of the extension.
17
+ # @parameter arguments [Array] The key-value argument pairs.
12
18
  def self.parse(headers)
13
19
  return to_enum(:parse, headers) unless block_given?
14
20
 
@@ -23,27 +29,39 @@ module Protocol
23
29
  end
24
30
  end
25
31
 
32
+ # Manages extensions on the client side, offering and accepting server responses.
26
33
  class Client
34
+ # Create a default client with permessage-deflate compression enabled.
35
+ # @returns [Client] A new client with the default compression extension.
27
36
  def self.default
28
37
  self.new([
29
38
  [Extension::Compression, {}]
30
39
  ])
31
40
  end
32
41
 
42
+ # Initialize a new client extension manager.
43
+ # @parameter extensions [Array] The list of extensions to offer, each as `[klass, options]`.
33
44
  def initialize(extensions = [])
34
45
  @extensions = extensions
35
46
  @accepted = []
36
47
  end
37
48
 
49
+ # @attribute [Array] The list of extensions to offer.
38
50
  attr :extensions
51
+ # @attribute [Array] The extensions accepted after negotiation.
39
52
  attr :accepted
40
53
 
54
+ # Build a lookup table of extensions keyed by their name.
55
+ # @returns [Hash] A hash mapping extension names to their `[klass, options]` pairs.
41
56
  def named
42
57
  @extensions.map do |extension|
43
58
  [extension.first::NAME, extension]
44
59
  end.to_h
45
60
  end
46
61
 
62
+ # Yield extension offer headers for each registered extension.
63
+ # @yields {|header| ...} Each offer header string.
64
+ # @parameter header [Array(String)] The extension offer header tokens.
47
65
  def offer
48
66
  @extensions.each do |extension, options|
49
67
  if header = extension.offer(**options)
@@ -52,6 +70,9 @@ module Protocol
52
70
  end
53
71
  end
54
72
 
73
+ # Accept server extension responses and record the negotiated extensions.
74
+ # @parameter headers [Array(String)] The `Sec-WebSocket-Extensions` response header values.
75
+ # @returns [Array] The accepted extensions as `[klass, options]` pairs.
55
76
  def accept(headers)
56
77
  named = self.named
57
78
 
@@ -69,6 +90,8 @@ module Protocol
69
90
  return @accepted
70
91
  end
71
92
 
93
+ # Apply all accepted extensions to the given connection as a client.
94
+ # @parameter connection [Connection] The WebSocket connection to configure.
72
95
  def apply(connection)
73
96
  @accepted.each do |(klass, options)|
74
97
  klass.client(connection, **options)
@@ -76,27 +99,41 @@ module Protocol
76
99
  end
77
100
  end
78
101
 
102
+ # Manages extensions on the server side, negotiating client offers and applying the agreed extensions.
79
103
  class Server
104
+ # Create a default server with permessage-deflate compression enabled.
105
+ # @returns [Server] A new server with the default compression extension.
80
106
  def self.default
81
107
  self.new([
82
108
  [Extension::Compression, {}]
83
109
  ])
84
110
  end
85
111
 
112
+ # Initialize a new server extension manager.
113
+ # @parameter extensions [Array] The list of supported extensions, each as `[klass, options]`.
86
114
  def initialize(extensions)
87
115
  @extensions = extensions
88
116
  @accepted = []
89
117
  end
90
118
 
119
+ # @attribute [Array] The list of supported extensions.
91
120
  attr :extensions
121
+ # @attribute [Array] The extensions accepted after negotiation.
92
122
  attr :accepted
93
123
 
124
+ # Build a lookup table of extensions keyed by their name.
125
+ # @returns [Hash] A hash mapping extension names to their `[klass, options]` pairs.
94
126
  def named
95
127
  @extensions.map do |extension|
96
128
  [extension.first::NAME, extension]
97
129
  end.to_h
98
130
  end
99
131
 
132
+ # Negotiate client extension offers and yield accepted response headers.
133
+ # @parameter headers [Array(String)] The `Sec-WebSocket-Extensions` request header values.
134
+ # @yields {|header| ...} Each accepted extension header to include in the response.
135
+ # @parameter header [Array(String)] The negotiated extension header tokens.
136
+ # @returns [Array] The accepted extensions as `[klass, options]` pairs.
100
137
  def accept(headers)
101
138
  extensions = []
102
139
 
@@ -124,6 +161,8 @@ module Protocol
124
161
  return @accepted
125
162
  end
126
163
 
164
+ # Apply all accepted extensions to the given connection as a server.
165
+ # @parameter connection [Connection] The WebSocket connection to configure.
127
166
  def apply(connection)
128
167
  @accepted.reverse_each do |(klass, options)|
129
168
  klass.server(connection, **options)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2025, by Samuel Williams.
4
+ # Copyright, 2019-2026, by Samuel Williams.
5
5
  # Copyright, 2019, by Soumya.
6
6
  # Copyright, 2021, by Aurora Nockert.
7
7
  # Copyright, 2025, by Taleh Zaliyev.
@@ -10,6 +10,7 @@ require_relative "error"
10
10
 
11
11
  module Protocol
12
12
  module WebSocket
13
+ # Represents a single WebSocket frame as defined by RFC 6455.
13
14
  class Frame
14
15
  include Comparable
15
16
 
@@ -19,8 +20,7 @@ module Protocol
19
20
  RESERVED = RSV1 | RSV2 | RSV3
20
21
 
21
22
  OPCODE = 0
22
-
23
- # @parameter length [Integer] The length of the payload, or nil if the header has not been read yet.
23
+
24
24
  # @parameter mask [Boolean | String] An optional 4-byte string which is used to mask the payload.
25
25
  def initialize(finished = true, payload = nil, flags: 0, opcode: self.class::OPCODE, mask: false)
26
26
  if mask == true
@@ -31,22 +31,31 @@ module Protocol
31
31
  @flags = flags
32
32
  @opcode = opcode
33
33
  @mask = mask
34
- @length = payload&.bytesize
35
34
  @payload = payload
36
35
  end
37
36
 
37
+ # Check whether the specified RSV flag bit is set on this frame.
38
+ # @parameter value [Integer] The flag bitmask to test (e.g. `RSV1`).
39
+ # @returns [Boolean] `true` if the flag bit is set.
38
40
  def flag?(value)
39
41
  @flags & value != 0
40
42
  end
41
43
 
44
+ # Compare this frame to another frame by their array representation.
45
+ # @parameter other [Frame] The frame to compare against.
46
+ # @returns [Integer] A comparison result (-1, 0, or 1).
42
47
  def <=> other
43
48
  to_ary <=> other.to_ary
44
49
  end
45
50
 
51
+ # Convert this frame to an array of its fields for comparison or inspection.
52
+ # @returns [Array] An array of `[finished, flags, opcode, mask, payload]`.
46
53
  def to_ary
47
- [@finished, @flags, @opcode, @mask, @length, @payload]
54
+ [@finished, @flags, @opcode, @mask, @payload]
48
55
  end
49
56
 
57
+ # Check whether this is a control frame (opcode has bit 3 set).
58
+ # @returns [Boolean] `true` if this is a control frame.
50
59
  def control?
51
60
  @opcode & 0x8 != 0
52
61
  end
@@ -56,10 +65,14 @@ module Protocol
56
65
  false
57
66
  end
58
67
 
68
+ # Check whether this is the final frame in a message.
69
+ # @returns [Boolean] `true` if the FIN bit is set.
59
70
  def finished?
60
71
  @finished == true
61
72
  end
62
73
 
74
+ # Check whether this frame is a continuation fragment (FIN bit not set).
75
+ # @returns [Boolean] `true` if the FIN bit is not set.
63
76
  def continued?
64
77
  @finished == false
65
78
  end
@@ -89,9 +102,14 @@ module Protocol
89
102
  attr_accessor :flags
90
103
  attr_accessor :opcode
91
104
  attr_accessor :mask
92
- attr_accessor :length
93
105
  attr_accessor :payload
94
106
 
107
+ # The byte length of the payload.
108
+ # @returns [Integer | nil]
109
+ def length
110
+ @payload&.bytesize
111
+ end
112
+
95
113
  if IO.const_defined?(:Buffer) && IO::Buffer.respond_to?(:for) && IO::Buffer.method_defined?(:xor!)
96
114
  private def mask_xor(data, mask)
97
115
  buffer = data.dup
@@ -107,7 +125,7 @@ module Protocol
107
125
  warn "IO::Buffer not available, falling back to slow implementation of mask_xor!"
108
126
  private def mask_xor(data, mask)
109
127
  result = String.new(encoding: Encoding::BINARY)
110
-
128
+
111
129
  for i in 0...data.bytesize do
112
130
  result << (data.getbyte(i) ^ mask.getbyte(i % 4))
113
131
  end
@@ -116,24 +134,25 @@ module Protocol
116
134
  end
117
135
  end
118
136
 
137
+ # Pack the given data into this frame's payload, applying masking if configured.
138
+ # @parameter data [String] The payload data to pack.
139
+ # @returns [Frame] Returns `self`.
119
140
  def pack(data = "")
120
- length = data.bytesize
121
-
122
- if length.bit_length > 63
123
- raise ProtocolError, "Frame length #{@length} bigger than allowed maximum!"
141
+ if data.bytesize.bit_length > 63
142
+ raise ProtocolError, "Frame length #{data.bytesize} bigger than allowed maximum!"
124
143
  end
125
144
 
126
145
  if @mask
127
- @payload = mask_xor(data, mask)
128
- @length = length
146
+ @payload = mask_xor(data, @mask)
129
147
  else
130
148
  @payload = data
131
- @length = length
132
149
  end
133
150
 
134
151
  return self
135
152
  end
136
153
 
154
+ # Unpack the raw payload, removing masking if present.
155
+ # @returns [String] The unmasked payload data.
137
156
  def unpack
138
157
  if @mask and !@payload.empty?
139
158
  return mask_xor(@payload, @mask)
@@ -142,101 +161,11 @@ module Protocol
142
161
  end
143
162
  end
144
163
 
164
+ # Apply this frame to the connection by dispatching it to the appropriate handler.
165
+ # @parameter connection [Connection] The WebSocket connection to receive this frame.
145
166
  def apply(connection)
146
167
  connection.receive_frame(self)
147
168
  end
148
-
149
- def self.parse_header(buffer)
150
- byte = buffer.unpack("C").first
151
-
152
- finished = (byte & 0b1000_0000 != 0)
153
- flags = (byte & 0b0111_0000) >> 4
154
- opcode = byte & 0b0000_1111
155
-
156
- if (0x3 .. 0x7).include?(opcode)
157
- raise ProtocolError, "Non-control opcode = #{opcode} is reserved!"
158
- elsif (0xB .. 0xF).include?(opcode)
159
- raise ProtocolError, "Control opcode = #{opcode} is reserved!"
160
- end
161
-
162
- return finished, flags, opcode
163
- end
164
-
165
- def self.read(finished, flags, opcode, stream, maximum_frame_size)
166
- buffer = stream.read(1) or raise EOFError, "Could not read header!"
167
- byte = buffer.unpack("C").first
168
-
169
- mask = (byte & 0b1000_0000 != 0)
170
- length = byte & 0b0111_1111
171
-
172
- if opcode & 0x8 != 0
173
- if length > 125
174
- raise ProtocolError, "Invalid control frame payload length: #{length} > 125!"
175
- elsif !finished
176
- raise ProtocolError, "Fragmented control frame!"
177
- end
178
- end
179
-
180
- if length == 126
181
- buffer = stream.read(2) or raise EOFError, "Could not read length!"
182
- length = buffer.unpack("n").first
183
- elsif length == 127
184
- buffer = stream.read(8) or raise EOFError, "Could not read length!"
185
- length = buffer.unpack("Q>").first
186
- end
187
-
188
- if length > maximum_frame_size
189
- raise ProtocolError, "Invalid payload length: #{length} > #{maximum_frame_size}!"
190
- end
191
-
192
- if mask
193
- mask = stream.read(4) or raise EOFError, "Could not read mask!"
194
- end
195
-
196
- payload = stream.read(length) or raise EOFError, "Could not read payload!"
197
-
198
- if payload.bytesize != length
199
- raise EOFError, "Incorrect payload length: #{length} != #{payload.bytesize}!"
200
- end
201
-
202
- return self.new(finished, payload, flags: flags, opcode: opcode, mask: mask)
203
- end
204
-
205
- def write(stream)
206
- buffer = String.new(encoding: Encoding::BINARY)
207
-
208
- if @payload&.bytesize != @length
209
- raise ProtocolError, "Invalid payload length: #{@length} != #{@payload.bytesize} for #{self}!"
210
- end
211
-
212
- if @mask and @mask.bytesize != 4
213
- raise ProtocolError, "Invalid mask length!"
214
- end
215
-
216
- if length <= 125
217
- short_length = length
218
- elsif length.bit_length <= 16
219
- short_length = 126
220
- else
221
- short_length = 127
222
- end
223
-
224
- buffer << [
225
- (@finished ? 0b1000_0000 : 0) | (@flags << 4) | @opcode,
226
- (@mask ? 0b1000_0000 : 0) | short_length,
227
- ].pack("CC")
228
-
229
- if short_length == 126
230
- buffer << [@length].pack("n")
231
- elsif short_length == 127
232
- buffer << [@length].pack("Q>")
233
- end
234
-
235
- buffer << @mask if @mask
236
-
237
- stream.write(buffer)
238
- stream.write(@payload)
239
- end
240
169
  end
241
170
  end
242
171
  end
@@ -29,6 +29,9 @@ module Protocol
29
29
 
30
30
  # Wraps an underlying {Async::IO::Stream} for reading and writing binary data into structured frames.
31
31
  class Framer
32
+ # Initialize a new framer wrapping the given stream.
33
+ # @parameter stream [IO] The underlying stream to read from and write to.
34
+ # @parameter frames [Hash] A mapping of opcodes to frame classes.
32
35
  def initialize(stream, frames = FRAMES)
33
36
  @stream = stream
34
37
  @frames = frames
@@ -47,28 +50,105 @@ module Protocol
47
50
  # Read a frame from the underlying stream.
48
51
  # @returns [Frame] the frame read from the stream.
49
52
  def read_frame(maximum_frame_size = MAXIMUM_ALLOWED_FRAME_SIZE)
50
- # Read the header:
51
- finished, flags, opcode = read_header
53
+ buffer = @stream.read(2)
52
54
 
53
- # Read the frame:
54
- klass = @frames[opcode] || Frame
55
- frame = klass.read(finished, flags, opcode, @stream, maximum_frame_size)
55
+ unless buffer and buffer.bytesize == 2
56
+ raise EOFError, "Could not read frame header!"
57
+ end
58
+
59
+ first_byte = buffer.getbyte(0)
60
+ second_byte = buffer.getbyte(1)
61
+
62
+ finished = (first_byte & 0b1000_0000 != 0)
63
+ flags = (first_byte & 0b0111_0000) >> 4
64
+ opcode = first_byte & 0b0000_1111
65
+
66
+ if opcode >= 0x3 && opcode <= 0x7
67
+ raise ProtocolError, "Non-control opcode = #{opcode} is reserved!"
68
+ elsif opcode >= 0xB
69
+ raise ProtocolError, "Control opcode = #{opcode} is reserved!"
70
+ end
71
+
72
+ mask = (second_byte & 0b1000_0000 != 0)
73
+ length = second_byte & 0b0111_1111
74
+
75
+ if opcode & 0x8 != 0
76
+ if length > 125
77
+ raise ProtocolError, "Invalid control frame payload length: #{length} > 125!"
78
+ elsif !finished
79
+ raise ProtocolError, "Fragmented control frame!"
80
+ end
81
+ end
82
+
83
+ if length == 126
84
+ if mask
85
+ buffer = @stream.read(6) or raise EOFError, "Could not read length and mask!"
86
+ length = buffer.unpack1("n")
87
+ mask = buffer.byteslice(2, 4)
88
+ else
89
+ buffer = @stream.read(2) or raise EOFError, "Could not read length!"
90
+ length = buffer.unpack1("n")
91
+ end
92
+ elsif length == 127
93
+ if mask
94
+ buffer = @stream.read(12) or raise EOFError, "Could not read length and mask!"
95
+ length = buffer.unpack1("Q>")
96
+ mask = buffer.byteslice(8, 4)
97
+ else
98
+ buffer = @stream.read(8) or raise EOFError, "Could not read length!"
99
+ length = buffer.unpack1("Q>")
100
+ end
101
+ elsif mask
102
+ mask = @stream.read(4) or raise EOFError, "Could not read mask!"
103
+ end
104
+
105
+ if length > maximum_frame_size
106
+ raise ProtocolError, "Invalid payload length: #{length} > #{maximum_frame_size}!"
107
+ end
56
108
 
57
- return frame
109
+ payload = @stream.read(length) or raise EOFError, "Could not read payload!"
110
+
111
+ if payload.bytesize != length
112
+ raise EOFError, "Incorrect payload length: #{length} != #{payload.bytesize}!"
113
+ end
114
+
115
+ klass = @frames[opcode] || Frame
116
+ return klass.new(finished, payload, flags: flags, opcode: opcode, mask: mask)
58
117
  end
59
118
 
60
119
  # Write a frame to the underlying stream.
120
+ # @parameter frame [Frame] The frame to serialize and write.
121
+ # @raises [ProtocolError] If the frame has an invalid mask.
61
122
  def write_frame(frame)
62
- frame.write(@stream)
63
- end
64
-
65
- # Read the header of the frame.
66
- def read_header
67
- if buffer = @stream.read(1) and buffer.bytesize == 1
68
- return Frame.parse_header(buffer)
123
+ if frame.mask and frame.mask.bytesize != 4
124
+ raise ProtocolError, "Invalid mask length!"
69
125
  end
70
126
 
71
- raise EOFError, "Could not read frame header!"
127
+ length = frame.length
128
+
129
+ if length <= 125
130
+ short_length = length
131
+ elsif length.bit_length <= 16
132
+ short_length = 126
133
+ else
134
+ short_length = 127
135
+ end
136
+
137
+ buffer = [
138
+ (frame.finished ? 0b1000_0000 : 0) | (frame.flags << 4) | frame.opcode,
139
+ (frame.mask ? 0b1000_0000 : 0) | short_length,
140
+ ].pack("CC")
141
+
142
+ if short_length == 126
143
+ buffer << [length].pack("n")
144
+ elsif short_length == 127
145
+ buffer << [length].pack("Q>")
146
+ end
147
+
148
+ buffer << frame.mask if frame.mask
149
+
150
+ @stream.write(buffer)
151
+ @stream.write(frame.payload)
72
152
  end
73
153
  end
74
154
  end
@@ -8,6 +8,7 @@ require "securerandom"
8
8
 
9
9
  module Protocol
10
10
  module WebSocket
11
+ # @namespace
11
12
  module Headers
12
13
  # The protocol string used for the `upgrade:` header (HTTP/1) and `:protocol` pseudo-header (HTTP/2).
13
14
  PROTOCOL = "websocket"
@@ -23,6 +24,7 @@ module Protocol
23
24
 
24
25
  SEC_WEBSOCKET_EXTENSIONS = "sec-websocket-extensions"
25
26
 
27
+ # Provides utilities for generating and verifying the WebSocket handshake nonce.
26
28
  module Nounce
27
29
  GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
28
30
 
@@ -57,6 +57,8 @@ module Protocol
57
57
  parse(...).to_h
58
58
  end
59
59
 
60
+ # Send this message as a text frame over the given connection.
61
+ # @parameter connection [Connection] The WebSocket connection to send through.
60
62
  def send(connection, **options)
61
63
  connection.send_text(@buffer, **options)
62
64
  end
@@ -68,6 +70,8 @@ module Protocol
68
70
 
69
71
  # Represents a binary message that can be sent or received over a WebSocket connection.
70
72
  class BinaryMessage < Message
73
+ # Send this message as a binary frame over the given connection.
74
+ # @parameter connection [Connection] The WebSocket connection to send through.
71
75
  def send(connection, **options)
72
76
  connection.send_binary(@buffer, **options)
73
77
  end
@@ -75,6 +79,8 @@ module Protocol
75
79
 
76
80
  # Represents a ping message that can be sent over a WebSocket connection.
77
81
  class PingMessage < Message
82
+ # Send this message as a ping frame over the given connection.
83
+ # @parameter connection [Connection] The WebSocket connection to send through.
78
84
  def send(connection)
79
85
  connection.send_ping(@buffer)
80
86
  end
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2019-2024, by Samuel Williams.
4
+ # Copyright, 2019-2026, by Samuel Williams.
5
5
 
6
+ # @namespace
6
7
  module Protocol
8
+ # @namespace
7
9
  module WebSocket
8
- VERSION = "0.20.2"
10
+ VERSION = "0.21.0"
9
11
  end
10
12
  end
data/license.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # MIT License
2
2
 
3
- Copyright, 2019-2025, by Samuel Williams.
3
+ Copyright, 2019-2026, by Samuel Williams.
4
4
  Copyright, 2019, by Soumya.
5
5
  Copyright, 2019, by William T. Nelson.
6
6
  Copyright, 2020, by Olle Jonsson.
data/readme.md CHANGED
@@ -12,6 +12,52 @@ Please see the [project documentation](https://socketry.github.io/protocol-webso
12
12
 
13
13
  - [Extensions](https://socketry.github.io/protocol-websocket/guides/extensions/index) - This guide explains how to use `protocol-websocket` for implementing a websocket client and server using extensions.
14
14
 
15
+ ## Releases
16
+
17
+ Please see the [project releases](https://socketry.github.io/protocol-websocket/releases/index) for all releases.
18
+
19
+ ### v0.21.0
20
+
21
+ - All frame reading and writing logic has been consolidated into `Framer` to improve performance.
22
+
23
+ ### v0.20.2
24
+
25
+ - Fix error messages for `Frame` to be more descriptive.
26
+
27
+ ### v0.20.1
28
+
29
+ - Revert masking enforcement option introduced in v0.20.0 due to compatibility issues.
30
+
31
+ ### v0.20.0
32
+
33
+ - Introduce option `requires_masking` to `Framer` for enforcing masking on received frames.
34
+
35
+ ### v0.19.1
36
+
37
+ - Ensure ping reply payload is packed correctly.
38
+
39
+ ### v0.19.0
40
+
41
+ - Default to empty string for message buffer when no data is provided.
42
+
43
+ ### v0.18.0
44
+
45
+ - Add `PingMessage` alongside `TextMessage` and `BinaryMessage` for a consistent message interface.
46
+ - Remove `JSONMessage` (use application-level encoding instead).
47
+
48
+ ### v0.17.0
49
+
50
+ - Introduce `#close_write` and `#shutdown` methods on `Connection` for more precise connection lifecycle control.
51
+
52
+ ### v0.16.0
53
+
54
+ - Move `#send` logic into `Message` for better encapsulation.
55
+ - Improve error handling when a `nil` message is passed.
56
+
57
+ ### v0.15.0
58
+
59
+ - Require `Message` class by default.
60
+
15
61
  ## Contributing
16
62
 
17
63
  We welcome contributions to this project.
@@ -22,6 +68,22 @@ We welcome contributions to this project.
22
68
  4. Push to the branch (`git push origin my-new-feature`).
23
69
  5. Create new Pull Request.
24
70
 
71
+ ### Running Tests
72
+
73
+ To run the test suite:
74
+
75
+ ``` shell
76
+ bundle exec sus
77
+ ```
78
+
79
+ ### Making Releases
80
+
81
+ To make a new release:
82
+
83
+ ``` shell
84
+ bundle exec bake gem:release:patch # or minor or major
85
+ ```
86
+
25
87
  ### Developer Certificate of Origin
26
88
 
27
89
  In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed.
data/releases.md ADDED
@@ -0,0 +1,43 @@
1
+ # Releases
2
+
3
+ ## v0.21.0
4
+
5
+ - All frame reading and writing logic has been consolidated into `Framer` to improve performance.
6
+
7
+ ## v0.20.2
8
+
9
+ - Fix error messages for `Frame` to be more descriptive.
10
+
11
+ ## v0.20.1
12
+
13
+ - Revert masking enforcement option introduced in v0.20.0 due to compatibility issues.
14
+
15
+ ## v0.20.0
16
+
17
+ - Introduce option `requires_masking` to `Framer` for enforcing masking on received frames.
18
+
19
+ ## v0.19.1
20
+
21
+ - Ensure ping reply payload is packed correctly.
22
+
23
+ ## v0.19.0
24
+
25
+ - Default to empty string for message buffer when no data is provided.
26
+
27
+ ## v0.18.0
28
+
29
+ - Add `PingMessage` alongside `TextMessage` and `BinaryMessage` for a consistent message interface.
30
+ - Remove `JSONMessage` (use application-level encoding instead).
31
+
32
+ ## v0.17.0
33
+
34
+ - Introduce `#close_write` and `#shutdown` methods on `Connection` for more precise connection lifecycle control.
35
+
36
+ ## v0.16.0
37
+
38
+ - Move `#send` logic into `Message` for better encapsulation.
39
+ - Improve error handling when a `nil` message is passed.
40
+
41
+ ## v0.15.0
42
+
43
+ - Require `Message` class by default.
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: protocol-websocket
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.20.2
4
+ version: 0.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -41,7 +41,7 @@ cert_chain:
41
41
  Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8
42
42
  voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg=
43
43
  -----END CERTIFICATE-----
44
- date: 2025-04-12 00:00:00.000000000 Z
44
+ date: 1980-01-02 00:00:00.000000000 Z
45
45
  dependencies:
46
46
  - !ruby/object:Gem::Dependency
47
47
  name: protocol-http
@@ -61,6 +61,9 @@ executables: []
61
61
  extensions: []
62
62
  extra_rdoc_files: []
63
63
  files:
64
+ - context/extensions.md
65
+ - context/getting-started.md
66
+ - context/index.yaml
64
67
  - lib/protocol/websocket.rb
65
68
  - lib/protocol/websocket/binary_frame.rb
66
69
  - lib/protocol/websocket/close_frame.rb
@@ -84,6 +87,7 @@ files:
84
87
  - lib/protocol/websocket/version.rb
85
88
  - license.md
86
89
  - readme.md
90
+ - releases.md
87
91
  homepage: https://github.com/socketry/protocol-websocket
88
92
  licenses:
89
93
  - MIT
@@ -97,14 +101,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
97
101
  requirements:
98
102
  - - ">="
99
103
  - !ruby/object:Gem::Version
100
- version: '3.1'
104
+ version: '3.3'
101
105
  required_rubygems_version: !ruby/object:Gem::Requirement
102
106
  requirements:
103
107
  - - ">="
104
108
  - !ruby/object:Gem::Version
105
109
  version: '0'
106
110
  requirements: []
107
- rubygems_version: 3.6.2
111
+ rubygems_version: 4.0.6
108
112
  specification_version: 4
109
113
  summary: A low level implementation of the WebSocket protocol.
110
114
  test_files: []
metadata.gz.sig CHANGED
Binary file