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.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +935 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -110
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -106
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2025 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +696 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/public/css/tina4.css +2463 -2463
  63. data/lib/tina4/public/css/tina4.min.css +1 -1
  64. data/lib/tina4/public/images/logo.svg +5 -5
  65. data/lib/tina4/public/js/frond.min.js +2 -2
  66. data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
  67. data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
  68. data/lib/tina4/public/js/tina4.min.js +92 -92
  69. data/lib/tina4/public/js/tina4js.min.js +48 -48
  70. data/lib/tina4/public/swagger/index.html +90 -90
  71. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  72. data/lib/tina4/query_builder.rb +380 -380
  73. data/lib/tina4/queue.rb +366 -366
  74. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  75. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  76. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  77. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  78. data/lib/tina4/rack_app.rb +817 -817
  79. data/lib/tina4/rate_limiter.rb +130 -130
  80. data/lib/tina4/request.rb +268 -255
  81. data/lib/tina4/response.rb +346 -346
  82. data/lib/tina4/response_cache.rb +551 -551
  83. data/lib/tina4/router.rb +406 -406
  84. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  85. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  86. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  87. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  88. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  89. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  90. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  91. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  92. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  93. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  94. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  95. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  96. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  97. data/lib/tina4/scss/tina4css/base.scss +1 -1
  98. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  99. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  100. data/lib/tina4/scss_compiler.rb +178 -178
  101. data/lib/tina4/seeder.rb +567 -567
  102. data/lib/tina4/service_runner.rb +303 -303
  103. data/lib/tina4/session.rb +297 -297
  104. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  105. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  106. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  107. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  108. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  109. data/lib/tina4/shutdown.rb +84 -84
  110. data/lib/tina4/sql_translation.rb +158 -158
  111. data/lib/tina4/swagger.rb +124 -124
  112. data/lib/tina4/template.rb +894 -894
  113. data/lib/tina4/templates/base.twig +26 -26
  114. data/lib/tina4/templates/errors/302.twig +14 -14
  115. data/lib/tina4/templates/errors/401.twig +9 -9
  116. data/lib/tina4/templates/errors/403.twig +29 -29
  117. data/lib/tina4/templates/errors/404.twig +29 -29
  118. data/lib/tina4/templates/errors/500.twig +38 -38
  119. data/lib/tina4/templates/errors/502.twig +9 -9
  120. data/lib/tina4/templates/errors/503.twig +12 -12
  121. data/lib/tina4/templates/errors/base.twig +37 -37
  122. data/lib/tina4/test_client.rb +159 -159
  123. data/lib/tina4/testing.rb +340 -340
  124. data/lib/tina4/validator.rb +174 -174
  125. data/lib/tina4/version.rb +1 -1
  126. data/lib/tina4/webserver.rb +312 -312
  127. data/lib/tina4/websocket.rb +343 -343
  128. data/lib/tina4/websocket_backplane.rb +190 -190
  129. data/lib/tina4/wsdl.rb +564 -564
  130. data/lib/tina4.rb +458 -458
  131. data/lib/tina4ruby.rb +4 -4
  132. metadata +3 -3
@@ -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