supabase-rb 3.1.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/lib/supabase/auth/README.md +10 -4
  3. data/lib/supabase/auth/admin_api.rb +4 -0
  4. data/lib/supabase/auth/admin_mfa_api.rb +30 -0
  5. data/lib/supabase/auth/async/admin_api.rb +4 -1
  6. data/lib/supabase/auth/async/admin_mfa_api.rb +15 -0
  7. data/lib/supabase/auth/async/client.rb +2 -4
  8. data/lib/supabase/auth/async.rb +1 -0
  9. data/lib/supabase/auth/client.rb +71 -31
  10. data/lib/supabase/auth/helpers.rb +4 -0
  11. data/lib/supabase/auth/types.rb +14 -5
  12. data/lib/supabase/auth.rb +1 -0
  13. data/lib/supabase/client.rb +103 -22
  14. data/lib/supabase/client_options.rb +1 -1
  15. data/lib/supabase/functions/README.md +99 -12
  16. data/lib/supabase/functions/client.rb +72 -31
  17. data/lib/supabase/functions/types.rb +24 -3
  18. data/lib/supabase/postgrest/async/client.rb +2 -0
  19. data/lib/supabase/postgrest/client.rb +9 -2
  20. data/lib/supabase/postgrest/errors.rb +18 -6
  21. data/lib/supabase/postgrest/request_builder.rb +5 -11
  22. data/lib/supabase/realtime/README.md +111 -0
  23. data/lib/supabase/realtime/callback_safety.rb +41 -0
  24. data/lib/supabase/realtime/channel.rb +89 -23
  25. data/lib/supabase/realtime/client.rb +130 -44
  26. data/lib/supabase/realtime/errors.rb +0 -13
  27. data/lib/supabase/realtime/message.rb +13 -3
  28. data/lib/supabase/realtime/presence.rb +84 -32
  29. data/lib/supabase/realtime/push.rb +11 -2
  30. data/lib/supabase/realtime/timer.rb +72 -0
  31. data/lib/supabase/realtime.rb +2 -1
  32. data/lib/supabase/storage/README.md +117 -0
  33. data/lib/supabase/storage/async/client.rb +7 -4
  34. data/lib/supabase/storage/client.rb +16 -4
  35. data/lib/supabase/storage/file_api.rb +36 -9
  36. data/lib/supabase/storage/request.rb +3 -1
  37. data/lib/supabase/storage/utils.rb +15 -1
  38. data/lib/supabase/version.rb +1 -1
  39. data/lib/supabase.rb +0 -7
  40. metadata +33 -16
  41. data/lib/supabase/realtime/test_socket.rb +0 -65
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "callback_safety"
3
4
  require_relative "errors"
4
5
  require_relative "message"
5
6
  require_relative "presence"
6
7
  require_relative "push"
8
+ require_relative "timer"
7
9
  require_relative "types"
8
10
 
9
11
  module Supabase
@@ -16,7 +18,7 @@ module Supabase
16
18
  #
17
19
  # Should be constructed via {Client#channel}, not directly.
18
20
  class Channel
19
- attr_reader :topic, :params, :state, :join_push, :presence, :pending_pushes
21
+ attr_reader :topic, :params, :state, :join_push, :presence, :pending_pushes, :rejoin_timer
20
22
 
21
23
  def initialize(topic, params: nil, socket: nil)
22
24
  @topic = topic
@@ -24,7 +26,7 @@ module Supabase
24
26
  @socket = socket
25
27
  @state = Types::ChannelStates::CLOSED
26
28
  @joined_once = false
27
- @presence = Presence.new
29
+ @presence = Presence.new(logger: logger)
28
30
 
29
31
  @broadcast_callbacks = [] # [{ event:, callback: }]
30
32
  @postgres_changes_callbacks = [] # [{ event:, schema:, table:, filter:, callback: }]
@@ -38,12 +40,31 @@ module Supabase
38
40
  @join_push = Push.new(self, Types::ChannelEvents::JOIN, @params)
