protocol-websocket 0.20.1 → 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: ff918f001efdfca622476924df547f137959c0b42c9eb489a6d3252d4e2f111f
4
- data.tar.gz: 81a99be831aee6267cf3c2291b945b08a2c4055604ed3a1bab61ddf9b2963c6a
3
+ metadata.gz: 90750bdfa4f0cafe292f5b7faf67cecc3ad8dd599ef30e6be28bf6594405f424
4
+ data.tar.gz: 16d01a6e7e451a9444698cc852f937f1f50b9898bdb072d90cf3c93be2ce8b95
5
5
  SHA512:
6
- metadata.gz: 6a71e45dc30989dcb02c8c3520e827865c7131a7eda8e8c08f0e86b6331c5730017725a4db1abc7f38643342ceeb1aeb906052b98e937ba36dad5c4abc27f8df
7
- data.tar.gz: 45309a8c2f2d7d98f49379e7899d01261d90bc7b08a84683b6c3c646570239213a6294149b06a228fd0c2c2b15ffa682b1ac26a45fedd65460b8b1c3ac137a27
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,14 +1,16 @@
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
  # Copyright, 2019, by Soumya.
6
6
  # Copyright, 2021, by Aurora Nockert.
7
+ # Copyright, 2025, by Taleh Zaliyev.
7
8
 
8
9
  require_relative "error"
9
10
 
10
11
  module Protocol
11
12
  module WebSocket
13
+ # Represents a single WebSocket frame as defined by RFC 6455.
12
14
  class Frame
13
15
  include Comparable
14
16
 
@@ -18,8 +20,7 @@ module Protocol
18
20
  RESERVED = RSV1 | RSV2 | RSV3
19
21
 
20
22
  OPCODE = 0
21
-
22
- # @parameter length [Integer] The length of the payload, or nil if the header has not been read yet.
23
+
23
24
  # @parameter mask [Boolean | String] An optional 4-byte string which is used to mask the payload.
24
25
  def initialize(finished = true, payload = nil, flags: 0, opcode: self.class::OPCODE, mask: false)
25
26
  if mask == true
@@ -30,22 +31,31 @@ module Protocol
30
31
  @flags = flags
31
32
  @opcode = opcode
32
33
  @mask = mask
33
- @length = payload&.bytesize
34
34
  @payload = payload
35
35
  end
36
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.
37
40
  def flag?(value)
38
41
  @flags & value != 0
39
42
  end
40
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).
41
47
  def <=> other
42
48
  to_ary <=> other.to_ary
43
49
  end
44
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]`.
45
53
  def to_ary
46
- [@finished, @flags, @opcode, @mask, @length, @payload]
54
+ [@finished, @flags, @opcode, @mask, @payload]
47
55
  end
48
56
 
57
+ # Check whether this is a control frame (opcode has bit 3 set).
58
+ # @returns [Boolean] `true` if this is a control frame.
49
59
  def control?
50
60
  @opcode & 0x8 != 0
51
61
  end
@@ -55,10 +65,14 @@ module Protocol
55
65
  false
56
66
  end
57
67
 
68
+ # Check whether this is the final frame in a message.
69
+ # @returns [Boolean] `true` if the FIN bit is set.
58
70
  def finished?
59
71
  @finished == true
60
72
  end
61
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.
62
76
  def continued?
63
77
  @finished == false
64
78
  end
@@ -88,9 +102,14 @@ module Protocol
88
102
  attr_accessor :flags
89
103
  attr_accessor :opcode
90
104
  attr_accessor :mask
91
- attr_accessor :length
92
105
  attr_accessor :payload
93
106
 
107
+ # The byte length of the payload.
108
+ # @returns [Integer | nil]
109
+ def length
110
+ @payload&.bytesize
111
+ end
112
+
94
113
  if IO.const_defined?(:Buffer) && IO::Buffer.respond_to?(:for) && IO::Buffer.method_defined?(:xor!)
95
114
  private def mask_xor(data, mask)
96
115
  buffer = data.dup
@@ -106,7 +125,7 @@ module Protocol
106
125
  warn "IO::Buffer not available, falling back to slow implementation of mask_xor!"
107
126
  private def mask_xor(data, mask)
108
127
  result = String.new(encoding: Encoding::BINARY)
109
-
128
+
110
129
  for i in 0...data.bytesize do
111
130
  result << (data.getbyte(i) ^ mask.getbyte(i % 4))
112
131
  end
@@ -115,24 +134,25 @@ module Protocol
115
134
  end
116
135
  end
117
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`.
118
140
  def pack(data = "")
