omq-websocket 0.1.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 +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE +13 -0
- data/README.md +72 -0
- data/lib/omq/transport/websocket/codec.rb +62 -0
- data/lib/omq/transport/websocket/connection.rb +322 -0
- data/lib/omq/transport/websocket/options_ext.rb +29 -0
- data/lib/omq/transport/websocket/transport.rb +280 -0
- data/lib/omq/transport/websocket/version.rb +9 -0
- data/lib/omq/transport/websocket.rb +47 -0
- data/lib/omq/websocket.rb +3 -0
- metadata +122 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 06c0113988693375d1e80176cc6e481dbac6146c2d2c703fe45740ee6a4f07cc
|
|
4
|
+
data.tar.gz: 756f09161db08b511cf091195ac1912ad4c4575b6fdd937e60eb0cef1ae04a28
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5ba7f7dd4c079836aa56c5ce3e7e10e116d175725ee8ec8deda03d9ab8404220e63b4a1b82384f1d09b902d84f5fc11bdf3d2c35980193a6b378d35a93e7104e
|
|
7
|
+
data.tar.gz: 7ae4da9f5b6ffd222741a4bbf16ce2cc8eeece79085fb71341acbb2bc14a1816b15a3e1923a007ef99cbcc5c57afdb7115b7847b81d224ac38c0e27a8831349d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.1.0 - 2026-07-23
|
|
6
|
+
|
|
7
|
+
- Initial release. Adds `ws://` and `wss://` transports to OMQ
|
|
8
|
+
implementing ZeroMQ RFC 45 (ZWS 2.0). Both schemes register on
|
|
9
|
+
`require "omq/transport/websocket"`.
|
|
10
|
+
- Subprotocols: `ZWS2.0` (no mechanism, identity-as-first-message)
|
|
11
|
+
and `ZWS2.0/NULL` (NULL handshake via 0x02 command frames).
|
|
12
|
+
- Built on `async-websocket` for the WebSocket upgrade and
|
|
13
|
+
`async-http` for the listener.
|
|
14
|
+
- ZWS framing: one ZeroMQ frame per WebSocket binary message,
|
|
15
|
+
prefixed with a single FLAG byte (0x00 last, 0x01 more, 0x02
|
|
16
|
+
command). No ZMTP/3.1 greeting.
|
|
17
|
+
- `wss://` is plain TLS-then-WebSocket via an `OpenSSL::SSL::SSLContext`
|
|
18
|
+
passed through `tls_context:` on the socket.
|
|
19
|
+
- Release source is now the `zeromq/omq.rb` monorepo.
|
|
20
|
+
- Requires `omq ~> 0.28` and `protocol-zmtp ~> 0.10`.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright (c) 2026, Patrik Wenger
|
|
2
|
+
|
|
3
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
4
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
5
|
+
copyright notice and this permission notice appear in all copies.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
8
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
9
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
10
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
11
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
12
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
13
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# OMQ::Transport::WebSocket
|
|
2
|
+
|
|
3
|
+
[](https://github.com/zeromq/omq.rb/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/omq-websocket)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://www.ruby-lang.org)
|
|
7
|
+
|
|
8
|
+
ZeroMQ-over-WebSocket transport for [OMQ](https://github.com/zeromq/omq.rb).
|
|
9
|
+
Implements [ZeroMQ RFC 45](https://rfc.zeromq.org/spec/45/) (ZWS 2.0).
|
|
10
|
+
|
|
11
|
+
Adds `ws://` and `wss://` schemes to OMQ. Both register at
|
|
12
|
+
`require` time. No other OMQ code change required.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
gem "omq", "~> 0.28"
|
|
18
|
+
gem "omq-websocket", "~> 0.1"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Use
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require "omq"
|
|
25
|
+
require "omq/websocket"
|
|
26
|
+
|
|
27
|
+
# Server
|
|
28
|
+
pull = OMQ::PULL.new
|
|
29
|
+
pull.bind("ws://127.0.0.1:5555")
|
|
30
|
+
|
|
31
|
+
# Client
|
|
32
|
+
push = OMQ::PUSH.connect("ws://127.0.0.1:5555")
|
|
33
|
+
push.send("hello")
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### TLS (`wss://`)
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
40
|
+
ctx.cert = OpenSSL::X509::Certificate.new(File.read("cert.pem"))
|
|
41
|
+
ctx.key = OpenSSL::PKey::RSA.new(File.read("key.pem"))
|
|
42
|
+
|
|
43
|
+
pull = OMQ::PULL.new
|
|
44
|
+
pull.tls_context = ctx
|
|
45
|
+
pull.bind("wss://127.0.0.1:5556")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Mechanism
|
|
49
|
+
|
|
50
|
+
By default the client offers `ZWS2.0/NULL` then `ZWS2.0`. The server
|
|
51
|
+
picks the first one it accepts. Override via:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
sock.ws_subprotocols = %w[ZWS2.0] # force no-mechanism
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Path
|
|
58
|
+
|
|
59
|
+
Defaults to `/`. Override via `sock.ws_path = "/zeromq"`. Listener
|
|
60
|
+
returns 404 for any other path.
|
|
61
|
+
|
|
62
|
+
## Wire protocol
|
|
63
|
+
|
|
64
|
+
Per RFC 45, each ZeroMQ frame maps to one WebSocket binary message:
|
|
65
|
+
a single FLAG byte (`0x00` final, `0x01` more, `0x02` command)
|
|
66
|
+
followed by the frame body. No 64-byte ZMTP greeting. Mechanism
|
|
67
|
+
negotiation happens via `Sec-WebSocket-Protocol` during the HTTP
|
|
68
|
+
upgrade. See `lib/omq/transport/websocket/codec.rb`.
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
ISC.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "protocol/zmtp"
|
|
4
|
+
|
|
5
|
+
module OMQ
|
|
6
|
+
module Transport
|
|
7
|
+
module WebSocket
|
|
8
|
+
# ZWS 2.0 wire codec (RFC 45). One ZeroMQ frame per WebSocket
|
|
9
|
+
# binary message, prefixed with a single FLAG byte:
|
|
10
|
+
#
|
|
11
|
+
# 0x00 final data frame (no MORE)
|
|
12
|
+
# 0x01 intermediate data frame (MORE)
|
|
13
|
+
# 0x02 command frame (READY/PING/PONG/SUBSCRIBE/...)
|
|
14
|
+
#
|
|
15
|
+
# No length prefix — the WebSocket message length IS the frame
|
|
16
|
+
# length. No greeting; mechanism is negotiated via
|
|
17
|
+
# Sec-WebSocket-Protocol during the HTTP upgrade.
|
|
18
|
+
module Codec
|
|
19
|
+
|
|
20
|
+
FLAG_LAST = 0x00
|
|
21
|
+
FLAG_MORE = 0x01
|
|
22
|
+
FLAG_COMMAND = 0x02
|
|
23
|
+
|
|
24
|
+
EMPTY_BINARY = "".b.freeze
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Encodes a frame body with the appropriate FLAG byte prefix.
|
|
28
|
+
#
|
|
29
|
+
# @param body [String] frame payload (binary)
|
|
30
|
+
# @param more [Boolean] more frames follow in this message
|
|
31
|
+
# @param command [Boolean] command frame (PING/PONG/READY/...)
|
|
32
|
+
# @return [String] WS binary message bytes (BINARY encoding)
|
|
33
|
+
#
|
|
34
|
+
def self.encode(body, more: false, command: false)
|
|
35
|
+
flag = command ? FLAG_COMMAND : (more ? FLAG_MORE : FLAG_LAST)
|
|
36
|
+
payload = body.encoding == Encoding::BINARY ? body : body.b
|
|
37
|
+
out = String.new(capacity: payload.bytesize + 1, encoding: Encoding::BINARY)
|
|
38
|
+
|
|
39
|
+
out << flag.chr(Encoding::BINARY)
|
|
40
|
+
out << payload
|
|
41
|
+
out
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Decodes a WS binary message into [flag_byte, body_slice].
|
|
46
|
+
#
|
|
47
|
+
# @param bytes [String] WS binary message bytes
|
|
48
|
+
# @return [Array(Integer, String)]
|
|
49
|
+
# @raise [Protocol::ZMTP::Error] on empty message
|
|
50
|
+
#
|
|
51
|
+
def self.decode(bytes)
|
|
52
|
+
raise Protocol::ZMTP::Error, "ZWS frame is empty" if bytes.empty?
|
|
53
|
+
|
|
54
|
+
flag = bytes.getbyte(0)
|
|
55
|
+
body = bytes.byteslice(1, bytes.bytesize - 1) || EMPTY_BINARY
|
|
56
|
+
[flag, body]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "protocol/zmtp"
|
|
4
|
+
require "async/clock"
|
|
5
|
+
|
|
6
|
+
module OMQ
|
|
7
|
+
module Transport
|
|
8
|
+
module WebSocket
|
|
9
|
+
# Quacks like Protocol::ZMTP::Connection but speaks ZWS 2.0 over
|
|
10
|
+
# an Async::WebSocket::Connection (or any object that responds to
|
|
11
|
+
# +#read+, +#send_binary+, +#flush+, +#close+, +#protocol+).
|
|
12
|
+
#
|
|
13
|
+
# The HTTP/WS upgrade is already complete by the time this is
|
|
14
|
+
# constructed — the Engine instantiates this class via
|
|
15
|
+
# OMQ::Transport::WebSocket.connection_class once
|
|
16
|
+
# +handle_accepted+ / +handle_connected+ delivers the ws_conn.
|
|
17
|
+
#
|
|
18
|
+
# No ZMTP/3.1 greeting. The mechanism (NULL or no-mechanism) is
|
|
19
|
+
# determined from the negotiated Sec-WebSocket-Protocol. Identity
|
|
20
|
+
# is exchanged either via a READY command (NULL) or as the first
|
|
21
|
+
# data message (no-mechanism), per RFC 45.
|
|
22
|
+
class Connection
|
|
23
|
+
|
|
24
|
+
attr_reader :peer_socket_type
|
|
25
|
+
attr_reader :peer_identity
|
|
26
|
+
attr_reader :peer_public_key
|
|
27
|
+
attr_reader :peer_properties
|
|
28
|
+
attr_reader :peer_major
|
|
29
|
+
attr_reader :peer_minor
|
|
30
|
+
attr_reader :last_received_at
|
|
31
|
+
attr_reader :ws
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# @param ws [#read, #send_binary, #flush, #close, #protocol] WebSocket connection
|
|
35
|
+
# @param socket_type [String] our socket type (e.g. "REQ")
|
|
36
|
+
# @param identity [String] our identity
|
|
37
|
+
# @param as_server [Boolean] true on accepted side
|
|
38
|
+
# @param mechanism [Object, nil] ignored — ZWS uses the negotiated subprotocol instead
|
|
39
|
+
# @param max_message_size [Integer, nil] max frame body size
|
|
40
|
+
# @param opts [Hash] extra READY properties (NULL handshake only)
|
|
41
|
+
#
|
|
42
|
+
def initialize(ws, socket_type:, identity: "", as_server: false,
|
|
43
|
+
mechanism: nil, max_message_size: nil, **opts)
|
|
44
|
+
@ws = ws
|
|
45
|
+
@socket_type = socket_type.to_s
|
|
46
|
+
@identity = identity || ""
|
|
47
|
+
@as_server = as_server
|
|
48
|
+
@max_message_size = max_message_size
|
|
49
|
+
@metadata = opts.empty? ? nil : opts.transform_keys(&:to_s)
|
|
50
|
+
|
|
51
|
+
@peer_socket_type = nil
|
|
52
|
+
@peer_identity = nil
|
|
53
|
+
@peer_public_key = nil
|
|
54
|
+
@peer_properties = nil
|
|
55
|
+
|
|
56
|
+
# Advertise ZMTP 3.1 to upper layers so SUBSCRIBE/CANCEL always
|
|
57
|
+
# go via send_command (the ZWS form per RFC 45) rather than the
|
|
58
|
+
# legacy in-band byte-prefix form.
|
|
59
|
+
@peer_major = 3
|
|
60
|
+
@peer_minor = 1
|
|
61
|
+
|
|
62
|
+
@last_received_at = nil
|
|
63
|
+
@mutex = Mutex.new
|
|
64
|
+
@closed = false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def encrypted?
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def curve?
|
|
74
|
+
false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Performs the ZWS 2.0 mechanism handshake. Branches on the
|
|
79
|
+
# subprotocol negotiated during the HTTP upgrade.
|
|
80
|
+
#
|
|
81
|
+
# @return [void]
|
|
82
|
+
# @raise [Protocol::ZMTP::Error] on protocol violation, unsupported
|
|
83
|
+
# subprotocol, or incompatible peer socket type
|
|
84
|
+
#
|
|
85
|
+
def handshake!
|
|
86
|
+
case @ws.protocol
|
|
87
|
+
when "ZWS2.0/NULL"
|
|
88
|
+
handshake_null!
|
|
89
|
+
validate_peer_compatibility!
|
|
90
|
+
when "ZWS2.0", nil
|
|
91
|
+
handshake_no_mechanism!
|
|
92
|
+
# No socket-type validation possible without READY exchange.
|
|
93
|
+
else
|
|
94
|
+
raise Protocol::ZMTP::Error, "unsupported ZWS subprotocol: #{@ws.protocol.inspect}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def send_message(parts)
|
|
100
|
+
@mutex.synchronize do
|
|
101
|
+
write_frames(parts)
|
|
102
|
+
@ws.flush
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def write_message(parts)
|
|
108
|
+
@mutex.synchronize do
|
|
109
|
+
write_frames(parts)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def write_messages(messages)
|
|
115
|
+
@mutex.synchronize do
|
|
116
|
+
messages.each { |parts| write_frames(parts) }
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def flush
|
|
122
|
+
@mutex.synchronize do
|
|
123
|
+
@ws.flush
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def send_command(command)
|
|
129
|
+
@mutex.synchronize do
|
|
130
|
+
@ws.send_binary(Codec.encode(command.to_body, command: true))
|
|
131
|
+
@ws.flush
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# Reads a multi-frame message. Auto-handles PING/PONG and yields
|
|
137
|
+
# any other command frames to the caller block (matching
|
|
138
|
+
# Protocol::ZMTP::Connection#receive_message semantics).
|
|
139
|
+
#
|
|
140
|
+
# @return [Array<String>] frame bodies
|
|
141
|
+
# @raise [EOFError] on peer close
|
|
142
|
+
#
|
|
143
|
+
def receive_message
|
|
144
|
+
frames = []
|
|
145
|
+
|
|
146
|
+
loop do
|
|
147
|
+
frame = read_frame
|
|
148
|
+
|
|
149
|
+
if frame.command?
|
|
150
|
+
yield frame if block_given?
|
|
151
|
+
next
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
frames << frame.body
|
|
155
|
+
break unless frame.more?
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
frames
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# Reads one ZWS frame. Auto-handles PING (replies PONG) and
|
|
163
|
+
# discards PONG. Returns a Protocol::ZMTP::Codec::Frame so
|
|
164
|
+
# upstream code (Subscription.parse, fan_out subscription
|
|
165
|
+
# listeners) sees the same shape as ZMTP/3.1.
|
|
166
|
+
#
|
|
167
|
+
# @return [Protocol::ZMTP::Codec::Frame]
|
|
168
|
+
# @raise [EOFError] on peer close
|
|
169
|
+
#
|
|
170
|
+
def read_frame
|
|
171
|
+
loop do
|
|
172
|
+
message = @ws.read
|
|
173
|
+
unless message
|
|
174
|
+
close
|
|
175
|
+
raise EOFError, "ZWS connection closed"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
bytes = message.buffer
|
|
179
|
+
bytes = bytes.b if bytes.encoding != Encoding::BINARY
|
|
180
|
+
flag, body = Codec.decode(bytes)
|
|
181
|
+
|
|
182
|
+
if @max_message_size && body.bytesize > @max_message_size
|
|
183
|
+
raise Protocol::ZMTP::Error,
|
|
184
|
+
"ZWS frame exceeds max_message_size (#{body.bytesize} > #{@max_message_size})"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
command = (flag & Codec::FLAG_COMMAND) != 0
|
|
188
|
+
more = !command && (flag & Codec::FLAG_MORE) != 0
|
|
189
|
+
frame = Protocol::ZMTP::Codec::Frame.new(body, more: more, command: command)
|
|
190
|
+
|
|
191
|
+
touch_heartbeat
|
|
192
|
+
|
|
193
|
+
if frame.command?
|
|
194
|
+
cmd = Protocol::ZMTP::Codec::Command.from_body(frame.body)
|
|
195
|
+
case cmd.name
|
|
196
|
+
when "PING"
|
|
197
|
+
_ttl, context = cmd.ping_ttl_and_context
|
|
198
|
+
send_command(Protocol::ZMTP::Codec::Command.pong(context: context))
|
|
199
|
+
next
|
|
200
|
+
when "PONG"
|
|
201
|
+
next
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
return frame
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def touch_heartbeat
|
|
211
|
+
@last_received_at = Async::Clock.now
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def heartbeat_expired?(timeout)
|
|
216
|
+
return false unless @last_received_at
|
|
217
|
+
(Async::Clock.now - @last_received_at) > timeout
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def close
|
|
222
|
+
return if @closed
|
|
223
|
+
@closed = true
|
|
224
|
+
@ws.close
|
|
225
|
+
rescue IOError, ::Protocol::WebSocket::ClosedError
|
|
226
|
+
# already closed
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def closed?
|
|
231
|
+
@closed
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
private
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def write_frames(parts)
|
|
239
|
+
last = parts.size - 1
|
|
240
|
+
|
|
241
|
+
parts.each_with_index do |part, i|
|
|
242
|
+
@ws.send_binary(Codec.encode(part, more: i < last))
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def handshake_null!
|
|
248
|
+
ready = Protocol::ZMTP::Codec::Command.ready(
|
|
249
|
+
socket_type: @socket_type,
|
|
250
|
+
identity: @identity,
|
|
251
|
+
metadata: @metadata,
|
|
252
|
+
)
|
|
253
|
+
send_command(ready)
|
|
254
|
+
|
|
255
|
+
frame = read_command_frame!
|
|
256
|
+
cmd = Protocol::ZMTP::Codec::Command.from_body(frame.body)
|
|
257
|
+
|
|
258
|
+
unless cmd.name == "READY"
|
|
259
|
+
raise Protocol::ZMTP::Error, "expected READY, got #{cmd.name}"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
props = cmd.properties
|
|
263
|
+
@peer_properties = props
|
|
264
|
+
@peer_socket_type = props["Socket-Type"]
|
|
265
|
+
@peer_identity = props["Identity"] || Codec::EMPTY_BINARY
|
|
266
|
+
|
|
267
|
+
unless @peer_socket_type
|
|
268
|
+
raise Protocol::ZMTP::Error, "peer READY missing Socket-Type"
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def handshake_no_mechanism!
|
|
274
|
+
@mutex.synchronize do
|
|
275
|
+
@ws.send_binary(Codec.encode(@identity || Codec::EMPTY_BINARY))
|
|
276
|
+
@ws.flush
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
message = @ws.read
|
|
280
|
+
unless message
|
|
281
|
+
raise Protocol::ZMTP::Error, "ZWS peer closed before sending identity"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
bytes = message.buffer
|
|
285
|
+
bytes = bytes.b if bytes.encoding != Encoding::BINARY
|
|
286
|
+
_flag, body = Codec.decode(bytes)
|
|
287
|
+
@peer_identity = body
|
|
288
|
+
|
|
289
|
+
touch_heartbeat
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def read_command_frame!
|
|
294
|
+
loop do
|
|
295
|
+
message = @ws.read
|
|
296
|
+
unless message
|
|
297
|
+
raise Protocol::ZMTP::Error, "ZWS peer closed during handshake"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
bytes = message.buffer
|
|
301
|
+
bytes = bytes.b if bytes.encoding != Encoding::BINARY
|
|
302
|
+
flag, body = Codec.decode(bytes)
|
|
303
|
+
|
|
304
|
+
next if (flag & Codec::FLAG_COMMAND) == 0
|
|
305
|
+
|
|
306
|
+
touch_heartbeat
|
|
307
|
+
return Protocol::ZMTP::Codec::Frame.new(body, command: true)
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def validate_peer_compatibility!
|
|
313
|
+
unless Protocol::ZMTP::VALID_PEERS[@socket_type.to_sym]&.include?(@peer_socket_type.to_sym)
|
|
314
|
+
raise Protocol::ZMTP::Error,
|
|
315
|
+
"incompatible socket types: #{@socket_type} cannot connect to #{@peer_socket_type}"
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OMQ
|
|
4
|
+
module Transport
|
|
5
|
+
module WebSocket
|
|
6
|
+
# Prepended onto OMQ::Options at require time.
|
|
7
|
+
# Adds the per-socket knobs needed by ws:// and wss://.
|
|
8
|
+
module OptionsExt
|
|
9
|
+
|
|
10
|
+
DEFAULT_SUBPROTOCOLS = %w[ZWS2.0/NULL ZWS2.0].freeze
|
|
11
|
+
DEFAULT_PATH = "/"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
attr_accessor :tls_context
|
|
15
|
+
attr_accessor :ws_subprotocols
|
|
16
|
+
attr_accessor :ws_path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def initialize(*, **)
|
|
20
|
+
super
|
|
21
|
+
@tls_context = nil
|
|
22
|
+
@ws_subprotocols = DEFAULT_SUBPROTOCOLS
|
|
23
|
+
@ws_path = DEFAULT_PATH
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "async"
|
|
5
|
+
require "async/promise"
|
|
6
|
+
require "async/http/endpoint"
|
|
7
|
+
require "async/http/server"
|
|
8
|
+
require "async/http/protocol/http1"
|
|
9
|
+
require "async/websocket/client"
|
|
10
|
+
require "async/websocket/adapters/http"
|
|
11
|
+
require "protocol/http/response"
|
|
12
|
+
|
|
13
|
+
module OMQ
|
|
14
|
+
module Transport
|
|
15
|
+
# ZeroMQ-over-WebSocket transport (ZWS 2.0, RFC 45). Registered
|
|
16
|
+
# for both +ws://+ and +wss://+ schemes. The only difference
|
|
17
|
+
# between the two is whether the underlying HTTP endpoint carries
|
|
18
|
+
# an SSL context (supplied via +socket.tls_context=+).
|
|
19
|
+
module WebSocket
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
|
|
23
|
+
# Engine reads this in ConnectionLifecycle#handshake! to
|
|
24
|
+
# construct the per-connection codec/handshake driver.
|
|
25
|
+
# ZWS framing differs from ZMTP/3.1, so we substitute our own
|
|
26
|
+
# Connection class instead of Protocol::ZMTP::Connection.
|
|
27
|
+
#
|
|
28
|
+
# @return [Class]
|
|
29
|
+
#
|
|
30
|
+
def connection_class
|
|
31
|
+
OMQ::Transport::WebSocket::Connection
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Creates a bound WebSocket listener.
|
|
36
|
+
#
|
|
37
|
+
# @param endpoint [String] e.g. "ws://127.0.0.1:5555/" or "wss://host:443/zmq"
|
|
38
|
+
# @param engine [Engine]
|
|
39
|
+
# @return [Listener]
|
|
40
|
+
# @raise [Error] if wss:// is used without a tls_context
|
|
41
|
+
#
|
|
42
|
+
def listener(endpoint, engine, **)
|
|
43
|
+
scheme = endpoint[/\A([a-z]+):\/\//, 1]
|
|
44
|
+
tls = engine.options.tls_context
|
|
45
|
+
|
|
46
|
+
if scheme == "wss" && tls.nil?
|
|
47
|
+
raise Error, "wss:// bind requires options.tls_context"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
subprotocols = engine.options.ws_subprotocols
|
|
51
|
+
path = engine.options.ws_path
|
|
52
|
+
http_endpoint = parse_http_endpoint(endpoint, tls)
|
|
53
|
+
bound = http_endpoint.bound
|
|
54
|
+
actual_port = bound.sockets.first.to_io.local_address.ip_port
|
|
55
|
+
host = http_endpoint.hostname
|
|
56
|
+
host_part = host.include?(":") ? "[#{host}]" : host
|
|
57
|
+
shown = "#{scheme}://#{host_part}:#{actual_port}#{path}"
|
|
58
|
+
|
|
59
|
+
Listener.new(
|
|
60
|
+
shown_endpoint: shown,
|
|
61
|
+
bound: bound,
|
|
62
|
+
http_endpoint: http_endpoint,
|
|
63
|
+
subprotocols: subprotocols,
|
|
64
|
+
match_path: path,
|
|
65
|
+
engine: engine,
|
|
66
|
+
port: actual_port,
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Creates a WebSocket dialer.
|
|
72
|
+
#
|
|
73
|
+
# @param endpoint [String]
|
|
74
|
+
# @param engine [Engine]
|
|
75
|
+
# @return [Dialer]
|
|
76
|
+
#
|
|
77
|
+
def dialer(endpoint, engine, **)
|
|
78
|
+
Dialer.new(endpoint, engine)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Verifies that the endpoint URI is well-formed for HTTP/WS.
|
|
83
|
+
#
|
|
84
|
+
# @param endpoint [String]
|
|
85
|
+
# @raise [ArgumentError]
|
|
86
|
+
#
|
|
87
|
+
def validate_endpoint!(endpoint)
|
|
88
|
+
uri = URI.parse(endpoint)
|
|
89
|
+
raise ArgumentError, "missing host: #{endpoint.inspect}" unless uri.hostname
|
|
90
|
+
raise ArgumentError, "missing port: #{endpoint.inspect}" unless uri.port
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Parses +endpoint+ into an Async::HTTP::Endpoint, attaching the
|
|
95
|
+
# SSL context when present (TLS for wss://).
|
|
96
|
+
#
|
|
97
|
+
# @param endpoint [String]
|
|
98
|
+
# @param tls_context [OpenSSL::SSL::SSLContext, nil]
|
|
99
|
+
# @return [Async::HTTP::Endpoint]
|
|
100
|
+
#
|
|
101
|
+
def parse_http_endpoint(endpoint, tls_context)
|
|
102
|
+
if tls_context
|
|
103
|
+
::Async::HTTP::Endpoint.parse(endpoint, ssl_context: tls_context)
|
|
104
|
+
else
|
|
105
|
+
::Async::HTTP::Endpoint.parse(endpoint)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# Outgoing connection factory. Stateful so reconnect can call
|
|
113
|
+
# +#connect+ again without redoing endpoint parsing.
|
|
114
|
+
class Dialer
|
|
115
|
+
|
|
116
|
+
attr_reader :endpoint
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def initialize(endpoint, engine)
|
|
120
|
+
@endpoint = endpoint
|
|
121
|
+
@engine = engine
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Establishes the WebSocket upgrade and hands the resulting
|
|
126
|
+
# Async::WebSocket::Connection to the engine. The engine builds
|
|
127
|
+
# the OMQ::Transport::WebSocket::Connection on top via the
|
|
128
|
+
# transport's +connection_class+ hook and runs the ZWS
|
|
129
|
+
# handshake.
|
|
130
|
+
#
|
|
131
|
+
# @return [void]
|
|
132
|
+
#
|
|
133
|
+
def connect
|
|
134
|
+
tls = @engine.options.tls_context
|
|
135
|
+
subprotocols = @engine.options.ws_subprotocols
|
|
136
|
+
http_endpoint = WebSocket.parse_http_endpoint(@endpoint, tls)
|
|
137
|
+
ws_conn = ::Async::WebSocket::Client.connect(
|
|
138
|
+
http_endpoint,
|
|
139
|
+
protocols: subprotocols,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
@engine.handle_connected(ws_conn, endpoint: @endpoint)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# Bound WebSocket listener. Wraps an Async::HTTP::Server bound on
|
|
149
|
+
# the requested host/port. Each accepted HTTP request is matched
|
|
150
|
+
# against +ws_path+ and upgraded via
|
|
151
|
+
# Async::WebSocket::Adapters::HTTP.open. The adapter block stays
|
|
152
|
+
# alive (via AcceptedConnection#wait_for_close) until the engine
|
|
153
|
+
# closes the connection — otherwise Adapters::HTTP.open would
|
|
154
|
+
# tear the WebSocket down the moment the block exits.
|
|
155
|
+
class Listener
|
|
156
|
+
|
|
157
|
+
attr_reader :endpoint
|
|
158
|
+
attr_reader :port
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def initialize(shown_endpoint:, bound:, http_endpoint:, subprotocols:, match_path:, engine:, port:)
|
|
162
|
+
@endpoint = shown_endpoint
|
|
163
|
+
@bound = bound
|
|
164
|
+
@http_endpoint = http_endpoint
|
|
165
|
+
@subprotocols = subprotocols
|
|
166
|
+
@match_path = match_path
|
|
167
|
+
@engine = engine
|
|
168
|
+
@port = port
|
|
169
|
+
@task = nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def start_accept_loops(parent_task, &on_accepted)
|
|
174
|
+
@task = parent_task.async(transient: true, annotation: "ws accept #{@endpoint}") do |task|
|
|
175
|
+
server = ::Async::HTTP::Server.new(
|
|
176
|
+
->(request) { handle_request(request, on_accepted) },
|
|
177
|
+
@bound,
|
|
178
|
+
protocol: ::Async::HTTP::Protocol::HTTP1,
|
|
179
|
+
scheme: @http_endpoint.secure? ? "https" : "http",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
@bound.accept(&server.method(:accept))
|
|
183
|
+
|
|
184
|
+
# +accept+ fires per-socket accept fibers and returns. Wait
|
|
185
|
+
# on them so the +ensure+ doesn't close +@bound+ out from
|
|
186
|
+
# under a live accept.
|
|
187
|
+
task.children.each(&:wait)
|
|
188
|
+
rescue ::Async::Stop
|
|
189
|
+
# socket barrier stopped — clean cancel
|
|
190
|
+
ensure
|
|
191
|
+
@bound.close rescue nil
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def stop
|
|
197
|
+
@task&.stop
|
|
198
|
+
@bound.close rescue nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
private
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def handle_request(request, on_accepted)
|
|
206
|
+
return not_found if @match_path && request.path != @match_path
|
|
207
|
+
|
|
208
|
+
::Async::WebSocket::Adapters::HTTP.open(request, protocols: @subprotocols) do |ws_conn|
|
|
209
|
+
accepted = AcceptedConnection.new(ws_conn)
|
|
210
|
+
on_accepted.call(accepted)
|
|
211
|
+
accepted.wait_for_close
|
|
212
|
+
end or not_found
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def not_found
|
|
217
|
+
::Protocol::HTTP::Response[404, {}, []]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# Server-side wrapper around an Async::WebSocket::Connection.
|
|
224
|
+
# Delegates the WS interface used by Connection (+read+,
|
|
225
|
+
# +send_binary+, +flush+, +protocol+) and adds +wait_for_close+
|
|
226
|
+
# so the Adapters::HTTP.open fiber can block until the engine
|
|
227
|
+
# closes the connection.
|
|
228
|
+
#
|
|
229
|
+
# +close+ only resolves the promise — the actual WebSocket close
|
|
230
|
+
# is performed by Adapters::HTTP.open's ensure block once
|
|
231
|
+
# +wait_for_close+ returns. Calling +@ws.close+ here too would
|
|
232
|
+
# double-close and (on Ruby 3.3) deadlock when the peer has
|
|
233
|
+
# already closed.
|
|
234
|
+
class AcceptedConnection
|
|
235
|
+
|
|
236
|
+
def initialize(ws)
|
|
237
|
+
@ws = ws
|
|
238
|
+
@closed_promise = ::Async::Promise.new
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def protocol
|
|
243
|
+
@ws.protocol
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def read
|
|
248
|
+
@ws.read
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def send_binary(buffer, **opts)
|
|
253
|
+
@ws.send_binary(buffer, **opts)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def flush
|
|
258
|
+
@ws.flush
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def close
|
|
263
|
+
@closed_promise.resolve(true)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def closed?
|
|
268
|
+
@closed_promise.resolved?
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def wait_for_close
|
|
273
|
+
@closed_promise.wait
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "omq"
|
|
4
|
+
require "async"
|
|
5
|
+
require "async/http"
|
|
6
|
+
require "async/http/endpoint"
|
|
7
|
+
require "async/http/server"
|
|
8
|
+
require "async/websocket"
|
|
9
|
+
require "async/websocket/client"
|
|
10
|
+
require "async/websocket/adapters/http"
|
|
11
|
+
|
|
12
|
+
require_relative "websocket/version"
|
|
13
|
+
require_relative "websocket/options_ext"
|
|
14
|
+
require_relative "websocket/codec"
|
|
15
|
+
require_relative "websocket/connection"
|
|
16
|
+
require_relative "websocket/transport"
|
|
17
|
+
|
|
18
|
+
module OMQ
|
|
19
|
+
module Transport
|
|
20
|
+
module WebSocket
|
|
21
|
+
|
|
22
|
+
# Raised on configuration errors specific to this transport
|
|
23
|
+
# (e.g. wss:// without tls_context, malformed subprotocol).
|
|
24
|
+
#
|
|
25
|
+
class Error < RuntimeError
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
OMQ::Engine.transports["ws"] = OMQ::Transport::WebSocket
|
|
33
|
+
OMQ::Engine.transports["wss"] = OMQ::Transport::WebSocket
|
|
34
|
+
|
|
35
|
+
OMQ::Options.prepend(OMQ::Transport::WebSocket::OptionsExt)
|
|
36
|
+
|
|
37
|
+
OMQ::Socket.def_delegators :@options,
|
|
38
|
+
:tls_context, :tls_context=,
|
|
39
|
+
:ws_subprotocols, :ws_subprotocols=,
|
|
40
|
+
:ws_path, :ws_path=
|
|
41
|
+
|
|
42
|
+
# OpenSSL::SSL::SSLError already covers wss:// peer aborts after
|
|
43
|
+
# upgrade; add it idempotently in case omq-rfc-tls hasn't been loaded.
|
|
44
|
+
require "openssl"
|
|
45
|
+
unless OMQ::CONNECTION_LOST.include?(OpenSSL::SSL::SSLError)
|
|
46
|
+
OMQ::CONNECTION_LOST << OpenSSL::SSL::SSLError
|
|
47
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: omq-websocket
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Patrik Wenger
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: omq
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.28'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.28'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: protocol-zmtp
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.10'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.10'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: async
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '2.38'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '2.38'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: async-http
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0.94'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0.94'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: async-websocket
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0.30'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0.30'
|
|
82
|
+
description: Adds ws:// and wss:// transports to OMQ, implementing ZeroMQ RFC 45 (ZWS
|
|
83
|
+
2.0). Built on async-websocket. Registers both schemes on require; no OMQ core changes
|
|
84
|
+
required beyond the .connection_class hook (omq >= 0.27).
|
|
85
|
+
email:
|
|
86
|
+
- paddor@gmail.com
|
|
87
|
+
executables: []
|
|
88
|
+
extensions: []
|
|
89
|
+
extra_rdoc_files: []
|
|
90
|
+
files:
|
|
91
|
+
- CHANGELOG.md
|
|
92
|
+
- LICENSE
|
|
93
|
+
- README.md
|
|
94
|
+
- lib/omq/transport/websocket.rb
|
|
95
|
+
- lib/omq/transport/websocket/codec.rb
|
|
96
|
+
- lib/omq/transport/websocket/connection.rb
|
|
97
|
+
- lib/omq/transport/websocket/options_ext.rb
|
|
98
|
+
- lib/omq/transport/websocket/transport.rb
|
|
99
|
+
- lib/omq/transport/websocket/version.rb
|
|
100
|
+
- lib/omq/websocket.rb
|
|
101
|
+
homepage: https://github.com/zeromq/omq.rb/tree/main/gems/omq-websocket
|
|
102
|
+
licenses:
|
|
103
|
+
- ISC
|
|
104
|
+
metadata: {}
|
|
105
|
+
rdoc_options: []
|
|
106
|
+
require_paths:
|
|
107
|
+
- lib
|
|
108
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
109
|
+
requirements:
|
|
110
|
+
- - ">="
|
|
111
|
+
- !ruby/object:Gem::Version
|
|
112
|
+
version: '3.3'
|
|
113
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
114
|
+
requirements:
|
|
115
|
+
- - ">="
|
|
116
|
+
- !ruby/object:Gem::Version
|
|
117
|
+
version: '0'
|
|
118
|
+
requirements: []
|
|
119
|
+
rubygems_version: 4.0.16
|
|
120
|
+
specification_version: 4
|
|
121
|
+
summary: ZeroMQ-over-WebSocket transport for OMQ (ws:// and wss://)
|
|
122
|
+
test_files: []
|