39
41
  @subscribe_callback = nil
40
42
 
43
+ # py rejoin uses `lambda tries: 2**tries` with no cap
44
+ # (`realtime/_async/channel.py:109-111`). Timer (US-006) bumps `tries`
45
+ # before invoking this lambda with `tries + 1`, so the curve for the
46
+ # first five attempts is 4, 8, 16, 32, 64 s — identical to py.
47
+ @rejoin_timer = Timer.new(
48
+ callback: -> { rejoin if @joined_once && !leaving? && !closed? },
49
+ backoff: ->(tries) { 2.0**tries }
50
+ )
51
+
41
52
  @join_push
42
53
  .receive(Types::AckStatus::OK) { |p| on_join_ok(p) }
43
54
  .receive(Types::AckStatus::ERROR) { |p| on_join_error(p) }
44
55
  .receive(Types::AckStatus::TIMEOUT) { |_| on_join_timeout }
45
56
  end
46
57
 
58
+ # Logger used by {CallbackSafety} when a user callback raises. Resolved
59
+ # lazily from the realtime client (`@socket` in this class is the
60
+ # {Realtime::Client}, which exposes its injected logger via
61
+ # `Client#logger`). When the underlying transport doesn't carry a logger
62
+ # (e.g. tests that pass a bare {TestSocket} as `socket:`), `safe` falls
63
+ # through to `Kernel#warn`.
64
+ def logger
65
+ @socket.respond_to?(:logger) ? @socket.logger : nil
66
+ end
67
+
47
68
  # ----- State predicates -----
48
69
 
49
70
  def closed?; @state == Types::ChannelStates::CLOSED; end
@@ -65,6 +86,11 @@ module Supabase
65
86
 
66
87
  inject_postgres_changes_bindings
67
88
  @join_push.instance_variable_set(:@ref, @socket&.next_ref)
89
+ # Make subscribe a one-call entry point: if the caller hasn't already
90
+ # connected the underlying transport, open it now so the join frame
91
+ # actually reaches the wire instead of being held forever in the
92
+ # Client#send_buffer. Matches supabase-py's `channel.subscribe()` ergonomics.
93
+ @socket.connect if @socket && !@socket.connected?
68
94
  send_push(@join_push, register_pending: true)
69
95
  self
70
96
  end
@@ -231,13 +257,19 @@ module Supabase
231
257
  when Types::ChannelEvents::PRESENCE_DIFF
232
258
  @presence.sync_diff(message.payload)
233
259
  when Types::ChannelEvents::SYSTEM
234
- @system_callbacks.each { |cb| cb.call(message.payload) }
260
+ @system_callbacks.each do |cb|
261
+ CallbackSafety.safe(logger, "system") { cb.call(message.payload) }
262
+ end
235
263
  when Types::ChannelEvents::CLOSE
236
264
  @state = Types::ChannelStates::CLOSED
237
- @close_callbacks.each { |cb| cb.call(message.payload) }
265
+ @close_callbacks.each do |cb|
266
+ CallbackSafety.safe(logger, "phx_close") { cb.call(message.payload) }
267
+ end
238
268
  when Types::ChannelEvents::ERROR
239
269
  @state = Types::ChannelStates::ERRORED
240
- @error_callbacks.each { |cb| cb.call(message.payload) }
270
+ @error_callbacks.each do |cb|
271
+ CallbackSafety.safe(logger, "phx_error") { cb.call(message.payload) }
272
+ end
241
273
  end
242
274
 
243
275
  true
@@ -260,7 +292,10 @@ module Supabase
260
292
  # so the server filters before sending, instead of shipping every change
261
293
  # for the topic and forcing the client to drop most of them. Also flips
262
294
  # config.presence.enabled when any presence callback is attached, so the
