whoosh 1.3.2 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '058f9e32f51902477b54086f0b13891fb7dd4b3a33fbcef71387d2556af05ebe'
4
- data.tar.gz: abdfab2e7f266bd8d32f7357d531a7847a7087c30a508d1de955edddd1564383
3
+ metadata.gz: 5baff744454d485e7f9788aca18285ece0c8901a5abea6f9c2f3fd4a22a7de75
4
+ data.tar.gz: 6c2062d73b6bec5e60c0adfd897e7e8aec5d10fa366439471592fc196bfe5bbd
5
5
  SHA512:
6
- metadata.gz: 4e2f5bc134e5e2f32eb0c21258f240646abb0d140dfc8834e113d1f52a43d9919266ede8f36ba0750791ee98641d94d6059380d82274a171a8fb610ad9b0f963
7
- data.tar.gz: 3e0ce60dd666d57c6aa84804a2f5a0cf5af34bd2a215faf3d31705d4d535489de8c01a979c03144d1784a61526af8ea3e3941cacd1fee623ef9aae0bf3e7d276
6
+ metadata.gz: 7ef32b36e7405fe117eefc60908baf71a9553d328ccd2fd7823c715b7e37d4f01aeedc29a7cb1709e865410d407a32bc511f49353b90339702a06bb7f9a31425
7
+ data.tar.gz: 75188ded90b9205cb7c779d18252ef842989ec84d76ad78e1167d4ca267e544918b39c5d2a3596abe0b2d6001620e98e4272a75bdd62491b4130127bba78487c
data/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  <img src="https://img.shields.io/badge/ruby-%3E%3D%203.4.0-red" alt="Ruby">
14
14
  <img src="https://img.shields.io/badge/rack-3.0-blue" alt="Rack">
15
15
  <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
16
- <img src="https://img.shields.io/badge/tests-540%20passing-brightgreen" alt="Tests">
16
+ <img src="https://img.shields.io/badge/tests-542%20passing-brightgreen" alt="Tests">
17
17
  <img src="https://img.shields.io/badge/overhead-2.5%C2%B5s-orange" alt="Performance">
18
18
  </p>
19
19
 
@@ -1,29 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "digest"
5
- require "base64"
6
4
 
7
5
  module Whoosh
8
6
  module Streaming
9
7
  class WebSocket
10
- GUID = "258EAFA5-E914-47DA-95CA-5AB5DC65C3E5"
11
-
12
8
  attr_reader :env
13
9
 
14
10
  def initialize(env)
15
11
  @env = env
16
- @io = nil
12
+ @ws = nil
17
13
  @closed = false
14
+ @on_open = nil
18
15
  @on_message = nil
19
16
  @on_close = nil
20
- @on_open = nil
21
17
  end
22
18
 
23
19
  # Check if the request is a WebSocket upgrade
24
20
  def self.websocket?(env)
25
- env["HTTP_UPGRADE"]&.downcase == "websocket" &&
26
- env["HTTP_CONNECTION"]&.downcase&.include?("upgrade")
21
+ upgrade = env["HTTP_UPGRADE"]
22
+ upgrade && upgrade.downcase == "websocket"
27
23
  end
28
24
 
29
25
  # Register callbacks
@@ -41,154 +37,121 @@ module Whoosh
41
37
 
42
38
  # Send data to the client
43
39
  def send(data)
44
- return if @closed
40
+ return if @closed || @ws.nil?
45
41
  formatted = data.is_a?(String) ? data : JSON.generate(data)
46
- write_frame(formatted)
42
+ @ws.send(formatted)
47
43
  end
48
44
 
49
- def close
45
+ def close(code = nil, reason = nil)
50
46
  return if @closed
51
47
  @closed = true
52
- write_close_frame
53
- @io&.close rescue nil
54
- @on_close&.call
48
+ @ws&.close(code || 1000, reason || "")
49
+ rescue
50
+ # Already closed
55
51
  end
56
52
 
57
53
  def closed?
58
54
  @closed
59
55
  end
60
56
 
61
- # Returns a Rack response that hijacks the connection
57
+ # Returns a Rack response auto-detects Faye (Puma) or Async (Falcon)
62
58
  def rack_response
63
59
  unless self.class.websocket?(@env)
64
60
  return [400, { "content-type" => "text/plain" }, ["Not a WebSocket request"]]
65
61
  end
66
62
 
67
- # WebSocket handshake
68
- key = @env["HTTP_SEC_WEBSOCKET_KEY"]
69
- accept = Base64.strict_encode64(Digest::SHA1.digest(key + GUID))
70
-
71
- headers = {
72
- "Upgrade" => "websocket",
73
- "Connection" => "Upgrade",
74
- "Sec-WebSocket-Accept" => accept
75
- }
76
-
77
- # Use rack.hijack to take over the connection
78
- if @env["rack.hijack"]
79
- @env["rack.hijack"].call
80
- @io = @env["rack.hijack_io"]
81
-
82
- # Send handshake response manually
83
- @io.write("HTTP/1.1 101 Switching Protocols\r\n")
84
- headers.each { |k, v| @io.write("#{k}: #{v}\r\n") }
85
- @io.write("\r\n")
86
- @io.flush
87
-
88
- # Notify open
89
- @on_open&.call
90
-
91
- # Start reading frames in a thread
92
- Thread.new { read_loop }
93
-
94
- # Return a dummy response (connection is hijacked)
95
- [-1, {}, []]
63
+ if async_websocket_available? && falcon_env?
64
+ rack_response_async
96
65
  else
97
- # Fallback: return 101 and let the server handle hijack
98
- [101, headers, []]
66
+ rack_response_faye
99
67
  end
100
68
  end
