tina4ruby 3.11.13 → 3.11.15
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
- data/CHANGELOG.md +80 -80
- data/LICENSE.txt +21 -21
- data/README.md +137 -137
- data/exe/tina4ruby +5 -5
- data/lib/tina4/ai.rb +696 -696
- data/lib/tina4/api.rb +189 -189
- data/lib/tina4/auth.rb +305 -305
- data/lib/tina4/auto_crud.rb +244 -244
- data/lib/tina4/cache.rb +154 -154
- data/lib/tina4/cli.rb +1449 -1449
- data/lib/tina4/constants.rb +46 -46
- data/lib/tina4/container.rb +74 -74
- data/lib/tina4/cors.rb +74 -74
- data/lib/tina4/crud.rb +692 -692
- data/lib/tina4/database/sqlite3_adapter.rb +165 -165
- data/lib/tina4/database.rb +625 -625
- data/lib/tina4/database_result.rb +208 -208
- data/lib/tina4/debug.rb +8 -8
- data/lib/tina4/dev.rb +14 -14
- data/lib/tina4/dev_admin.rb +935 -935
- data/lib/tina4/dev_mailbox.rb +191 -191
- data/lib/tina4/drivers/firebird_driver.rb +124 -110
- data/lib/tina4/drivers/mongodb_driver.rb +561 -561
- data/lib/tina4/drivers/mssql_driver.rb +112 -112
- data/lib/tina4/drivers/mysql_driver.rb +90 -90
- data/lib/tina4/drivers/odbc_driver.rb +191 -191
- data/lib/tina4/drivers/postgres_driver.rb +116 -106
- data/lib/tina4/drivers/sqlite_driver.rb +122 -122
- data/lib/tina4/env.rb +95 -95
- data/lib/tina4/error_overlay.rb +252 -252
- data/lib/tina4/events.rb +109 -109
- data/lib/tina4/field_types.rb +154 -154
- data/lib/tina4/frond.rb +2025 -2025
- data/lib/tina4/gallery/auth/meta.json +1 -1
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
- data/lib/tina4/gallery/database/meta.json +1 -1
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
- data/lib/tina4/gallery/error-overlay/meta.json +1 -1
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
- data/lib/tina4/gallery/orm/meta.json +1 -1
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
- data/lib/tina4/gallery/rest-api/meta.json +1 -1
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
- data/lib/tina4/gallery/templates/meta.json +1 -1
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
- data/lib/tina4/graphql.rb +966 -966
- data/lib/tina4/health.rb +39 -39
- data/lib/tina4/html_element.rb +170 -170
- data/lib/tina4/job.rb +80 -80
- data/lib/tina4/localization.rb +168 -168
- data/lib/tina4/log.rb +203 -203
- data/lib/tina4/mcp.rb +696 -696
- data/lib/tina4/messenger.rb +587 -587
- data/lib/tina4/metrics.rb +793 -793
- data/lib/tina4/middleware.rb +445 -445
- data/lib/tina4/migration.rb +451 -451
- data/lib/tina4/orm.rb +790 -790
- data/lib/tina4/public/css/tina4.css +2463 -2463
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/public/images/logo.svg +5 -5
- data/lib/tina4/public/js/frond.min.js +2 -2
- data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
- data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
- data/lib/tina4/public/js/tina4.min.js +92 -92
- data/lib/tina4/public/js/tina4js.min.js +48 -48
- data/lib/tina4/public/swagger/index.html +90 -90
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
- data/lib/tina4/query_builder.rb +380 -380
- data/lib/tina4/queue.rb +366 -366
- data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
- data/lib/tina4/queue_backends/lite_backend.rb +298 -298
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
- data/lib/tina4/rack_app.rb +817 -817
- data/lib/tina4/rate_limiter.rb +130 -130
- data/lib/tina4/request.rb +268 -255
- data/lib/tina4/response.rb +346 -346
- data/lib/tina4/response_cache.rb +551 -551
- data/lib/tina4/router.rb +406 -406
- data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
- data/lib/tina4/scss/tina4css/_badges.scss +22 -22
- data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
- data/lib/tina4/scss/tina4css/_cards.scss +49 -49
- data/lib/tina4/scss/tina4css/_forms.scss +156 -156
- data/lib/tina4/scss/tina4css/_grid.scss +81 -81
- data/lib/tina4/scss/tina4css/_modals.scss +84 -84
- data/lib/tina4/scss/tina4css/_nav.scss +149 -149
- data/lib/tina4/scss/tina4css/_reset.scss +94 -94
- data/lib/tina4/scss/tina4css/_tables.scss +54 -54
- data/lib/tina4/scss/tina4css/_typography.scss +55 -55
- data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
- data/lib/tina4/scss/tina4css/_variables.scss +117 -117
- data/lib/tina4/scss/tina4css/base.scss +1 -1
- data/lib/tina4/scss/tina4css/colors.scss +48 -48
- data/lib/tina4/scss/tina4css/tina4.scss +17 -17
- data/lib/tina4/scss_compiler.rb +178 -178
- data/lib/tina4/seeder.rb +567 -567
- data/lib/tina4/service_runner.rb +303 -303
- data/lib/tina4/session.rb +297 -297
- data/lib/tina4/session_handlers/database_handler.rb +72 -72
- data/lib/tina4/session_handlers/file_handler.rb +67 -67
- data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
- data/lib/tina4/session_handlers/redis_handler.rb +43 -43
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
- data/lib/tina4/shutdown.rb +84 -84
- data/lib/tina4/sql_translation.rb +158 -158
- data/lib/tina4/swagger.rb +124 -124
- data/lib/tina4/template.rb +894 -894
- data/lib/tina4/templates/base.twig +26 -26
- data/lib/tina4/templates/errors/302.twig +14 -14
- data/lib/tina4/templates/errors/401.twig +9 -9
- data/lib/tina4/templates/errors/403.twig +29 -29
- data/lib/tina4/templates/errors/404.twig +29 -29
- data/lib/tina4/templates/errors/500.twig +38 -38
- data/lib/tina4/templates/errors/502.twig +9 -9
- data/lib/tina4/templates/errors/503.twig +12 -12
- data/lib/tina4/templates/errors/base.twig +37 -37
- data/lib/tina4/test_client.rb +159 -159
- data/lib/tina4/testing.rb +340 -340
- data/lib/tina4/validator.rb +174 -174
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +312 -312
- data/lib/tina4/websocket.rb +343 -343
- data/lib/tina4/websocket_backplane.rb +190 -190
- data/lib/tina4/wsdl.rb +564 -564
- data/lib/tina4.rb +458 -458
- data/lib/tina4ruby.rb +4 -4
- metadata +3 -3
data/lib/tina4/websocket.rb
CHANGED
|
@@ -1,343 +1,343 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
require "socket"
|
|
3
|
-
require "digest"
|
|
4
|
-
require "base64"
|
|
5
|
-
require "json"
|
|
6
|
-
require "set"
|
|
7
|
-
|
|
8
|
-
module Tina4
|
|
9
|
-
WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-5AB5DC11AD37"
|
|
10
|
-
|
|
11
|
-
# Compute Sec-WebSocket-Accept from Sec-WebSocket-Key per RFC 6455.
|
|
12
|
-
def self.compute_accept_key(key)
|
|
13
|
-
Base64.strict_encode64(Digest::SHA1.digest("#{key}#{WEBSOCKET_GUID}"))
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
# Build a WebSocket frame (server→client, never masked).
|
|
17
|
-
def self.build_frame(opcode, data, fin: true)
|
|
18
|
-
first_byte = (fin ? 0x80 : 0x00) | opcode
|
|
19
|
-
frame = [first_byte].pack("C")
|
|
20
|
-
length = data.bytesize
|
|
21
|
-
|
|
22
|
-
if length < 126
|
|
23
|
-
frame += [length].pack("C")
|
|
24
|
-
elsif length < 65536
|
|
25
|
-
frame += [126, length].pack("Cn")
|
|
26
|
-
else
|
|
27
|
-
frame += [127, length].pack("CQ>")
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
frame + data
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
class WebSocket
|
|
34
|
-
GUID = WEBSOCKET_GUID
|
|
35
|
-
|
|
36
|
-
attr_reader :connections
|
|
37
|
-
|
|
38
|
-
def initialize
|
|
39
|
-
@connections = {}
|
|
40
|
-
@handlers = {
|
|
41
|
-
open: [],
|
|
42
|
-
message: [],
|
|
43
|
-
close: [],
|
|
44
|
-
error: []
|
|
45
|
-
}
|
|
46
|
-
@rooms = {} # room_name => Set of conn_ids
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def on(event, &block)
|
|
50
|
-
@handlers[event.to_sym] << block if @handlers.key?(event.to_sym)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def upgrade?(env)
|
|
54
|
-
upgrade = env["HTTP_UPGRADE"] || ""
|
|
55
|
-
upgrade.downcase == "websocket"
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def get_clients
|
|
59
|
-
@connections
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def start(host: "0.0.0.0", port: 7147)
|
|
63
|
-
require "socket"
|
|
64
|
-
@server_socket = TCPServer.new(host, port)
|
|
65
|
-
@running = true
|
|
66
|
-
@server_thread = Thread.new do
|
|
67
|
-
while @running
|
|
68
|
-
begin
|
|
69
|
-
client = @server_socket.accept
|
|
70
|
-
env = {}
|
|
71
|
-
handle_upgrade(env, client)
|
|
72
|
-
rescue => e
|
|
73
|
-
break unless @running
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
self
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def stop
|
|
81
|
-
@running = false
|
|
82
|
-
@server_socket&.close rescue nil
|
|
83
|
-
@server_thread&.join(1)
|
|
84
|
-
@connections.each_value { |conn| conn.close rescue nil }
|
|
85
|
-
@connections.clear
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def broadcast(message, exclude: nil, path: nil)
|
|
89
|
-
@connections.each do |id, conn|
|
|
90
|
-
next if exclude && id == exclude
|
|
91
|
-
next if path && conn.path != path
|
|
92
|
-
conn.send_text(message)
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def send_to(conn_id, message)
|
|
97
|
-
conn = @connections[conn_id]
|
|
98
|
-
conn&.send_text(message)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def close(conn_id, code: 1000, reason: "")
|
|
102
|
-
conn = @connections[conn_id]
|
|
103
|
-
conn&.close(code: code, reason: reason)
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
# ── Rooms ──────────────────────────────────────────────────
|
|
107
|
-
|
|
108
|
-
def join_room_for(conn_id, room_name)
|
|
109
|
-
@rooms[room_name] ||= Set.new
|
|
110
|
-
@rooms[room_name].add(conn_id)
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def leave_room_for(conn_id, room_name)
|
|
114
|
-
@rooms[room_name]&.delete(conn_id)
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
def room_count(room_name)
|
|
118
|
-
(@rooms[room_name] || Set.new).size
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
# Return list of room names a given client/connection id belongs to.
|
|
122
|
-
# Matches PHP Tina4\WebSocket::getClientRooms($clientId).
|
|
123
|
-
def get_client_rooms(client_id)
|
|
124
|
-
@rooms.each_with_object([]) do |(name, members), acc|
|
|
125
|
-
acc << name if members.include?(client_id)
|
|
126
|
-
end
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
def get_room_connections(room_name)
|
|
130
|
-
ids = @rooms[room_name] || Set.new
|
|
131
|
-
ids.filter_map { |id| @connections[id] }
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def broadcast_to_room(room_name, message, exclude: nil)
|
|
135
|
-
(get_room_connections(room_name)).each do |conn|
|
|
136
|
-
next if exclude && conn.id == exclude
|
|
137
|
-
conn.send_text(message)
|
|
138
|
-
end
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
# Register a WebSocket handler for a path (class method, matching Python's
|
|
142
|
-
# WebSocketServer.route). The block receives a WebSocketConnection and should
|
|
143
|
-
# call conn.on_message / conn.on_close to wire up event callbacks.
|
|
144
|
-
#
|
|
145
|
-
# Registers on the Router so routes work in integrated (Rack) mode.
|
|
146
|
-
#
|
|
147
|
-
# Tina4::WebSocket.route("/chat") do |conn|
|
|
148
|
-
# conn.on_message { |data| conn.send(data) }
|
|
149
|
-
# conn.on_close { puts "bye" }
|
|
150
|
-
# end
|
|
151
|
-
#
|
|
152
|
-
def self.route(path, &block)
|
|
153
|
-
@route_handlers ||= {}
|
|
154
|
-
@route_handlers[path] = block
|
|
155
|
-
|
|
156
|
-
# Adapt to Router's (conn, event, data) style
|
|
157
|
-
adapter = proc do |conn, event, data|
|
|
158
|
-
case event
|
|
159
|
-
when :open
|
|
160
|
-
block.call(conn)
|
|
161
|
-
when :message
|
|
162
|
-
conn.on_message_handler&.call(data)
|
|
163
|
-
when :close
|
|
164
|
-
conn.on_close_handler&.call
|
|
165
|
-
end
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
Tina4::Router.websocket(path, &adapter)
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def handle_upgrade(env, socket)
|
|
172
|
-
key = env["HTTP_SEC_WEBSOCKET_KEY"]
|
|
173
|
-
return unless key
|
|
174
|
-
|
|
175
|
-
accept = Tina4.compute_accept_key(key)
|
|
176
|
-
|
|
177
|
-
response = "HTTP/1.1 101 Switching Protocols\r\n" \
|
|
178
|
-
"Upgrade: websocket\r\n" \
|
|
179
|
-
"Connection: Upgrade\r\n" \
|
|
180
|
-
"Sec-WebSocket-Accept: #{accept}\r\n\r\n"
|
|
181
|
-
|
|
182
|
-
socket.write(response)
|
|
183
|
-
|
|
184
|
-
conn_id = SecureRandom.hex(16)
|
|
185
|
-
ws_path = env["REQUEST_PATH"] || env["PATH_INFO"] || "/"
|
|
186
|
-
connection = WebSocketConnection.new(conn_id, socket, ws_server: self, path: ws_path)
|
|
187
|
-
@connections[conn_id] = connection
|
|
188
|
-
|
|
189
|
-
emit(:open, connection)
|
|
190
|
-
|
|
191
|
-
Thread.new do
|
|
192
|
-
begin
|
|
193
|
-
loop do
|
|
194
|
-
frame = connection.read_frame
|
|
195
|
-
break unless frame
|
|
196
|
-
|
|
197
|
-
case frame[:opcode]
|
|
198
|
-
when 0x1 # Text
|
|
199
|
-
emit(:message, connection, frame[:data])
|
|
200
|
-
when 0x8 # Close
|
|
201
|
-
break
|
|
202
|
-
when 0x9 # Ping
|
|
203
|
-
connection.send_pong(frame[:data])
|
|
204
|
-
end
|
|
205
|
-
end
|
|
206
|
-
rescue => e
|
|
207
|
-
emit(:error, connection, e)
|
|
208
|
-
ensure
|
|
209
|
-
@connections.delete(conn_id)
|
|
210
|
-
remove_from_all_rooms(conn_id)
|
|
211
|
-
emit(:close, connection)
|
|
212
|
-
socket.close rescue nil
|
|
213
|
-
end
|
|
214
|
-
end
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
def emit(event, *args)
|
|
218
|
-
@handlers[event]&.each { |h| h.call(*args) }
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
def remove_from_all_rooms(conn_id)
|
|
222
|
-
@rooms.each_value { |members| members.delete(conn_id) }
|
|
223
|
-
end
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
class WebSocketConnection
|
|
227
|
-
attr_reader :id, :rooms
|
|
228
|
-
attr_accessor :params, :path, :on_message_handler, :on_close_handler, :on_error_handler
|
|
229
|
-
|
|
230
|
-
def initialize(id, socket, ws_server: nil, path: "/")
|
|
231
|
-
@id = id
|
|
232
|
-
@socket = socket
|
|
233
|
-
@params = {}
|
|
234
|
-
@ws_server = ws_server
|
|
235
|
-
@path = path
|
|
236
|
-
@rooms = Set.new
|
|
237
|
-
@on_message_handler = nil
|
|
238
|
-
@on_close_handler = nil
|
|
239
|
-
@on_error_handler = nil
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
# Register a message handler (decorator style, matching Python).
|
|
243
|
-
def on_message(&block)
|
|
244
|
-
@on_message_handler = block
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
# Register a close handler (decorator style, matching Python).
|
|
248
|
-
def on_close(&block)
|
|
249
|
-
@on_close_handler = block
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
def join_room(room_name)
|
|
253
|
-
@rooms.add(room_name)
|
|
254
|
-
@ws_server&.join_room_for(@id, room_name)
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
def leave_room(room_name)
|
|
258
|
-
@rooms.delete(room_name)
|
|
259
|
-
@ws_server&.leave_room_for(@id, room_name)
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
def broadcast_to_room(room_name, message, exclude_self: false)
|
|
263
|
-
return unless @ws_server
|
|
264
|
-
|
|
265
|
-
exclude = exclude_self ? @id : nil
|
|
266
|
-
@ws_server.broadcast_to_room(room_name, message, exclude: exclude)
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
# Broadcast a message to all other connections on the same path
|
|
270
|
-
def broadcast(message, include_self: false)
|
|
271
|
-
return unless @ws_server
|
|
272
|
-
|
|
273
|
-
@ws_server.connections.each do |cid, conn|
|
|
274
|
-
next if !include_self && cid == @id
|
|
275
|
-
next if conn.path != @path
|
|
276
|
-
conn.send_text(message)
|
|
277
|
-
end
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
def send(message)
|
|
281
|
-
data = message.encode("UTF-8")
|
|
282
|
-
frame = build_frame(0x1, data)
|
|
283
|
-
@socket.write(frame)
|
|
284
|
-
rescue IOError
|
|
285
|
-
# Connection closed
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
alias_method :send_text, :send
|
|
289
|
-
|
|
290
|
-
# Serialize a Hash/Array (or any JSON-coercible value) and send as a text frame.
|
|
291
|
-
# Matches Python/PHP send_json.
|
|
292
|
-
def send_json(data)
|
|
293
|
-
send_text(JSON.generate(data))
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
def send_pong(data)
|
|
297
|
-
frame = build_frame(0xA, data || "")
|
|
298
|
-
@socket.write(frame)
|
|
299
|
-
rescue IOError
|
|
300
|
-
# Connection closed
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
def close(code: 1000, reason: "")
|
|
304
|
-
payload = [code].pack("n") + reason
|
|
305
|
-
frame = build_frame(0x8, payload)
|
|
306
|
-
@socket.write(frame) rescue nil
|
|
307
|
-
@socket.close rescue nil
|
|
308
|
-
end
|
|
309
|
-
|
|
310
|
-
def read_frame
|
|
311
|
-
first_byte = @socket.getbyte
|
|
312
|
-
return nil unless first_byte
|
|
313
|
-
|
|
314
|
-
opcode = first_byte & 0x0F
|
|
315
|
-
second_byte = @socket.getbyte
|
|
316
|
-
return nil unless second_byte
|
|
317
|
-
|
|
318
|
-
masked = (second_byte & 0x80) != 0
|
|
319
|
-
length = second_byte & 0x7F
|
|
320
|
-
|
|
321
|
-
if length == 126
|
|
322
|
-
length = @socket.read(2).unpack1("n")
|
|
323
|
-
elsif length == 127
|
|
324
|
-
length = @socket.read(8).unpack1("Q>")
|
|
325
|
-
end
|
|
326
|
-
|
|
327
|
-
mask_key = masked ? @socket.read(4).bytes : nil
|
|
328
|
-
data = @socket.read(length) || ""
|
|
329
|
-
|
|
330
|
-
if masked && mask_key
|
|
331
|
-
data = data.bytes.each_with_index.map { |b, i| b ^ mask_key[i % 4] }.pack("C*")
|
|
332
|
-
end
|
|
333
|
-
|
|
334
|
-
{ opcode: opcode, data: data }
|
|
335
|
-
rescue IOError, EOFError
|
|
336
|
-
nil
|
|
337
|
-
end
|
|
338
|
-
|
|
339
|
-
def build_frame(opcode, data)
|
|
340
|
-
Tina4.build_frame(opcode, data)
|
|
341
|
-
end
|
|
342
|
-
end
|
|
343
|
-
end
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "socket"
|
|
3
|
+
require "digest"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "json"
|
|
6
|
+
require "set"
|
|
7
|
+
|
|
8
|
+
module Tina4
|
|
9
|
+
WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-5AB5DC11AD37"
|
|
10
|
+
|
|
11
|
+
# Compute Sec-WebSocket-Accept from Sec-WebSocket-Key per RFC 6455.
|
|
12
|
+
def self.compute_accept_key(key)
|
|
13
|
+
Base64.strict_encode64(Digest::SHA1.digest("#{key}#{WEBSOCKET_GUID}"))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Build a WebSocket frame (server→client, never masked).
|
|
17
|
+
def self.build_frame(opcode, data, fin: true)
|
|
18
|
+
first_byte = (fin ? 0x80 : 0x00) | opcode
|
|
19
|
+
frame = [first_byte].pack("C")
|
|
20
|
+
length = data.bytesize
|
|
21
|
+
|
|
22
|
+
if length < 126
|
|
23
|
+
frame += [length].pack("C")
|
|
24
|
+
elsif length < 65536
|
|
25
|
+
frame += [126, length].pack("Cn")
|
|
26
|
+
else
|
|
27
|
+
frame += [127, length].pack("CQ>")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
frame + data
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class WebSocket
|
|
34
|
+
GUID = WEBSOCKET_GUID
|
|
35
|
+
|
|
36
|
+
attr_reader :connections
|
|
37
|
+
|
|
38
|
+
def initialize
|
|
39
|
+
@connections = {}
|
|
40
|
+
@handlers = {
|
|
41
|
+
open: [],
|
|
42
|
+
message: [],
|
|
43
|
+
close: [],
|
|
44
|
+
error: []
|
|
45
|
+
}
|
|
46
|
+
@rooms = {} # room_name => Set of conn_ids
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def on(event, &block)
|
|
50
|
+
@handlers[event.to_sym] << block if @handlers.key?(event.to_sym)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def upgrade?(env)
|
|
54
|
+
upgrade = env["HTTP_UPGRADE"] || ""
|
|
55
|
+
upgrade.downcase == "websocket"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def get_clients
|
|
59
|
+
@connections
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def start(host: "0.0.0.0", port: 7147)
|
|
63
|
+
require "socket"
|
|
64
|
+
@server_socket = TCPServer.new(host, port)
|
|
65
|
+
@running = true
|
|
66
|
+
@server_thread = Thread.new do
|
|
67
|
+
while @running
|
|
68
|
+
begin
|
|
69
|
+
client = @server_socket.accept
|
|
70
|
+
env = {}
|
|
71
|
+
handle_upgrade(env, client)
|
|
72
|
+
rescue => e
|
|
73
|
+
break unless @running
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def stop
|
|
81
|
+
@running = false
|
|
82
|
+
@server_socket&.close rescue nil
|
|
83
|
+
@server_thread&.join(1)
|
|
84
|
+
@connections.each_value { |conn| conn.close rescue nil }
|
|
85
|
+
@connections.clear
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def broadcast(message, exclude: nil, path: nil)
|
|
89
|
+
@connections.each do |id, conn|
|
|
90
|
+
next if exclude && id == exclude
|
|
91
|
+
next if path && conn.path != path
|
|
92
|
+
conn.send_text(message)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def send_to(conn_id, message)
|
|
97
|
+
conn = @connections[conn_id]
|
|
98
|
+
conn&.send_text(message)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def close(conn_id, code: 1000, reason: "")
|
|
102
|
+
conn = @connections[conn_id]
|
|
103
|
+
conn&.close(code: code, reason: reason)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# ── Rooms ──────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
def join_room_for(conn_id, room_name)
|
|
109
|
+
@rooms[room_name] ||= Set.new
|
|
110
|
+
@rooms[room_name].add(conn_id)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def leave_room_for(conn_id, room_name)
|
|
114
|
+
@rooms[room_name]&.delete(conn_id)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def room_count(room_name)
|
|
118
|
+
(@rooms[room_name] || Set.new).size
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Return list of room names a given client/connection id belongs to.
|
|
122
|
+
# Matches PHP Tina4\WebSocket::getClientRooms($clientId).
|
|
123
|
+
def get_client_rooms(client_id)
|
|
124
|
+
@rooms.each_with_object([]) do |(name, members), acc|
|
|
125
|
+
acc << name if members.include?(client_id)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def get_room_connections(room_name)
|
|
130
|
+
ids = @rooms[room_name] || Set.new
|
|
131
|
+
ids.filter_map { |id| @connections[id] }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def broadcast_to_room(room_name, message, exclude: nil)
|
|
135
|
+
(get_room_connections(room_name)).each do |conn|
|
|
136
|
+
next if exclude && conn.id == exclude
|
|
137
|
+
conn.send_text(message)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Register a WebSocket handler for a path (class method, matching Python's
|
|
142
|
+
# WebSocketServer.route). The block receives a WebSocketConnection and should
|
|
143
|
+
# call conn.on_message / conn.on_close to wire up event callbacks.
|
|
144
|
+
#
|
|
145
|
+
# Registers on the Router so routes work in integrated (Rack) mode.
|
|
146
|
+
#
|
|
147
|
+
# Tina4::WebSocket.route("/chat") do |conn|
|
|
148
|
+
# conn.on_message { |data| conn.send(data) }
|
|
149
|
+
# conn.on_close { puts "bye" }
|
|
150
|
+
# end
|
|
151
|
+
#
|
|
152
|
+
def self.route(path, &block)
|
|
153
|
+
@route_handlers ||= {}
|
|
154
|
+
@route_handlers[path] = block
|
|
155
|
+
|
|
156
|
+
# Adapt to Router's (conn, event, data) style
|
|
157
|
+
adapter = proc do |conn, event, data|
|
|
158
|
+
case event
|
|
159
|
+
when :open
|
|
160
|
+
block.call(conn)
|
|
161
|
+
when :message
|
|
162
|
+
conn.on_message_handler&.call(data)
|
|
163
|
+
when :close
|
|
164
|
+
conn.on_close_handler&.call
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
Tina4::Router.websocket(path, &adapter)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def handle_upgrade(env, socket)
|
|
172
|
+
key = env["HTTP_SEC_WEBSOCKET_KEY"]
|
|
173
|
+
return unless key
|
|
174
|
+
|
|
175
|
+
accept = Tina4.compute_accept_key(key)
|
|
176
|
+
|
|
177
|
+
response = "HTTP/1.1 101 Switching Protocols\r\n" \
|
|
178
|
+
"Upgrade: websocket\r\n" \
|
|
179
|
+
"Connection: Upgrade\r\n" \
|
|
180
|
+
"Sec-WebSocket-Accept: #{accept}\r\n\r\n"
|
|
181
|
+
|
|
182
|
+
socket.write(response)
|
|
183
|
+
|
|
184
|
+
conn_id = SecureRandom.hex(16)
|
|
185
|
+
ws_path = env["REQUEST_PATH"] || env["PATH_INFO"] || "/"
|
|
186
|
+
connection = WebSocketConnection.new(conn_id, socket, ws_server: self, path: ws_path)
|
|
187
|
+
@connections[conn_id] = connection
|
|
188
|
+
|
|
189
|
+
emit(:open, connection)
|
|
190
|
+
|
|
191
|
+
Thread.new do
|
|
192
|
+
begin
|
|
193
|
+
loop do
|
|
194
|
+
frame = connection.read_frame
|
|
195
|
+
break unless frame
|
|
196
|
+
|
|
197
|
+
case frame[:opcode]
|
|
198
|
+
when 0x1 # Text
|
|
199
|
+
emit(:message, connection, frame[:data])
|
|
200
|
+
when 0x8 # Close
|
|
201
|
+
break
|
|
202
|
+
when 0x9 # Ping
|
|
203
|
+
connection.send_pong(frame[:data])
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
rescue => e
|
|
207
|
+
emit(:error, connection, e)
|
|
208
|
+
ensure
|
|
209
|
+
@connections.delete(conn_id)
|
|
210
|
+
remove_from_all_rooms(conn_id)
|
|
211
|
+
emit(:close, connection)
|
|
212
|
+
socket.close rescue nil
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def emit(event, *args)
|
|
218
|
+
@handlers[event]&.each { |h| h.call(*args) }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def remove_from_all_rooms(conn_id)
|
|
222
|
+
@rooms.each_value { |members| members.delete(conn_id) }
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
class WebSocketConnection
|
|
227
|
+
attr_reader :id, :rooms
|
|
228
|
+
attr_accessor :params, :path, :on_message_handler, :on_close_handler, :on_error_handler
|
|
229
|
+
|
|
230
|
+
def initialize(id, socket, ws_server: nil, path: "/")
|
|
231
|
+
@id = id
|
|
232
|
+
@socket = socket
|
|
233
|
+
@params = {}
|
|
234
|
+
@ws_server = ws_server
|
|
235
|
+
@path = path
|
|
236
|
+
@rooms = Set.new
|
|
237
|
+
@on_message_handler = nil
|
|
238
|
+
@on_close_handler = nil
|
|
239
|
+
@on_error_handler = nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Register a message handler (decorator style, matching Python).
|
|
243
|
+
def on_message(&block)
|
|
244
|
+
@on_message_handler = block
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Register a close handler (decorator style, matching Python).
|
|
248
|
+
def on_close(&block)
|
|
249
|
+
@on_close_handler = block
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def join_room(room_name)
|
|
253
|
+
@rooms.add(room_name)
|
|
254
|
+
@ws_server&.join_room_for(@id, room_name)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def leave_room(room_name)
|
|
258
|
+
@rooms.delete(room_name)
|
|
259
|
+
@ws_server&.leave_room_for(@id, room_name)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def broadcast_to_room(room_name, message, exclude_self: false)
|
|
263
|
+
return unless @ws_server
|
|
264
|
+
|
|
265
|
+
exclude = exclude_self ? @id : nil
|
|
266
|
+
@ws_server.broadcast_to_room(room_name, message, exclude: exclude)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Broadcast a message to all other connections on the same path
|
|
270
|
+
def broadcast(message, include_self: false)
|
|
271
|
+
return unless @ws_server
|
|
272
|
+
|
|
273
|
+
@ws_server.connections.each do |cid, conn|
|
|
274
|
+
next if !include_self && cid == @id
|
|
275
|
+
next if conn.path != @path
|
|
276
|
+
conn.send_text(message)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def send(message)
|
|
281
|
+
data = message.encode("UTF-8")
|
|
282
|
+
frame = build_frame(0x1, data)
|
|
283
|
+
@socket.write(frame)
|
|
284
|
+
rescue IOError
|
|
285
|
+
# Connection closed
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
alias_method :send_text, :send
|
|
289
|
+
|
|
290
|
+
# Serialize a Hash/Array (or any JSON-coercible value) and send as a text frame.
|
|
291
|
+
# Matches Python/PHP send_json.
|
|
292
|
+
def send_json(data)
|
|
293
|
+
send_text(JSON.generate(data))
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def send_pong(data)
|
|
297
|
+
frame = build_frame(0xA, data || "")
|
|
298
|
+
@socket.write(frame)
|
|
299
|
+
rescue IOError
|
|
300
|
+
# Connection closed
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def close(code: 1000, reason: "")
|
|
304
|
+
payload = [code].pack("n") + reason
|
|
305
|
+
frame = build_frame(0x8, payload)
|
|
306
|
+
@socket.write(frame) rescue nil
|
|
307
|
+
@socket.close rescue nil
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def read_frame
|
|
311
|
+
first_byte = @socket.getbyte
|
|
312
|
+
return nil unless first_byte
|
|
313
|
+
|
|
314
|
+
opcode = first_byte & 0x0F
|
|
315
|
+
second_byte = @socket.getbyte
|
|
316
|
+
return nil unless second_byte
|
|
317
|
+
|
|
318
|
+
masked = (second_byte & 0x80) != 0
|
|
319
|
+
length = second_byte & 0x7F
|
|
320
|
+
|
|
321
|
+
if length == 126
|
|
322
|
+
length = @socket.read(2).unpack1("n")
|
|
323
|
+
elsif length == 127
|
|
324
|
+
length = @socket.read(8).unpack1("Q>")
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
mask_key = masked ? @socket.read(4).bytes : nil
|
|
328
|
+
data = @socket.read(length) || ""
|
|
329
|
+
|
|
330
|
+
if masked && mask_key
|
|
331
|
+
data = data.bytes.each_with_index.map { |b, i| b ^ mask_key[i % 4] }.pack("C*")
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
{ opcode: opcode, data: data }
|
|
335
|
+
rescue IOError, EOFError
|
|
336
|
+
nil
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def build_frame(opcode, data)
|
|
340
|
+
Tina4.build_frame(opcode, data)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|