263
- # server starts emitting presence_state/diff frames.
295
+ # server starts emitting presence_state/diff frames. Finally, pulls the
296
+ # current socket access_token onto config.access_token so RLS sees the
297
+ # caller's JWT — private channels reject the join otherwise. The token
298
+ # source is identical to what set_auth rotates (single source of truth).
264
299
  def inject_postgres_changes_bindings
265
300
  config = (@join_push.payload["config"] ||= {})
266
301
  config["postgres_changes"] = @postgres_changes_callbacks.map do |binding|
@@ -273,6 +308,8 @@ module Supabase
273
308
 
274
309
  presence_cfg = (config["presence"] ||= {})
275
310
  presence_cfg["enabled"] = true if @presence.any_callbacks?
311
+
312
+ config["access_token"] = @socket&.access_token
276
313
  end
277
314
 
278
315
  # If a presence callback is added after the channel is already joined,
@@ -296,7 +333,7 @@ module Supabase
296
333
  join_ref: @join_push.ref
297
334
  )
298
335
 
299
- if can_send?
336
+ if can_send?(push)
300
337
  if register_pending && push.ref
301
338
  @pending_pushes[push.ref] = push
302
339
  # Arm the timeout only once the push is actually on the wire — if it
@@ -314,14 +351,15 @@ module Supabase
314
351
  @pending_pushes.delete(ref)
315
352
  end
316
353
 
317
- def can_send?
318
- # The join push flushes while joining; the leave push flushes while leaving.
319
- # Everything else (broadcasts, presence, custom pushes) only sends once joined.
320
- [
321
- Types::ChannelStates::JOINED,
322
- Types::ChannelStates::JOINING,
323
- Types::ChannelStates::LEAVING
324
- ].include?(@state)
354
+ # The join push flushes while joining; the leave push flushes while leaving.
355
+ # Everything else (broadcasts, presence, custom pushes) only sends once
356
+ # joined calls made before subscribe() / between subscribe() and the
357
+ # phx_reply ack are buffered and replayed by flush_push_buffer on JOINED.
358
+ def can_send?(push)
359
+ return joining? || joined? if push.equal?(@join_push)
360
+ return leaving? if push.event == Types::ChannelEvents::LEAVE
361
+
362
+ joined?
325
363
  end
326
364
 
327
365
  def dispatch_reply(message)
@@ -341,20 +379,35 @@ module Supabase
341
379
  change_type = data["type"]
342
380
  schema = data["schema"]
343
381
  table = data["table"]
382
+ ids = message.payload["ids"]
344
383
 
345
384
  @postgres_changes_callbacks.each do |binding|
346
385
  next unless binding[:event] == change_type || binding[:event] == "*"
347
386
  next if binding[:schema] && binding[:schema] != schema
348
387
  next if binding[:table] && binding[:table] != table
349
-
350
- binding[:callback].call(message.payload)
388
+ # Server-side binding-id routing: once on_join_ok has recorded the
389
+ # server-assigned :id, an inbound frame's payload.ids tells us which
390
+ # bindings the server intended to fire. This is how two bindings on
391
+ # the same (schema, table) but different :filter get demultiplexed —
392
+ # without it both would fire on every change. Before the join-ack
393
+ # (no :id yet) we fall through and the legacy event/schema/table
394
+ # filtering remains the sole gate.
395
+ next if binding[:id] && ids.is_a?(Array) && !ids.include?(binding[:id])
396
+
397
+ CallbackSafety.safe(logger, "postgres_changes:#{binding[:event]}") do
398
+ binding[:callback].call(message.payload)
399
+ end
351
400
  end
352
401
  end
353
402
 
354
403
  def dispatch_broadcast(message)
355
404
  event = message.payload["event"]
356
405
  @broadcast_callbacks.each do |binding|
357
- binding[:callback].call(message.payload) if binding[:event] == event
406
+ next unless binding[:event] == event
407
+
408
+ CallbackSafety.safe(logger, "broadcast:#{event}") do
409
+ binding[:callback].call(message.payload)
410
+ end
358
411
  end
