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.
- checksums.yaml +4 -4
- data/lib/supabase/auth/README.md +10 -4
- data/lib/supabase/auth/admin_api.rb +4 -0
- data/lib/supabase/auth/admin_mfa_api.rb +30 -0
- data/lib/supabase/auth/async/admin_api.rb +4 -1
- data/lib/supabase/auth/async/admin_mfa_api.rb +15 -0
- data/lib/supabase/auth/async/client.rb +2 -4
- data/lib/supabase/auth/async.rb +1 -0
- data/lib/supabase/auth/client.rb +71 -31
- data/lib/supabase/auth/helpers.rb +4 -0
- data/lib/supabase/auth/types.rb +14 -5
- data/lib/supabase/auth.rb +1 -0
- data/lib/supabase/client.rb +103 -22
- data/lib/supabase/client_options.rb +1 -1
- data/lib/supabase/functions/README.md +99 -12
- data/lib/supabase/functions/client.rb +72 -31
- data/lib/supabase/functions/types.rb +24 -3
- data/lib/supabase/postgrest/async/client.rb +2 -0
- data/lib/supabase/postgrest/client.rb +9 -2
- data/lib/supabase/postgrest/errors.rb +18 -6
- data/lib/supabase/postgrest/request_builder.rb +5 -11
- data/lib/supabase/realtime/README.md +111 -0
- data/lib/supabase/realtime/callback_safety.rb +41 -0
- data/lib/supabase/realtime/channel.rb +89 -23
- data/lib/supabase/realtime/client.rb +130 -44
- data/lib/supabase/realtime/errors.rb +0 -13
- data/lib/supabase/realtime/message.rb +13 -3
- data/lib/supabase/realtime/presence.rb +84 -32
- data/lib/supabase/realtime/push.rb +11 -2
- data/lib/supabase/realtime/timer.rb +72 -0
- data/lib/supabase/realtime.rb +2 -1
- data/lib/supabase/storage/README.md +117 -0
- data/lib/supabase/storage/async/client.rb +7 -4
- data/lib/supabase/storage/client.rb +16 -4
- data/lib/supabase/storage/file_api.rb +36 -9
- data/lib/supabase/storage/request.rb +3 -1
- data/lib/supabase/storage/utils.rb +15 -1
- data/lib/supabase/version.rb +1 -1
- data/lib/supabase.rb +0 -7
- metadata +33 -16
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
#
|
|
106
|
-
#
|
|
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
|
-
|
|
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.
|
|
158
|
+
@channels.dup
|
|
119
159
|
end
|
|
120
160
|
|
|
121
161
|
def remove_channel(channel)
|
|
122
162
|
channel.unsubscribe
|
|
123
|
-
@channels.delete(channel
|
|
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.
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
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.
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
23
|
-
#
|
|
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
|
-
|
|
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
|