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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/context/extensions.md +91 -0
- data/context/getting-started.md +73 -0
- data/context/index.yaml +16 -0
- data/lib/protocol/websocket/coder/json.rb +2 -0
- data/lib/protocol/websocket/coder.rb +1 -0
- data/lib/protocol/websocket/connection.rb +5 -1
- data/lib/protocol/websocket/error.rb +3 -0
- data/lib/protocol/websocket/extension/compression/constants.rb +2 -2
- data/lib/protocol/websocket/extension/compression/deflate.rb +21 -2
- data/lib/protocol/websocket/extension/compression/inflate.rb +11 -0
- data/lib/protocol/websocket/extension/compression.rb +3 -2
- data/lib/protocol/websocket/extensions.rb +39 -0
- data/lib/protocol/websocket/frame.rb +35 -106
- data/lib/protocol/websocket/framer.rb +94 -14
- data/lib/protocol/websocket/headers.rb +2 -0
- data/lib/protocol/websocket/message.rb +6 -0
- data/lib/protocol/websocket/version.rb +4 -2
- data/license.md +1 -1
- data/readme.md +62 -0
- data/releases.md +43 -0
- data.tar.gz.sig +0 -0
- metadata +8 -4
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 90750bdfa4f0cafe292f5b7faf67cecc3ad8dd599ef30e6be28bf6594405f424
|
|
4
|
+
data.tar.gz: 16d01a6e7e451a9444698cc852f937f1f50b9898bdb072d90cf3c93be2ce8b95
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
data/context/index.yaml
ADDED
|
@@ -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
|
|
@@ -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.
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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, @
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
finished, flags, opcode = read_header
|
|
53
|
+
buffer = @stream.read(2)
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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.
|
|
63
|
-
|
|
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
|
-
|
|
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-
|
|
4
|
+
# Copyright, 2019-2026, by Samuel Williams.
|
|
5
5
|
|
|
6
|
+
# @namespace
|
|
6
7
|
module Protocol
|
|
8
|
+
# @namespace
|
|
7
9
|
module WebSocket
|
|
8
|
-
VERSION = "0.
|
|
10
|
+
VERSION = "0.21.0"
|
|
9
11
|
end
|
|
10
12
|
end
|
data/license.md
CHANGED
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.
|
|
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:
|
|
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.
|
|
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:
|
|
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
|