359
412
  end
360
413
 
@@ -391,7 +444,7 @@ module Supabase
391
444
  err = Errors::RealtimeError.new(
392
445
  "mismatch between server and client bindings for postgres changes"
393
446
  )
394
- @subscribe_callback&.call(Types::SubscribeStates::CHANNEL_ERROR, err)
447
+ fire_subscribe_callback(Types::SubscribeStates::CHANNEL_ERROR, err)
395
448
  return
396
449
  end
397
450
 
@@ -399,18 +452,29 @@ module Supabase
399
452
  end
400
453
 
401
454
  @state = Types::ChannelStates::JOINED
455
+ @rejoin_timer.reset
402
456
  flush_push_buffer
403
- @subscribe_callback&.call(Types::SubscribeStates::SUBSCRIBED, nil)
457
+ fire_subscribe_callback(Types::SubscribeStates::SUBSCRIBED, nil)
404
458
  end
405
459
 
406
460
  def on_join_error(payload)
407
461
  @state = Types::ChannelStates::ERRORED
408
- @subscribe_callback&.call(Types::SubscribeStates::CHANNEL_ERROR, payload)
462
+ @rejoin_timer.schedule_timeout
463
+ fire_subscribe_callback(Types::SubscribeStates::CHANNEL_ERROR, payload)
409
464
  end
410
465
 
411
466
  def on_join_timeout
412
467
  @state = Types::ChannelStates::ERRORED
413
- @subscribe_callback&.call(Types::SubscribeStates::TIMED_OUT, nil)
468
+ @rejoin_timer.schedule_timeout
469
+ fire_subscribe_callback(Types::SubscribeStates::TIMED_OUT, nil)
470
+ end
471
+
472
+ def fire_subscribe_callback(state, error_or_payload)
473
+ return unless @subscribe_callback
474
+
475
+ CallbackSafety.safe(logger, "subscribe:#{state}") do
476
+ @subscribe_callback.call(state, error_or_payload)
477
+ end
414
478
  end
415
479
 
416
480
  def flush_push_buffer
@@ -421,7 +485,9 @@ module Supabase
421
485
 
422
486
  def on_leave_ack
423
487
  @state = Types::ChannelStates::CLOSED
424
- @close_callbacks.each { |cb| cb.call({}) }
488
+ @close_callbacks.each do |cb|
489
+ CallbackSafety.safe(logger, "phx_close") { cb.call({}) }
490
+ end
425
491
  end
426
492
  end
427
493
  end
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "uri"
5
5
 
6
+ require_relative "callback_safety"
6
7
  require_relative "channel"
7
8
  require_relative "errors"
8
9
  require_relative "message"
@@ -16,35 +17,44 @@ module Supabase
16
17
  # and dispatches inbound frames to whichever channel owns the topic.
17
18
  #
18
19
  # Bring your own {Socket} (e.g. websocket-client-simple adapter or async-websocket
19
- # adapter). For unit tests, pass a {TestSocket}.
20
+ # adapter). For unit tests, pass a {TestSocket}. If no transport is supplied,
21
+ # a default {Sockets::WebsocketClientSimple} adapter is constructed
22
+ # automatically so `Supabase.create_client(...).realtime.channel(...).subscribe`
23
+ # works out of the box.
20
24
  #
21
- # socket = Supabase::Realtime::TestSocket.new
22
25
  # client = Supabase::Realtime::Client.new(
23
26
  # url: "wss://project.supabase.co/realtime/v1",
24
- # params: { apikey: key },
25
- # socket: socket
27
+ # params: { apikey: key }
26
28
  # )
27
- # client.connect
28
29
  #
29
30
  # channel = client.channel("realtime:public:users")
30
31
  # channel.on_postgres_changes("*", schema: "public", table: "users") { |p| puts p }
31
32
  # channel.subscribe
32
33
  class Client
33
34
  attr_reader :url, :params, :access_token, :channels, :socket, :timeout,
