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 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
+ [![CI](https://github.com/zeromq/omq.rb/actions/workflows/ci.yml/badge.svg)](https://github.com/zeromq/omq.rb/actions/workflows/ci.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/omq-websocket?color=e9573f)](https://rubygems.org/gems/omq-websocket)
5
+ [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](LICENSE)
6
+ [![Ruby](https://img.shields.io/badge/Ruby-%3E%3D%203.3-CC342D?logo=ruby&logoColor=white)](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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module Transport
5
+ module WebSocket
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
9
+ 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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "transport/websocket"
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: []