101
69
 
102
- # For testing — simulate without real socket
70
+ # For testing without real socket
103
71
  def trigger_message(msg)
104
72
  @on_message&.call(msg)
105
73
  end
106
74
 
107
- def trigger_close
108
- @on_close&.call
75
+ def trigger_close(code = 1000, reason = "")
76
+ @on_close&.call(code, reason)
109
77
  @closed = true
110
78
  end
111
79
 
112
- private
113
-
114
- def read_loop
115
- while !@closed && @io && !@io.closed?
116
- frame = read_frame
117
- break unless frame
118
-
119
- case frame[:opcode]
120
- when 0x1 # Text frame
121
- @on_message&.call(frame[:data])
122
- when 0x8 # Close frame
123
- close
124
- break
125
- when 0x9 # Ping
126
- write_pong(frame[:data])
127
- end
128
- end
129
- rescue IOError, Errno::ECONNRESET, Errno::EPIPE
130
- @closed = true
131
- @on_close&.call
80
+ def trigger_open
81
+ @on_open&.call
132
82
  end
133
83
 
134
- def read_frame
135
- return nil unless @io && !@io.closed?
84
+ private
136
85
 
137
- first_byte = @io.readbyte
138
- fin = (first_byte & 0x80) != 0
139
- opcode = first_byte & 0x0F
86
+ # Faye WebSocket — works with Puma (threads + EventMachine)
87
+ def rack_response_faye
88
+ require "faye/websocket"
140
89
 
141
- second_byte = @io.readbyte
142
- masked = (second_byte & 0x80) != 0
143
- length = second_byte & 0x7F
90
+ @ws = Faye::WebSocket.new(@env)
144
91
 
145
- if length == 126
146
- length = @io.read(2).unpack1("n")
147
- elsif length == 127
148
- length = @io.read(8).unpack1("Q>")
92
+ @ws.on :open do |_event|
93
+ @on_open&.call
149
94
  end
150
95
 
151
- mask_key = masked ? @io.read(4).bytes : nil
152
- payload = @io.read(length)&.bytes || []
96
+ @ws.on :message do |event|
97
+ @on_message&.call(event.data)
98
+ end
153
99
 
154
- if masked && mask_key
155
- payload = payload.each_with_index.map { |b, i| b ^ mask_key[i % 4] }
100
+ @ws.on :close do |event|
101
+ @closed = true
102
+ @on_close&.call(event.code, event.reason)
103
+ @ws = nil
156
104
  end
157
105
 
158
- { opcode: opcode, data: payload.pack("C*"), fin: fin }
159
- rescue EOFError, IOError
160
- nil
106
+ @ws.rack_response
161
107
  end
162
108
 
163
- def write_frame(data, opcode: 0x1)
164
- return unless @io && !@io.closed?
109
+ # Async WebSocket — works with Falcon (fibers + async)
110
+ def rack_response_async
111
+ require "async/websocket/adapters/rack"
165
112
 
166
- bytes = data.encode("UTF-8").bytes
167
- frame = [0x80 | opcode] # FIN + opcode
113
+ Async::WebSocket::Adapters::Rack.open(@env, protocols: ["ws"]) do |connection|
114
+ @ws = AsyncWSWrapper.new(connection)
115
+ @on_open&.call
168
116
 
169
- if bytes.length < 126
170
- frame << bytes.length
171
- elsif bytes.length < 65536
172
- frame << 126
173
- frame += [bytes.length].pack("n").bytes
174
- else
175
- frame << 127
176
- frame += [bytes.length].pack("Q>").bytes
117
+ while (message = connection.read)
118
+ @on_message&.call(message.to_str)
119
+ end
120
+ rescue EOFError, Protocol::WebSocket::ClosedError
121
+ # Client disconnected
122
+ ensure
123
+ @closed = true
124
+ @on_close&.call(1000, "")
125
+ @ws = nil
177
126
  end
127
+ end
178
128
 
179
- frame += bytes
180
- @io.write(frame.pack("C*"))
181
- @io.flush
182
- rescue IOError, Errno::EPIPE
183
- @closed = true
129
+ def falcon_env?
130
+ # Falcon sets async.* keys in the env
131
+ @env.key?("async.reactor") || @env.key?("protocol.http.request")
184
132
  end
185
133
 
186
- def write_close_frame
187
- write_frame("", opcode: 0x8) rescue nil
134
+ def async_websocket_available?
135
+ require "async/websocket/adapters/rack"
136
+ true
137
+ rescue LoadError
138
+ false
188
139
  end
189
140
 
190
- def write_pong(data)
191
- write_frame(data, opcode: 0xA) rescue nil
141
+ # Wrapper to give async-websocket the same #send interface
142
+ class AsyncWSWrapper
143
+ def initialize(connection)
144
+ @connection = connection
145
+ end
146
+
147
+ def send(data)
148
+ @connection.write(Protocol::WebSocket::TextMessage.generate(data))
149
+ @connection.flush
150
+ end
151
+
152
+ def close(code = 1000, reason = "")
153
+ @connection.close
154
+ end
192
155
  end
193
156
  end
194
157
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Whoosh
4
- VERSION = "1.3.2"
4
+ VERSION = "1.4.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: whoosh
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.2
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Johannes Dwi Cahyo
@@ -107,6 +107,20 @@ dependencies:
107
107
  - - "~>"
108
108
  - !ruby/object:Gem::Version
109
109
  version: '1.8'
110
+ - !ruby/object:Gem::Dependency
111
+ name: faye-websocket
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '0.11'
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '0.11'
110
124
  - !ruby/object:Gem::Dependency
111
125
  name: rspec
112
126
  requirement: !ruby/object:Gem::Requirement