34
- :heartbeat_interval, :auto_reconnect, :max_retries, :initial_backoff
35
+ :heartbeat_interval, :auto_reconnect, :max_retries, :initial_backoff,
36
+ :logger
35
37
 
36
38
  # @param url [String] WebSocket endpoint (ws:// or wss://). Plain http(s) are upgraded.
37
39
  # @param params [Hash] query-string params merged onto the URL (e.g. apikey/access_token)
38
- # @param socket [Socket, nil] inject your own transport (defaults to nil caller wires it up)
40
+ # @param transport [Socket, nil] inject your own transport. If nil, the production
41
+ # websocket-client-simple adapter is constructed from URL+params.
42
+ # @param socket [Socket, nil] deprecated alias for `transport:` — kept for back compat.
39
43
  # @param timeout [Numeric] default per-push timeout (seconds)
40
44
  # @param heartbeat_interval [Numeric] seconds between automatic heartbeat pushes (0 disables)
41
45
  # @param auto_reconnect [Boolean] reconnect on unexpected socket close
42
46
  # @param max_retries [Integer] maximum reconnect attempts before giving up
43
47
  # @param initial_backoff [Numeric] seconds of delay before the first reconnect attempt;
44
48
  # doubles each attempt up to a 60s cap (matches supabase-py)
45
- def initialize(url:, params: {}, socket: nil, timeout: Types::DEFAULT_TIMEOUT_SECONDS,
49
+ # @param logger [#warn, nil] optional logger for non-fatal events. Used by
50
+ # {CallbackSafety} to record exceptions raised inside user-supplied
51
+ # channel/presence/push callbacks without killing the read-thread
52
+ # (US-002). Falls back to `Kernel#warn` ($stderr) when nil.
53
+ def initialize(url:, params: {}, transport: nil, socket: nil,
54
+ timeout: Types::DEFAULT_TIMEOUT_SECONDS,
46
55
  heartbeat_interval: Types::DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
47
- auto_reconnect: true, max_retries: 5, initial_backoff: 1.0)
56
+ auto_reconnect: true, max_retries: 5, initial_backoff: 1.0,
57
+ logger: nil)
48
58
  unless Transformers.is_ws_url(url)
49
59
  raise ArgumentError,
50
60
  "Invalid Realtime URL #{url.inspect}: expected ws://, wss://, http://, or https://"
@@ -53,8 +63,8 @@ module Supabase
53
63
  @url = normalize_url(url, params)
54
64
  @params = params
55
65
  @access_token = params[:access_token] || params["access_token"]
56
- @channels = {}
57
- @socket = socket
66
+ @channels = []
67
+ @socket = transport || socket || build_default_transport
58
68
  @timeout = timeout
59
69
  @ref = 0
60
70
 
@@ -62,15 +72,38 @@ module Supabase
62
72
  @auto_reconnect = auto_reconnect
63
73
  @max_retries = max_retries
64
74
  @initial_backoff = initial_backoff
75
+ @logger = logger
65
76
  @heartbeat_thread = nil
66
77
  @reconnect_thread = nil
67
78
  @intentionally_closed = false
68
79
  @send_buffer = [] # frames queued while no socket / not connected
69
80
  @send_buffer_mutex = Mutex.new
81
+ @reconnect_failed_callbacks = []
70
82
 
71
83
  attach_socket if @socket
72
84
  end
73
85
 