119
- length = data.bytesize
120
-
121
- if length.bit_length > 63
122
- 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!"
123
143
  end
124
144
 
125
145
  if @mask
126
- @payload = mask_xor(data, mask)
127
- @length = length
146
+ @payload = mask_xor(data, @mask)
128
147
  else
129
148
  @payload = data
130
- @length = length
131
149
  end
132
150
 
133
151
  return self
134
152
  end
135
153
 
154
+ # Unpack the raw payload, removing masking if present.
155
+ # @returns [String] The unmasked payload data.
136
156
  def unpack
137
157
  if @mask and !@payload.empty?
138
158
  return mask_xor(@payload, @mask)
@@ -141,101 +161,11 @@ module Protocol
141
161
  end
142
162
  end
143
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.
144
166
  def apply(connection)
145
167
  connection.receive_frame(self)
146
168
  end
147
-
148
- def self.parse_header(buffer)
149
- byte = buffer.unpack("C").first
150
-
151
- finished = (byte & 0b1000_0000 != 0)
152
- flags = (byte & 0b0111_0000) >> 4
153
- opcode = byte & 0b0000_1111
154
-
155
- if (0x3 .. 0x7).include?(opcode)
156
- raise ProtocolError, "Non-control opcode = #{opcode} is reserved!"
157
- elsif (0xB .. 0xF).include?(opcode)
158
- raise ProtocolError, "Control opcode = #{opcode} is reserved!"
159
- end
160
-
161
- return finished, flags, opcode
162
- end
163
-
164
- def self.read(finished, flags, opcode, stream, maximum_frame_size)
165
- buffer = stream.read(1) or raise EOFError, "Could not read header!"
166
- byte = buffer.unpack("C").first
167
-
168
- mask = (byte & 0b1000_0000 != 0)
169
- length = byte & 0b0111_1111
170
-
171
- if opcode & 0x8 != 0
172
- if length > 125
173
- raise ProtocolError, "Invalid control frame payload length: #{length} > 125!"
174
- elsif !finished
175
- raise ProtocolError, "Fragmented control frame!"
176
- end
177
- end
178
-
179
- if length == 126
180
- buffer = stream.read(2) or raise EOFError, "Could not read length!"
181
- length = buffer.unpack("n").first
182
- elsif length == 127
183
- buffer = stream.read(8) or raise EOFError, "Could not read length!"
184
- length = buffer.unpack("Q>").first
185
- end
186
-
187
- if length > maximum_frame_size
188
- raise ProtocolError, "Invalid payload length: #{@length} > #{maximum_frame_size}!"
189
- end
190
-
191
- if mask
192
- mask = stream.read(4) or raise EOFError, "Could not read mask!"
193
- end
194
-
195
- payload = stream.read(length) or raise EOFError, "Could not read payload!"
196
-
197
- if payload.bytesize != length
198
- raise EOFError, "Incorrect payload length: #{@length} != #{payload.bytesize}!"
199
- end
200
-
201
- return self.new(finished, payload, flags: flags, opcode: opcode, mask: mask)
202
- end
203
-
204
- def write(stream)
205
- buffer = String.new(encoding: Encoding::BINARY)
206
-
207
- if @payload&.bytesize != @length
208
- raise ProtocolError, "Invalid payload length: #{@length} != #{@payload.bytesize} for #{self}!"
209
- end
210
-
211
- if @mask and @mask.bytesize != 4
212
- raise ProtocolError, "Invalid mask length!"
213
- end
214
-
215
- if length <= 125
216
- short_length = length
217
- elsif length.bit_length <= 16
218
- short_length = 126
219
- else
220
- short_length = 127
221
- end
222
-
223
- buffer << [
224
- (@finished ? 0b1000_0000 : 0) | (@flags << 4) | @opcode,
225
- (@mask ? 0b1000_0000 : 0) | short_length,
226
- ].pack("CC")
227
-
228
- if short_length == 126
229
- buffer << [@length].pack("n")
230
- elsif short_length == 127
231
- buffer << [@length].pack("Q>")
232
- end
233
-
234
- buffer << @mask if @mask
235
-
236
- stream.write(buffer)
237
- stream.write(@payload)
238
- end
239
169
  end
