tina4ruby 3.13.36 → 3.13.38
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/lib/tina4/auth.rb +118 -7
- data/lib/tina4/cli.rb +106 -2
- data/lib/tina4/database.rb +356 -46
- data/lib/tina4/dev_admin.rb +54 -11
- data/lib/tina4/drivers/sqlite_driver.rb +23 -0
- data/lib/tina4/env.rb +40 -4
- data/lib/tina4/events.rb +54 -8
- data/lib/tina4/graphql.rb +68 -12
- data/lib/tina4/html_element.rb +55 -7
- data/lib/tina4/mcp.rb +10 -3
- data/lib/tina4/messenger.rb +130 -25
- data/lib/tina4/metrics.rb +238 -47
- data/lib/tina4/middleware.rb +136 -13
- data/lib/tina4/migration.rb +6 -4
- data/lib/tina4/orm.rb +13 -10
- data/lib/tina4/public/js/tina4-dev-admin.js +212 -212
- data/lib/tina4/public/js/tina4-dev-admin.min.js +212 -212
- data/lib/tina4/rack_app.rb +17 -10
- data/lib/tina4/response.rb +31 -11
- data/lib/tina4/seeder.rb +433 -84
- data/lib/tina4/session.rb +94 -17
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +354 -18
- data/lib/tina4/wsdl.rb +25 -2
- data/lib/tina4.rb +11 -9
- metadata +6 -47
data/lib/tina4/session.rb
CHANGED
|
@@ -21,7 +21,16 @@ module Tina4
|
|
|
21
21
|
if !options.key?(:cookie_name) && env_name && !env_name.empty?
|
|
22
22
|
@options[:cookie_name] = env_name
|
|
23
23
|
end
|
|
24
|
-
|
|
24
|
+
# No guessable built-in secret. The session never signs with this value
|
|
25
|
+
# (IDs are SecureRandom.hex(32)), so we resolve it from TINA4_SECRET only
|
|
26
|
+
# — nil when unset. This honours the framework's blank-secret discipline
|
|
27
|
+
# (Auth.ensure_dev_secret never uses a guessable default); Python/Node
|
|
28
|
+
# sessions carry no secret field at all.
|
|
29
|
+
@options[:secret] ||= ENV["TINA4_SECRET"]
|
|
30
|
+
# Backend-failure policy strict flag (parity with Python's
|
|
31
|
+
# TINA4_SESSION_STRICT). When truthy, read/write/destroy/gc failures
|
|
32
|
+
# RE-RAISE instead of logging + degrading.
|
|
33
|
+
@strict = Tina4::Env.is_truthy(ENV["TINA4_SESSION_STRICT"])
|
|
25
34
|
@handler = create_handler
|
|
26
35
|
@id = extract_session_id(env) || SecureRandom.hex(32)
|
|
27
36
|
@data = load_session
|
|
@@ -51,14 +60,23 @@ module Tina4
|
|
|
51
60
|
@data.dup
|
|
52
61
|
end
|
|
53
62
|
|
|
63
|
+
# Persist the session if dirty. On a backend write failure the error is
|
|
64
|
+
# logged and false is returned — the @modified (dirty) flag is RETAINED so
|
|
65
|
+
# a later save can retry. Returns true on a successful (or no-op) write.
|
|
54
66
|
def save
|
|
55
|
-
return unless @modified
|
|
56
|
-
|
|
57
|
-
|
|
67
|
+
return true unless @modified
|
|
68
|
+
if safe_write(@id, @data)
|
|
69
|
+
@modified = false
|
|
70
|
+
true
|
|
71
|
+
else
|
|
72
|
+
false # dirty flag retained for retry
|
|
73
|
+
end
|
|
58
74
|
end
|
|
59
75
|
|
|
76
|
+
# Destroy the current session. Should be called right after login or any
|
|
77
|
+
# privilege change to defend against session fixation (see #regenerate).
|
|
60
78
|
def destroy
|
|
61
|
-
|
|
79
|
+
safe_destroy(@id)
|
|
62
80
|
@data = {}
|
|
63
81
|
end
|
|
64
82
|
|
|
@@ -104,12 +122,17 @@ module Tina4
|
|
|
104
122
|
result.nil? ? default : result
|
|
105
123
|
end
|
|
106
124
|
|
|
107
|
-
# Regenerate the session ID while preserving data — returns new ID
|
|
125
|
+
# Regenerate the session ID while preserving data — returns the new ID.
|
|
126
|
+
# Call this right after login or any privilege change to defend against
|
|
127
|
+
# session fixation (a pre-auth session ID must not survive into the
|
|
128
|
+
# authenticated session). Destroys the old backend record (best-effort)
|
|
129
|
+
# and persists under the new ID.
|
|
108
130
|
def regenerate
|
|
109
131
|
old_id = @id
|
|
110
132
|
@id = SecureRandom.hex(32)
|
|
111
|
-
|
|
133
|
+
safe_destroy(old_id)
|
|
112
134
|
@modified = true
|
|
135
|
+
save
|
|
113
136
|
@id
|
|
114
137
|
end
|
|
115
138
|
|
|
@@ -133,24 +156,27 @@ module Tina4
|
|
|
133
156
|
end
|
|
134
157
|
|
|
135
158
|
# Reads raw session data for a given session ID from backend storage.
|
|
136
|
-
# Returns the data hash or
|
|
159
|
+
# Returns the data hash, or {} on a backend failure (logged + degraded).
|
|
137
160
|
def read(session_id)
|
|
138
|
-
|
|
161
|
+
safe_read(session_id)
|
|
139
162
|
end
|
|
140
163
|
|
|
141
164
|
# Writes raw session data for a given session ID to backend storage.
|
|
165
|
+
# Returns true on success, false on a backend failure (logged + degraded).
|
|
142
166
|
def write(session_id, data, ttl = nil)
|
|
143
|
-
|
|
144
|
-
@handler.write(session_id, data, ttl)
|
|
145
|
-
else
|
|
146
|
-
@handler.write(session_id, data)
|
|
147
|
-
end
|
|
167
|
+
safe_write(session_id, data, ttl)
|
|
148
168
|
end
|
|
149
169
|
|
|
150
|
-
# Garbage collection: remove expired sessions from the handler
|
|
170
|
+
# Garbage collection: remove expired sessions from the handler.
|
|
171
|
+
# A backend failure is logged and swallowed (never crashes the request).
|
|
151
172
|
def gc(max_lifetime = nil)
|
|
173
|
+
return unless @handler.respond_to?(:gc)
|
|
152
174
|
max_lifetime ||= @options[:max_age]
|
|
153
|
-
@handler.gc(max_lifetime)
|
|
175
|
+
@handler.gc(max_lifetime)
|
|
176
|
+
rescue StandardError => e
|
|
177
|
+
log_backend_error("gc", e)
|
|
178
|
+
raise if @strict
|
|
179
|
+
nil
|
|
154
180
|
end
|
|
155
181
|
|
|
156
182
|
def cookie_header(cookie_name = nil)
|
|
@@ -181,8 +207,59 @@ module Tina4
|
|
|
181
207
|
end
|
|
182
208
|
|
|
183
209
|
def load_session
|
|
184
|
-
|
|
210
|
+
safe_read(@id)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# ── Backend-failure policy (parity with Python's Session boundary) ──
|
|
214
|
+
#
|
|
215
|
+
# Centralised here, NOT in each handler, so every backend (file, redis,
|
|
216
|
+
# valkey, mongo, database) shares one policy. The rule:
|
|
217
|
+
# read failure → log + return {} (empty session, never a 500)
|
|
218
|
+
# write failure → log + return false (caller retains dirty for retry)
|
|
219
|
+
# destroy failure → log + swallow (return false)
|
|
220
|
+
# gc failure → log + swallow (see #gc)
|
|
221
|
+
# A genuinely-empty but HEALTHY backend (handler returns nil/{} WITHOUT
|
|
222
|
+
# raising) is NOT a failure and logs nothing. TINA4_SESSION_STRICT=true
|
|
223
|
+
# re-raises instead of degrading.
|
|
224
|
+
|
|
225
|
+
def safe_read(session_id)
|
|
226
|
+
existing = @handler.read(session_id)
|
|
185
227
|
existing || {}
|
|
228
|
+
rescue StandardError => e
|
|
229
|
+
log_backend_error("read", e)
|
|
230
|
+
raise if @strict
|
|
231
|
+
{}
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def safe_write(session_id, data, ttl = nil)
|
|
235
|
+
if ttl
|
|
236
|
+
@handler.write(session_id, data, ttl)
|
|
237
|
+
else
|
|
238
|
+
@handler.write(session_id, data)
|
|
239
|
+
end
|
|
240
|
+
true
|
|
241
|
+
rescue StandardError => e
|
|
242
|
+
log_backend_error("write", e)
|
|
243
|
+
raise if @strict
|
|
244
|
+
false
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def safe_destroy(session_id)
|
|
248
|
+
@handler.destroy(session_id)
|
|
249
|
+
true
|
|
250
|
+
rescue StandardError => e
|
|
251
|
+
log_backend_error("destroy", e)
|
|
252
|
+
raise if @strict
|
|
253
|
+
false
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Single source of the backend-failure log line. Names the operation and
|
|
257
|
+
# the concrete handler class so ops can see WHICH backend failed.
|
|
258
|
+
def log_backend_error(operation, error)
|
|
259
|
+
handler_class = @handler.class.name
|
|
260
|
+
Tina4::Log.error("Session #{operation} failed (#{handler_class}): #{error.message}")
|
|
261
|
+
rescue StandardError
|
|
262
|
+
warn("Session #{operation} failed: #{error.message}")
|
|
186
263
|
end
|
|
187
264
|
|
|
188
265
|
def create_handler
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4/websocket.rb
CHANGED
|
@@ -4,15 +4,44 @@ require "digest"
|
|
|
4
4
|
require "base64"
|
|
5
5
|
require "json"
|
|
6
6
|
require "set"
|
|
7
|
+
require "securerandom"
|
|
7
8
|
|
|
8
9
|
module Tina4
|
|
9
10
|
WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-5AB5DC11AD37"
|
|
10
11
|
|
|
12
|
+
# Shared pub/sub channel name + envelope shape for the WebSocket backplane.
|
|
13
|
+
# MUST stay byte-identical across all four frameworks (Python/PHP/Ruby/Node)
|
|
14
|
+
# so a broadcast published by one framework's instance is relayed by another.
|
|
15
|
+
WEBSOCKET_BACKPLANE_CHANNEL = "tina4:ws"
|
|
16
|
+
|
|
11
17
|
# Compute Sec-WebSocket-Accept from Sec-WebSocket-Key per RFC 6455.
|
|
12
18
|
def self.compute_accept_key(key)
|
|
13
19
|
Base64.strict_encode64(Digest::SHA1.digest("#{key}#{WEBSOCKET_GUID}"))
|
|
14
20
|
end
|
|
15
21
|
|
|
22
|
+
# Return true if the request's Origin is permitted to upgrade to a WebSocket.
|
|
23
|
+
#
|
|
24
|
+
# Controlled by TINA4_WS_ALLOWED_ORIGINS (comma-separated exact origins, e.g.
|
|
25
|
+
# "https://app.example.com,https://admin.example.com").
|
|
26
|
+
#
|
|
27
|
+
# Empty/unset = allow ALL origins (current behaviour, non-breaking). When set,
|
|
28
|
+
# only requests whose Origin exactly matches a listed value are allowed; a
|
|
29
|
+
# missing Origin header is rejected once the allow-list is active.
|
|
30
|
+
#
|
|
31
|
+
# +headers+ is a Hash. The Origin is looked up case-insensitively across both
|
|
32
|
+
# the Rack-style "HTTP_ORIGIN" key and a plain "origin"/"Origin" key so the
|
|
33
|
+
# same helper serves the rack_app upgrade path and direct callers/tests.
|
|
34
|
+
def self.websocket_origin_allowed?(headers)
|
|
35
|
+
raw = (ENV["TINA4_WS_ALLOWED_ORIGINS"] || "").strip
|
|
36
|
+
return true if raw.empty? # No allow-list configured — permit everything.
|
|
37
|
+
|
|
38
|
+
allowed = raw.split(",").map(&:strip).reject(&:empty?)
|
|
39
|
+
return true if allowed.empty?
|
|
40
|
+
|
|
41
|
+
origin = headers["HTTP_ORIGIN"] || headers["origin"] || headers["Origin"]
|
|
42
|
+
allowed.include?(origin)
|
|
43
|
+
end
|
|
44
|
+
|
|
16
45
|
# Build a WebSocket frame (server→client, never masked).
|
|
17
46
|
def self.build_frame(opcode, data, fin: true)
|
|
18
47
|
first_byte = (fin ? 0x80 : 0x00) | opcode
|
|
@@ -44,8 +73,24 @@ module Tina4
|
|
|
44
73
|
error: []
|
|
45
74
|
}
|
|
46
75
|
@rooms = {} # room_name => Set of conn_ids
|
|
76
|
+
|
|
77
|
+
# ── Backplane (multi-instance scaling) ──────────────────────
|
|
78
|
+
# Lazily wired on first broadcast (see ensure_backplane). Each instance
|
|
79
|
+
# owns a stable id so it can ignore its own echoes coming back over the
|
|
80
|
+
# shared pub/sub channel (the origin guard). The backplane listener runs
|
|
81
|
+
# in its own Ruby thread, so the connections structure is guarded by a
|
|
82
|
+
# mutex shared with the broadcast path.
|
|
83
|
+
@backplane = nil
|
|
84
|
+
@backplane_started = false
|
|
85
|
+
@instance_id = SecureRandom.hex(8)
|
|
86
|
+
@backplane_channel = Tina4::WEBSOCKET_BACKPLANE_CHANNEL
|
|
87
|
+
@conn_mutex = Mutex.new
|
|
47
88
|
end
|
|
48
89
|
|
|
90
|
+
# Stable per-process id used as the backplane envelope "src" so an instance
|
|
91
|
+
# drops its own echoes. Exposed for tests / introspection.
|
|
92
|
+
attr_reader :instance_id, :backplane_channel
|
|
93
|
+
|
|
49
94
|
def on(event, &block)
|
|
50
95
|
@handlers[event.to_sym] << block if @handlers.key?(event.to_sym)
|
|
51
96
|
end
|
|
@@ -86,16 +131,31 @@ module Tina4
|
|
|
86
131
|
end
|
|
87
132
|
|
|
88
133
|
def broadcast(message, exclude: nil, path: nil)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
134
|
+
ensure_backplane
|
|
135
|
+
targets = @conn_mutex.synchronize do
|
|
136
|
+
@connections.select do |id, conn|
|
|
137
|
+
!(exclude && id == exclude) && !(path && conn.path != path)
|
|
138
|
+
end.values
|
|
93
139
|
end
|
|
140
|
+
deliver_resilient(targets, message)
|
|
141
|
+
publish_envelope(path ? "path" : "all", message, path: path, exclude: exclude)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Send to ALL connections (no path filter). Resilient + backplane-fanned.
|
|
145
|
+
def broadcast_all(message, exclude: nil)
|
|
146
|
+
ensure_backplane
|
|
147
|
+
targets = @conn_mutex.synchronize do
|
|
148
|
+
@connections.reject { |id, _| exclude && id == exclude }.values
|
|
149
|
+
end
|
|
150
|
+
deliver_resilient(targets, message)
|
|
151
|
+
publish_envelope("all", message, exclude: exclude)
|
|
94
152
|
end
|
|
95
153
|
|
|
96
154
|
def send_to(conn_id, message)
|
|
97
|
-
conn = @connections[conn_id]
|
|
98
|
-
conn
|
|
155
|
+
conn = @conn_mutex.synchronize { @connections[conn_id] }
|
|
156
|
+
return unless conn
|
|
157
|
+
|
|
158
|
+
prune(conn) unless safe_send(conn, message)
|
|
99
159
|
end
|
|
100
160
|
|
|
101
161
|
def close(conn_id, code: 1000, reason: "")
|
|
@@ -136,12 +196,250 @@ module Tina4
|
|
|
136
196
|
end
|
|
137
197
|
|
|
138
198
|
def broadcast_to_room(room_name, message, exclude: nil)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
199
|
+
ensure_backplane
|
|
200
|
+
targets = get_room_connections(room_name).reject { |conn| exclude && conn.id == exclude }
|
|
201
|
+
deliver_resilient(targets, message)
|
|
202
|
+
publish_envelope("room", message, room: room_name, exclude: exclude)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Register an open connection on this manager (thread-safe).
|
|
206
|
+
def register_connection(connection)
|
|
207
|
+
@conn_mutex.synchronize { @connections[connection.id] = connection }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Drop a connection on close (thread-safe) and remove it from all rooms.
|
|
211
|
+
def unregister_connection(conn_id)
|
|
212
|
+
@conn_mutex.synchronize { @connections.delete(conn_id) }
|
|
213
|
+
remove_from_all_rooms(conn_id)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# ── Broadcast resilience ──────────────────────────────────
|
|
217
|
+
#
|
|
218
|
+
# One dead/slow client must never abort delivery to the rest. deliver_resilient
|
|
219
|
+
# walks the target list, wraps each send, logs+prunes anything that throws,
|
|
220
|
+
# and keeps going.
|
|
221
|
+
|
|
222
|
+
# Send to ONE connection without letting a single dead client abort a
|
|
223
|
+
# broadcast loop. Returns true if delivered, false if the connection looks
|
|
224
|
+
# dead (the caller then prunes it). A failed send is logged, never silent.
|
|
225
|
+
# A connection whose own #send swallowed a write error and flipped #closed?
|
|
226
|
+
# is treated as dead too (mirrors Python's `return not ws._closed`).
|
|
227
|
+
def safe_send(conn, message)
|
|
228
|
+
conn.send_text(message)
|
|
229
|
+
# If the connection's own #send swallowed a write error it flips #closed?;
|
|
230
|
+
# treat that as dead. Probe defensively so a stub/double that doesn't
|
|
231
|
+
# define #closed? is simply treated as alive.
|
|
232
|
+
dead = begin
|
|
233
|
+
conn.respond_to?(:closed?) && conn.closed?
|
|
234
|
+
rescue StandardError
|
|
235
|
+
false
|
|
236
|
+
end
|
|
237
|
+
!dead
|
|
238
|
+
rescue StandardError => e
|
|
239
|
+
Tina4::Log.warning("WebSocket send to #{conn.id} failed, pruning: #{e.message}") if defined?(Tina4::Log)
|
|
240
|
+
false
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Deliver +message+ to every connection in +targets+, pruning any that fail.
|
|
244
|
+
def deliver_resilient(targets, message)
|
|
245
|
+
dead = targets.reject { |conn| safe_send(conn, message) }
|
|
246
|
+
dead.each { |conn| prune(conn) }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Remove a (presumed dead) connection from the manager + all rooms.
|
|
250
|
+
def prune(conn)
|
|
251
|
+
id = conn.respond_to?(:id) ? conn.id : nil
|
|
252
|
+
return unless id
|
|
253
|
+
|
|
254
|
+
@conn_mutex.synchronize { @connections.delete(id) }
|
|
255
|
+
remove_from_all_rooms(id)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# ── Idle reaper ───────────────────────────────────────────
|
|
259
|
+
#
|
|
260
|
+
# Opt-in via TINA4_WS_IDLE_TIMEOUT (seconds; 0/unset = disabled = current
|
|
261
|
+
# behaviour). Tracks last_activity per connection (bumped on every inbound
|
|
262
|
+
# frame in handle_upgrade); reap_idle closes/prunes anything idle past the
|
|
263
|
+
# timeout.
|
|
264
|
+
|
|
265
|
+
# Close connections whose last inbound frame is older than +timeout+ seconds.
|
|
266
|
+
# Returns the number reaped. timeout <= 0 is a no-op. Connections without a
|
|
267
|
+
# last_activity are skipped.
|
|
268
|
+
def reap_idle(timeout)
|
|
269
|
+
return 0 if timeout.to_f <= 0
|
|
270
|
+
|
|
271
|
+
now = Time.now.to_f
|
|
272
|
+
stale = @conn_mutex.synchronize do
|
|
273
|
+
@connections.values.select do |conn|
|
|
274
|
+
la = conn.respond_to?(:last_activity) ? conn.last_activity : nil
|
|
275
|
+
la && (now - la) > timeout
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
stale.each do |conn|
|
|
279
|
+
conn.close(code: 1001, reason: "idle timeout") rescue nil
|
|
280
|
+
prune(conn)
|
|
281
|
+
end
|
|
282
|
+
Tina4::Log.info("WebSocket idle reaper closed #{stale.size} connection(s)") if stale.any? && defined?(Tina4::Log)
|
|
283
|
+
stale.size
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Read the configured idle timeout (seconds). 0 = disabled.
|
|
287
|
+
def idle_timeout
|
|
288
|
+
Float(ENV["TINA4_WS_IDLE_TIMEOUT"] || "0")
|
|
289
|
+
rescue ArgumentError, TypeError
|
|
290
|
+
0.0
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Spin up a background reaper thread when an idle timeout is configured.
|
|
294
|
+
# Opt-in and non-breaking — unset/0 means no thread is created.
|
|
295
|
+
def start_idle_reaper
|
|
296
|
+
timeout = idle_timeout
|
|
297
|
+
return if timeout <= 0 || @reaper_thread&.alive?
|
|
298
|
+
|
|
299
|
+
interval = [1.0, timeout / 2.0].max
|
|
300
|
+
@reaper_running = true
|
|
301
|
+
@reaper_thread = Thread.new do
|
|
302
|
+
while @reaper_running
|
|
303
|
+
sleep interval
|
|
304
|
+
begin
|
|
305
|
+
reap_idle(timeout)
|
|
306
|
+
rescue StandardError => e
|
|
307
|
+
Tina4::Log.error("WebSocket idle reaper sweep failed: #{e.message}") if defined?(Tina4::Log)
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def stop_idle_reaper
|
|
314
|
+
@reaper_running = false
|
|
315
|
+
@reaper_thread&.kill
|
|
316
|
+
@reaper_thread = nil
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# ── Backplane (multi-instance scaling) ────────────────────
|
|
320
|
+
#
|
|
321
|
+
# When TINA4_WS_BACKPLANE is configured, every broadcast is ALSO published
|
|
322
|
+
# to a shared pub/sub channel so sibling instances relay it to their own
|
|
323
|
+
# local connections. Flow:
|
|
324
|
+
#
|
|
325
|
+
# instance A: broadcast → deliver locally → publish_envelope → channel
|
|
326
|
+
# │
|
|
327
|
+
# instance B: backplane bg-THREAD → on_backplane_message ─────────┘
|
|
328
|
+
# │ (origin guard drops A's echoes by src id)
|
|
329
|
+
# └→ relay_local → B's LOCAL connections only (no re-publish)
|
|
330
|
+
#
|
|
331
|
+
# The subscribe callback runs in the backplane's background Ruby thread, so
|
|
332
|
+
# it touches @connections under @conn_mutex (shared with the broadcast path).
|
|
333
|
+
# The relay NEVER re-publishes (that would loop the cluster).
|
|
334
|
+
|
|
335
|
+
# Lazily wire the configured backplane. Idempotent and best-effort — a
|
|
336
|
+
# failure logs and leaves the manager local-only; it must NEVER crash a
|
|
337
|
+
# broadcast.
|
|
338
|
+
def ensure_backplane
|
|
339
|
+
return if @backplane_started
|
|
340
|
+
|
|
341
|
+
# Set immediately so we only ever attempt the wiring once, even on failure
|
|
342
|
+
# (no retry storm on every broadcast).
|
|
343
|
+
@backplane_started = true
|
|
344
|
+
begin
|
|
345
|
+
backplane = Tina4::WebSocketBackplane.create_backplane
|
|
346
|
+
return if backplane.nil? # No backplane configured — stay local-only.
|
|
347
|
+
|
|
348
|
+
@backplane = backplane
|
|
349
|
+
@backplane.subscribe(@backplane_channel) { |raw| on_backplane_message(raw) }
|
|
350
|
+
Tina4::Log.info("WebSocket backplane active (instance #{@instance_id}, channel '#{@backplane_channel}')") if defined?(Tina4::Log)
|
|
351
|
+
rescue StandardError => e
|
|
352
|
+
@backplane = nil
|
|
353
|
+
Tina4::Log.error("WebSocket backplane wiring failed, continuing local-only: #{e.message}") if defined?(Tina4::Log)
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Receive a raw envelope from the backplane. Runs in the backplane's
|
|
358
|
+
# BACKGROUND THREAD.
|
|
359
|
+
def on_backplane_message(raw)
|
|
360
|
+
env = begin
|
|
361
|
+
JSON.parse(raw)
|
|
362
|
+
rescue JSON::ParserError, TypeError
|
|
363
|
+
return
|
|
364
|
+
end
|
|
365
|
+
return unless env.is_a?(Hash)
|
|
366
|
+
|
|
367
|
+
# Origin guard: ignore our own broadcasts echoed back over the channel.
|
|
368
|
+
# We already delivered them locally; relaying again would double-send.
|
|
369
|
+
return if env["src"] == @instance_id
|
|
370
|
+
|
|
371
|
+
relay_local(env)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Deliver a remote-originated envelope to LOCAL connections only. NEVER
|
|
375
|
+
# re-publishes (that would loop the message around the cluster). Dispatches
|
|
376
|
+
# by "kind": room / path / all.
|
|
377
|
+
def relay_local(env)
|
|
378
|
+
message = decode_envelope_message(env)
|
|
379
|
+
return if message.nil?
|
|
380
|
+
|
|
381
|
+
exclude = env["exclude"]
|
|
382
|
+
targets =
|
|
383
|
+
case env["kind"]
|
|
384
|
+
when "room"
|
|
385
|
+
room = env["room"]
|
|
386
|
+
room ? get_room_connections(room) : []
|
|
387
|
+
when "path"
|
|
388
|
+
path = env["path"]
|
|
389
|
+
path ? @conn_mutex.synchronize { @connections.values.select { |c| c.path == path } } : []
|
|
390
|
+
else # "all" (and anything unknown) → every local connection
|
|
391
|
+
@conn_mutex.synchronize { @connections.values }
|
|
392
|
+
end
|
|
393
|
+
targets = targets.reject { |conn| exclude && conn.id == exclude }
|
|
394
|
+
deliver_resilient(targets, message)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Publish a broadcast to the shared channel for sibling instances. No-op
|
|
398
|
+
# when no backplane is configured. Best-effort — a publish failure logs and
|
|
399
|
+
# is swallowed so the local broadcast that already happened is never undone
|
|
400
|
+
# by a flaky message bus.
|
|
401
|
+
def publish_envelope(kind, message, room: nil, path: nil, exclude: nil)
|
|
402
|
+
return unless @backplane
|
|
403
|
+
|
|
404
|
+
envelope = {
|
|
405
|
+
"src" => @instance_id,
|
|
406
|
+
"kind" => kind,
|
|
407
|
+
"exclude" => exclude,
|
|
408
|
+
"room" => room,
|
|
409
|
+
"path" => path
|
|
410
|
+
}
|
|
411
|
+
# JSON can't carry raw bytes — encode binary as base64, text as text.
|
|
412
|
+
if binary_payload?(message)
|
|
413
|
+
envelope["b64"] = Base64.strict_encode64(message.b)
|
|
414
|
+
else
|
|
415
|
+
envelope["text"] = message
|
|
416
|
+
end
|
|
417
|
+
begin
|
|
418
|
+
@backplane.publish(@backplane_channel, JSON.generate(envelope))
|
|
419
|
+
rescue StandardError => e
|
|
420
|
+
Tina4::Log.warning("WebSocket backplane publish failed: #{e.message}") if defined?(Tina4::Log)
|
|
142
421
|
end
|
|
143
422
|
end
|
|
144
423
|
|
|
424
|
+
# Reconstruct the original str/bytes message from an envelope. JSON can't
|
|
425
|
+
# carry bytes, so text → {"text": ...} and bytes → {"b64": base64(...)}.
|
|
426
|
+
def decode_envelope_message(env)
|
|
427
|
+
return env["text"] if env.key?("text")
|
|
428
|
+
return Base64.strict_decode64(env["b64"]).b if env.key?("b64")
|
|
429
|
+
|
|
430
|
+
nil
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# A message is "binary" when it is an ASCII-8BIT (BINARY-encoded) string.
|
|
434
|
+
# Ruby has no separate bytes type, so a caller's choice of the BINARY
|
|
435
|
+
# encoding is the signal to treat the payload as bytes: it goes through
|
|
436
|
+
# base64 so it survives the JSON envelope AND arrives with its binary
|
|
437
|
+
# encoding intact on the relaying instance (a JSON "text" value would come
|
|
438
|
+
# back as UTF-8, silently re-encoding binary data).
|
|
439
|
+
def binary_payload?(message)
|
|
440
|
+
message.is_a?(String) && message.encoding == Encoding::ASCII_8BIT
|
|
441
|
+
end
|
|
442
|
+
|
|
145
443
|
# Register a WebSocket handler for a path (class method, matching Python's
|
|
146
444
|
# WebSocketServer.route). The block receives a WebSocketConnection and should
|
|
147
445
|
# call conn.on_message / conn.on_close to wire up event callbacks.
|
|
@@ -172,10 +470,26 @@ module Tina4
|
|
|
172
470
|
Tina4::Router.websocket(path, &adapter)
|
|
173
471
|
end
|
|
174
472
|
|
|
175
|
-
|
|
473
|
+
# Upgrade a raw socket to a WebSocket connection and run its frame loop.
|
|
474
|
+
#
|
|
475
|
+
# +manager+ is the engine that should OWN the connection (its @connections /
|
|
476
|
+
# rooms / backplane / idle reaper). It defaults to +self+. In integrated
|
|
477
|
+
# (Rack) mode the rack_app passes a process-wide shared engine here so that
|
|
478
|
+
# broadcasts, rooms and the backplane span every route's connections even
|
|
479
|
+
# though each upgrade keeps its own isolated event handlers on +self+.
|
|
480
|
+
def handle_upgrade(env, socket, manager: self)
|
|
176
481
|
key = env["HTTP_SEC_WEBSOCKET_KEY"]
|
|
177
482
|
return unless key
|
|
178
483
|
|
|
484
|
+
# Origin allow-list (opt-in via TINA4_WS_ALLOWED_ORIGINS). Unset = allow
|
|
485
|
+
# all, so this never breaks an existing deployment. When set, an upgrade
|
|
486
|
+
# from a non-listed Origin is refused with a 403 before the handshake.
|
|
487
|
+
unless Tina4.websocket_origin_allowed?(env)
|
|
488
|
+
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n") rescue nil
|
|
489
|
+
socket.close rescue nil
|
|
490
|
+
return
|
|
491
|
+
end
|
|
492
|
+
|
|
179
493
|
accept = Tina4.compute_accept_key(key)
|
|
180
494
|
|
|
181
495
|
response = "HTTP/1.1 101 Switching Protocols\r\n" \
|
|
@@ -187,8 +501,12 @@ module Tina4
|
|
|
187
501
|
|
|
188
502
|
conn_id = SecureRandom.hex(16)
|
|
189
503
|
ws_path = env["REQUEST_PATH"] || env["PATH_INFO"] || "/"
|
|
190
|
-
connection = WebSocketConnection.new(conn_id, socket, ws_server:
|
|
191
|
-
|
|
504
|
+
connection = WebSocketConnection.new(conn_id, socket, ws_server: manager, path: ws_path)
|
|
505
|
+
manager.register_connection(connection)
|
|
506
|
+
|
|
507
|
+
# Start the idle reaper lazily once we actually have a connection (opt-in
|
|
508
|
+
# via TINA4_WS_IDLE_TIMEOUT; a no-op when unset/0).
|
|
509
|
+
manager.start_idle_reaper
|
|
192
510
|
|
|
193
511
|
emit(:open, connection)
|
|
194
512
|
|
|
@@ -198,6 +516,8 @@ module Tina4
|
|
|
198
516
|
frame = connection.read_frame
|
|
199
517
|
break unless frame
|
|
200
518
|
|
|
519
|
+
connection.touch # mark activity for the idle reaper
|
|
520
|
+
|
|
201
521
|
case frame[:opcode]
|
|
202
522
|
when 0x1 # Text
|
|
203
523
|
emit(:message, connection, frame[:data])
|
|
@@ -210,8 +530,7 @@ module Tina4
|
|
|
210
530
|
rescue => e
|
|
211
531
|
emit(:error, connection, e)
|
|
212
532
|
ensure
|
|
213
|
-
|
|
214
|
-
remove_from_all_rooms(conn_id)
|
|
533
|
+
manager.unregister_connection(conn_id)
|
|
215
534
|
emit(:close, connection)
|
|
216
535
|
socket.close rescue nil
|
|
217
536
|
end
|
|
@@ -228,7 +547,7 @@ module Tina4
|
|
|
228
547
|
end
|
|
229
548
|
|
|
230
549
|
class WebSocketConnection
|
|
231
|
-
attr_reader :id, :rooms
|
|
550
|
+
attr_reader :id, :rooms, :last_activity
|
|
232
551
|
attr_accessor :params, :path, :on_message_handler, :on_close_handler, :on_error_handler
|
|
233
552
|
|
|
234
553
|
def initialize(id, socket, ws_server: nil, path: "/")
|
|
@@ -241,6 +560,14 @@ module Tina4
|
|
|
241
560
|
@on_message_handler = nil
|
|
242
561
|
@on_close_handler = nil
|
|
243
562
|
@on_error_handler = nil
|
|
563
|
+
# Updated on every inbound frame; the idle reaper closes connections that
|
|
564
|
+
# have been silent longer than TINA4_WS_IDLE_TIMEOUT (opt-in).
|
|
565
|
+
@last_activity = Time.now.to_f
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
# Mark inbound activity for the idle reaper.
|
|
569
|
+
def touch
|
|
570
|
+
@last_activity = Time.now.to_f
|
|
244
571
|
end
|
|
245
572
|
|
|
246
573
|
# Register a message handler (decorator style, matching Python).
|
|
@@ -281,12 +608,21 @@ module Tina4
|
|
|
281
608
|
end
|
|
282
609
|
end
|
|
283
610
|
|
|
611
|
+
# True once a write has failed (broken pipe / closed socket). The manager's
|
|
612
|
+
# resilient broadcast path uses this to prune dead connections.
|
|
613
|
+
def closed?
|
|
614
|
+
@closed == true
|
|
615
|
+
end
|
|
616
|
+
|
|
284
617
|
def send(message)
|
|
285
|
-
data = message.
|
|
618
|
+
data = message.is_a?(String) ? message : message.to_s
|
|
619
|
+
# Text frames must be valid UTF-8; binary payloads are sent verbatim.
|
|
620
|
+
data = data.encode("UTF-8") if data.encoding != Encoding::ASCII_8BIT
|
|
286
621
|
frame = build_frame(0x1, data)
|
|
287
622
|
@socket.write(frame)
|
|
288
623
|
rescue IOError
|
|
289
|
-
# Connection closed
|
|
624
|
+
# Connection closed — mark dead so the broadcast path prunes it.
|
|
625
|
+
@closed = true
|
|
290
626
|
end
|
|
291
627
|
|
|
292
628
|
alias_method :send_text, :send
|
|
@@ -301,7 +637,7 @@ module Tina4
|
|
|
301
637
|
frame = build_frame(0xA, data || "")
|
|
302
638
|
@socket.write(frame)
|
|
303
639
|
rescue IOError
|
|
304
|
-
|
|
640
|
+
@closed = true
|
|
305
641
|
end
|
|
306
642
|
|
|
307
643
|
def close(code: 1000, reason: "")
|