86
+ # Register a callback fired exactly once when the background reconnect
87
+ # loop exhausts `max_retries` without re-establishing the socket. The
88
+ # callback receives the last underlying exception raised by the
89
+ # transport's `connect` (or `nil` if no attempt was made — currently
90
+ # unreachable but kept for forward-compat).
91
+ #
92
+ # Why this exists (US-003 / FR-4): supabase-py's `connect()` is a single
93
+ # coroutine that raises on permanent failure. The rb port runs reconnect
94
+ # on a background thread, so a `raise` would die unobserved. This
95
+ # callback is the rb-shaped equivalent — see
96
+ # `lib/supabase/realtime/README.md` "Realtime reconnect: отличие от
97
+ # supabase-py".
98
+ #
99
+ # Multiple registrations are allowed; each fires in registration order.
100
+ # The user block is wrapped in {CallbackSafety.safe} so a raise inside
101
+ # one callback never blocks the next one (consistent with US-002).
102
+ def on_reconnect_failed(&block)
103
+ @reconnect_failed_callbacks << block
104
+ self
105
+ end
106
+
74
107
  # Plug in a transport after construction (e.g. a websocket-client-simple wrapper).
75
108
  def use_socket(socket)
76
109
  @socket = socket
@@ -79,7 +112,10 @@ module Supabase
79
112
  end
80
113
 
81
114
  def connect
82
- raise Errors::RealtimeError, "no socket attached — call #use_socket(socket) first" unless @socket
115
+ unless @socket
116
+ @socket = build_default_transport
117
+ attach_socket
118
+ end
83
119
 
84
120
  @intentionally_closed = false
85
121
  @socket.connect
@@ -91,7 +127,7 @@ module Supabase
91
127
  stop_reconnect
92
128
  stop_heartbeat
93
129
  @socket&.close
94
- @channels.each_value { |ch| ch.instance_variable_set(:@state, Types::ChannelStates::CLOSED) }
130
+ @channels.each { |ch| ch.instance_variable_set(:@state, Types::ChannelStates::CLOSED) }
95
131
  self
96
132
  end
97
133
 
@@ -102,8 +138,10 @@ module Supabase
102
138
  @socket && @socket.connected?
103
139
  end
104
140
 
105
- # Get or create a Channel for the given topic. Subsequent calls with the
106
- # same topic return the same Channel instance, matching phoenix.js semantics.
141
+ # Always returns a **new** Channel instance, matching supabase-py. The
142
+ # client-side topic registry is a flat list, so multiple channels can
143
+ # share a topic (each with its own join_ref / subscription lifecycle).
144
+ # To look up an existing channel, walk `get_channels.find { |c| c.topic == ... }`.
107
145
  #
108
146
  # Topic names are auto-prefixed with `"realtime:"` to match supabase-py:
109
147
  # `client.channel("public:users")` reaches the same channel as
@@ -111,46 +149,61 @@ module Supabase
111
149
  # alone so existing code keeps working.
112
150
  def channel(topic, params: nil)
113
151
  full_topic = topic.start_with?("realtime:") ? topic : "realtime:#{topic}"
114
- @channels[full_topic] ||= Channel.new(full_topic, params: params, socket: self)
152
+ ch = Channel.new(full_topic, params: params, socket: self)
153
+ @channels << ch
154
+ ch
115
155
  end
116
156
 
117
157
  def get_channels
118
- @channels.values
158
+ @channels.dup
119
159
  end
120
160
 
121
161
  def remove_channel(channel)
122
162
  channel.unsubscribe
123
- @channels.delete(channel.topic)
163
+ @channels.delete(channel)
164
+ @socket&.close if @channels.empty?
124
165
  end
125
166
 
167
+ # Unsubscribe every tracked channel and clear the registry. Iterates over a
168
+ # snapshot (`@channels.dup`) so a channel that removes itself during
169
+ # `unsubscribe` doesn't shift the array mid-loop. Idempotent: a follow-up
170
+ # call on an empty registry is a no-op.
171
+ # @see supabase-py supabase/_sync/client.py:234
126
172
  def remove_all_channels
127
- @channels.values.each { |ch| ch.unsubscribe }
173
+ @channels.dup.each { |ch| ch.unsubscribe }
128
174
  @channels.clear
175
+ self
129
176
  end
130
177
 
131
178
  # Update the access token, send it to every joined channel so RLS reflects
132
179
  # the new auth context, and remember it for future joins.