240
170
  end
241
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.1"
10
+ VERSION = "0.21.0"
9
11
  end
10
12
  end
data/license.md CHANGED
@@ -1,10 +1,11 @@
1
1
  # MIT License
2
2
 
3
- Copyright, 2019-2024, 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.
7
7
  Copyright, 2021, by Aurora Nockert.
8
+ Copyright, 2025, by Taleh Zaliyev.
8
9
 
9
10
  Permission is hereby granted, free of charge, to any person obtaining a copy
10
11
  of this software and associated documentation files (the "Software"), to deal
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,15 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: protocol-websocket
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.20.1
4
+ version: 0.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
8
8
  - Aurora Nockert
9
9
  - Soumya
10
10
  - Olle Jonsson
11
+ - Taleh Zaliyev
11
12
  - William T. Nelson
12
- autorequire:
13
13
  bindir: bin
14
14
  cert_chain:
15
15
  - |
@@ -41,7 +41,7 @@ cert_chain:
41
41
  Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8
42
42
  voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg=
43
43
  -----END CERTIFICATE-----
44
- date: 2024-10-09 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
@@ -57,12 +57,13 @@ dependencies:
57
57
  - - "~>"
58
58
  - !ruby/object:Gem::Version
59
59
  version: '0.2'
60
- description:
61
- email:
62
60
  executables: []
63
61
  extensions: []
64
62
  extra_rdoc_files: []
65
63
  files:
64
+ - context/extensions.md
65
+ - context/getting-started.md
66
+ - context/index.yaml
66
67
  - lib/protocol/websocket.rb
67
68
  - lib/protocol/websocket/binary_frame.rb
68
69
  - lib/protocol/websocket/close_frame.rb
@@ -86,13 +87,13 @@ files:
86
87
  - lib/protocol/websocket/version.rb
87
88
  - license.md
88
89
  - readme.md
90
+ - releases.md
89
91
  homepage: https://github.com/socketry/protocol-websocket
90
92
  licenses:
91
93
  - MIT
92
94
  metadata:
93
95
  documentation_uri: https://socketry.github.io/protocol-websocket/
94
96
  source_code_uri: https://github.com/socketry/protocol-websocket.git
95
- post_install_message:
96
97
  rdoc_options: []
97
98
  require_paths:
98
99
  - lib
@@ -100,15 +101,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
100
101
  requirements:
101
102
  - - ">="
102
103
  - !ruby/object:Gem::Version
103
- version: '3.1'
104
+ version: '3.3'
104
105
  required_rubygems_version: !ruby/object:Gem::Requirement
105
106
  requirements:
106
107
  - - ">="
107
108
  - !ruby/object:Gem::Version
108
109
  version: '0'
109
110
  requirements: []
110
- rubygems_version: 3.5.11
111
- signing_key:
111
+ rubygems_version: 4.0.6
112
112
  specification_version: 4
113
113
  summary: A low level implementation of the WebSocket protocol.
114
114
  test_files: []
metadata.gz.sig CHANGED
Binary file