180
+ #
181
+ # Safe to call before `connect`: the token is always written to
182
+ # `@access_token` / `@params` so the next subscribe picks it up via
183
+ # `Channel#inject_postgres_changes_bindings`. The ACCESS_TOKEN frame fan-out
184
+ # only runs once the socket is actually connected.
133
185
  def set_auth(token)
134
186
  @access_token = token
135
187
  @params["access_token"] = token if @params.is_a?(Hash)
136
- return unless @socket && @socket.connected?
137
-
138
- @channels.each_value do |channel|
139
- next unless channel.joined?
140
-
141
- msg = Message.new(
142
- event: Types::ChannelEvents::ACCESS_TOKEN,
143
- topic: channel.topic,
144
- payload: { "access_token" => token },
145
- ref: next_ref
146
- )
147
- @socket.send(JSON.generate(
148
- "event" => msg.event,
149
- "topic" => msg.topic,
150
- "payload" => msg.payload,
151
- "ref" => msg.ref,
152
- "join_ref" => nil
153
- ))
188
+
189
+ if connected?
190
+ @channels.each do |channel|
191
+ next unless channel.joined?
192
+
193
+ msg = Message.new(
194
+ event: Types::ChannelEvents::ACCESS_TOKEN,
195
+ topic: channel.topic,
196
+ payload: { "access_token" => token },
197
+ ref: next_ref
198
+ )
199
+ @socket.send(JSON.generate(
200
+ "event" => msg.event,
201
+ "topic" => msg.topic,
202
+ "payload" => msg.payload,
203
+ "ref" => msg.ref,
204
+ "join_ref" => nil
205
+ ))
206
+ end
154
207
  end
155
208
  end
156
209
 
@@ -198,6 +251,16 @@ module Supabase
198
251
 
199
252
  private
200
253
 
254
+ # Lazy-construct the production WebSocket transport. Lives behind an autoload
255
+ # so that callers who inject their own `transport:` don't pay the cost of
256
+ # `require "websocket-client-simple"`, and so that the dependency only loads
257
+ # once it's actually needed (matches the per-adapter require pattern in
258
+ # lib/supabase/realtime/sockets/*).
259
+ def build_default_transport
260
+ require_relative "sockets/websocket_client_simple"
261
+ Sockets::WebsocketClientSimple.new(url: @url)
262
+ end
263
+
201
264
  def attach_socket
202
265
  @socket.on_message { |raw| handle_inbound(raw) }
203
266
  @socket.on_open { handle_socket_open }
@@ -270,7 +333,9 @@ module Supabase
270
333
 
271
334
  @reconnect_thread = Thread.new do
272
335
  Thread.current.report_on_exception = false
273
- retries = 0
336
+ retries = 0
337
+ last_error = nil
338
+ reconnected = false
274
339
  while retries < max_tries
275
340
  retries += 1
276
341
  wait = [initial * (2**(retries - 1)), 60.0].min
@@ -279,12 +344,27 @@ module Supabase
279
344
 
280
345
  begin
281
346
  @socket.connect
347
+ reconnected = true
282
348
  break # on_open will fire and restart heartbeat + rejoin channels
283
- rescue StandardError
349
+ rescue StandardError => e
350
+ last_error = e
284
351
  # Try again until max_retries is hit.
285
352
  end
286
353
  end
287
354
  @reconnect_thread = nil
355
+ fire_reconnect_failed(last_error) unless reconnected || @intentionally_closed
356
+ end
357
+ end
358
+
359
+ # Fan-out the on_reconnect_failed callback. Wrapped in CallbackSafety so
360
+ # an exception inside a user block does not propagate up the background
361
+ # reconnect thread (which has `report_on_exception = false`) and get
362
+ # swallowed silently.
363
+ def fire_reconnect_failed(last_error)
364
+ return if @reconnect_failed_callbacks.empty?
365
+
366
+ @reconnect_failed_callbacks.each do |cb|
367
+ CallbackSafety.safe(@logger, "reconnect_failed") { cb.call(last_error) }
288
368
  end
289
369
  end
290
370
 
@@ -295,9 +375,13 @@ module Supabase
295
375
  end
296
376
 
297
377
  def rejoin_channels
298
- @channels.each_value do |channel|
299
- next unless channel.instance_variable_get(:@joined_once)
300
- next if channel.joining?
378
+ @channels.each do |channel|
379
+ # Only rejoin channels the caller still cares about: JOINED (live
380
+ # subscription that the socket close interrupted) or JOINING (join
381
+ # handshake was in flight when the socket dropped). After
382
+ # `unsubscribe` a channel is LEAVING or CLOSED — rejoining it would
383
+ # silently revive a subscription the caller explicitly tore down.
384
+ next unless channel.joined? || channel.joining?
301
385
 
302
386
  channel.rejoin
303
387
  end
@@ -305,9 +389,11 @@ module Supabase
305
389
 
306
390
  def handle_inbound(raw)
307
391
  message = Message.parse(raw)
308
- return if message.topic.nil?
392
+ # `parse` returns nil on malformed JSON (US-017) — skip the frame so the
393
+ # socket adapter's read-loop keeps running for the next valid frame.
394
+ return if message.nil? || message.topic.nil?
309
395
 
310
- @channels.each_value do |channel|
396
+ @channels.each do |channel|
311
397
  channel.dispatch(message) if channel.topic == message.topic
312
398
  end
313
399
  end
@@ -9,21 +9,8 @@ module Supabase
9
9
  # instance — the Phoenix protocol only allows one join per channel.
10
10
  class AlreadyJoinedError < RealtimeError; end
11
11
 
12
- # Raised when a push waits longer than its timeout for a reply.
13
- class PushTimeoutError < RealtimeError; end
14
-
15
12
  # Raised when a non-JSON or malformed frame arrives on the WebSocket.
16
13
  class ProtocolError < RealtimeError; end
17
-
18
- # Raised when an operation requires an active WebSocket connection but
19
- # the client hasn't connected (or has been closed). Mirrors py
20
- # NotConnectedError so call sites can rescue the same class name.
21
- class NotConnectedError < RealtimeError; end
22
-
23
- # Raised when the server rejects a join push for authentication reasons
24
- # (typical case: missing/invalid apikey or access_token). Mirrors py
25
- # AuthorizationError.
26
- class AuthorizationError < RealtimeError; end
27
14
  end
28
15
  end
29
16
  end
@@ -19,8 +19,12 @@ module Supabase
19
19
  )
20
20
  end
21
21
 
22
- # Parse a raw JSON frame received on the WebSocket into a Message. Raises
23
- # ProtocolError if the frame isn't well-formed JSON.
22
+ # Parse a raw JSON frame received on the WebSocket into a Message. Returns
23
+ # nil (and logs a warning) when the frame isn't well-formed JSON — the
24
+ # caller (read-loop in {Client#handle_inbound}) treats nil as "skip this
25
+ # frame" so a single garbled byte sequence can't kill the loop. Closes
26
+ # US-017 / F-C-minor: previously raised ProtocolError and propagated up
27
+ # into the socket adapter, taking down the read thread on first bad frame.
24
28
  def self.parse(raw)
25
29
  json = JSON.parse(raw)
26
30
  new(
@@ -31,7 +35,13 @@ module Supabase
31
35
  join_ref: json["join_ref"]
32
36
  )
33
37
  rescue JSON::ParserError => e
34
- raise Errors::ProtocolError, "Malformed Phoenix frame: #{e.message}"
38
+ msg = "[Supabase::Realtime] Skipping malformed Phoenix frame: #{e.message}"
39
+ if defined?(@logger) && @logger.respond_to?(:warn)
40
+ @logger.warn(msg)
41
+ else
42
+ warn(msg)
43
+ end
44
+ nil
35
45
  end
36
46
  end
37
47
  end