supabase-rb 2.0.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 (74) hide show
  1. checksums.yaml +7 -0
  2. data/lib/supabase/README.md +90 -0
  3. data/lib/supabase/auth/README.md +172 -0
  4. data/lib/supabase/auth/admin_api.rb +218 -0
  5. data/lib/supabase/auth/admin_oauth_api.rb +51 -0
  6. data/lib/supabase/auth/api.rb +125 -0
  7. data/lib/supabase/auth/async/admin_api.rb +36 -0
  8. data/lib/supabase/auth/async/admin_oauth_api.rb +15 -0
  9. data/lib/supabase/auth/async/api.rb +32 -0
  10. data/lib/supabase/auth/async/client.rb +33 -0
  11. data/lib/supabase/auth/async.rb +14 -0
  12. data/lib/supabase/auth/client.rb +1217 -0
  13. data/lib/supabase/auth/constants.rb +32 -0
  14. data/lib/supabase/auth/errors.rb +207 -0
  15. data/lib/supabase/auth/helpers.rb +222 -0
  16. data/lib/supabase/auth/memory_storage.rb +25 -0
  17. data/lib/supabase/auth/storage.rb +19 -0
  18. data/lib/supabase/auth/timer.rb +40 -0
  19. data/lib/supabase/auth/types.rb +517 -0
  20. data/lib/supabase/auth/version.rb +7 -0
  21. data/lib/supabase/auth.rb +19 -0
  22. data/lib/supabase/client.rb +200 -0
  23. data/lib/supabase/client_options.rb +82 -0
  24. data/lib/supabase/functions/README.md +71 -0
  25. data/lib/supabase/functions/async/client.rb +45 -0
  26. data/lib/supabase/functions/async.rb +8 -0
  27. data/lib/supabase/functions/client.rb +174 -0
  28. data/lib/supabase/functions/errors.rb +38 -0
  29. data/lib/supabase/functions/types.rb +37 -0
  30. data/lib/supabase/functions/version.rb +7 -0
  31. data/lib/supabase/functions.rb +11 -0
  32. data/lib/supabase/postgrest/README.md +84 -0
  33. data/lib/supabase/postgrest/async/client.rb +50 -0
  34. data/lib/supabase/postgrest/async.rb +8 -0
  35. data/lib/supabase/postgrest/client.rb +136 -0
  36. data/lib/supabase/postgrest/errors.rb +49 -0
  37. data/lib/supabase/postgrest/request_builder.rb +657 -0
  38. data/lib/supabase/postgrest/types.rb +60 -0
  39. data/lib/supabase/postgrest/utils.rb +24 -0
  40. data/lib/supabase/postgrest/version.rb +7 -0
  41. data/lib/supabase/postgrest.rb +13 -0
  42. data/lib/supabase/realtime/README.md +90 -0
  43. data/lib/supabase/realtime/channel.rb +274 -0
  44. data/lib/supabase/realtime/client.rb +182 -0
  45. data/lib/supabase/realtime/errors.rb +19 -0
  46. data/lib/supabase/realtime/message.rb +38 -0
  47. data/lib/supabase/realtime/presence.rb +136 -0
  48. data/lib/supabase/realtime/push.rb +48 -0
  49. data/lib/supabase/realtime/socket.rb +40 -0
  50. data/lib/supabase/realtime/sockets/async_websocket.rb +175 -0
  51. data/lib/supabase/realtime/sockets/websocket_client_simple.rb +94 -0
  52. data/lib/supabase/realtime/test_socket.rb +65 -0
  53. data/lib/supabase/realtime/transformers.rb +26 -0
  54. data/lib/supabase/realtime/types.rb +70 -0
  55. data/lib/supabase/realtime/version.rb +7 -0
  56. data/lib/supabase/realtime.rb +18 -0
  57. data/lib/supabase/storage/README.md +108 -0
  58. data/lib/supabase/storage/analytics.rb +69 -0
  59. data/lib/supabase/storage/async/client.rb +52 -0
  60. data/lib/supabase/storage/async.rb +8 -0
  61. data/lib/supabase/storage/bucket_api.rb +65 -0
  62. data/lib/supabase/storage/client.rb +80 -0
  63. data/lib/supabase/storage/errors.rb +32 -0
  64. data/lib/supabase/storage/file_api.rb +281 -0
  65. data/lib/supabase/storage/request.rb +63 -0
  66. data/lib/supabase/storage/types.rb +236 -0
  67. data/lib/supabase/storage/utils.rb +35 -0
  68. data/lib/supabase/storage/vectors.rb +189 -0
  69. data/lib/supabase/storage/version.rb +7 -0
  70. data/lib/supabase/storage.rb +17 -0
  71. data/lib/supabase/version.rb +5 -0
  72. data/lib/supabase-auth.rb +3 -0
  73. data/lib/supabase.rb +63 -0
  74. metadata +272 -0
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supabase
4
+ module Postgrest
5
+ module Types
6
+ module CountMethod
7
+ EXACT = "exact"
8
+ PLANNED = "planned"
9
+ ESTIMATED = "estimated"
10
+ ALL = [EXACT, PLANNED, ESTIMATED].freeze
11
+ end
12
+
13
+ module ReturnMethod
14
+ MINIMAL = "minimal"
15
+ REPRESENTATION = "representation"
16
+ end
17
+
18
+ module RequestMethod
19
+ GET = "GET"
20
+ POST = "POST"
21
+ PATCH = "PATCH"
22
+ PUT = "PUT"
23
+ DELETE = "DELETE"
24
+ HEAD = "HEAD"
25
+ end
26
+
27
+ # PostgREST filter operators. Names mirror supabase-py's Filters enum 1:1
28
+ # — see postgrest-py/src/postgrest/types.py.
29
+ module Filters
30
+ NOT = "not"
31
+ EQ = "eq"
32
+ NEQ = "neq"
33
+ GT = "gt"
34
+ GTE = "gte"
35
+ LT = "lt"
36
+ LTE = "lte"
37
+ IS = "is"
38
+ LIKE = "like"
39
+ LIKE_ALL = "like(all)"
40
+ LIKE_ANY = "like(any)"
41
+ ILIKE = "ilike"
42
+ ILIKE_ALL = "ilike(all)"
43
+ ILIKE_ANY = "ilike(any)"
44
+ FTS = "fts"
45
+ PLFTS = "plfts"
46
+ PHFTS = "phfts"
47
+ WFTS = "wfts"
48
+ IN = "in"
49
+ CS = "cs"
50
+ CD = "cd"
51
+ OV = "ov"
52
+ SL = "sl"
53
+ SR = "sr"
54
+ NXL = "nxl"
55
+ NXR = "nxr"
56
+ ADJ = "adj"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supabase
4
+ module Postgrest
5
+ module Utils
6
+ module_function
7
+
8
+ RESERVED_CHARS = ",:()"
9
+
10
+ # Quote values that contain PostgREST reserved characters so they aren't
11
+ # interpreted as operator separators. Mirrors supabase-py's sanitize_param.
12
+ def sanitize_param(param)
13
+ s = param.to_s
14
+ return %("#{s}") if s.chars.any? { |c| RESERVED_CHARS.include?(c) }
15
+
16
+ s
17
+ end
18
+
19
+ def sanitize_pattern_param(pattern)
20
+ sanitize_param(pattern.to_s.gsub("%", "*"))
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supabase
4
+ module Postgrest
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "postgrest/version"
4
+ require_relative "postgrest/types"
5
+ require_relative "postgrest/errors"
6
+ require_relative "postgrest/utils"
7
+ require_relative "postgrest/request_builder"
8
+ require_relative "postgrest/client"
9
+
10
+ module Supabase
11
+ module Postgrest
12
+ end
13
+ end
@@ -0,0 +1,90 @@
1
+ # `supabase-realtime`
2
+
3
+ Ruby client for [Supabase Realtime](https://supabase.com/docs/guides/realtime).
4
+ Implements the [Phoenix Channels](https://hexdocs.pm/phoenix/channels.html)
5
+ protocol against a **pluggable Socket interface**. Broadcast, Presence, and
6
+ Postgres Change Data Capture (CDC) — same surface as
7
+ [`realtime`](https://github.com/supabase/supabase-py/tree/main/src/realtime)
8
+ in Python.
9
+
10
+ - Source: [github.com/supabase-rb/client](https://github.com/supabase-rb/client)
11
+
12
+ ## Installation
13
+
14
+ ```ruby
15
+ gem "supabase-realtime"
16
+ ```
17
+
18
+ Then `bundle install`. (Requires Ruby >= 3.0.)
19
+
20
+ ## Design
21
+
22
+ The protocol layer (channel state machine, presence sync, listener routing,
23
+ push/reply tracking) is fully implemented and tested. A real WebSocket
24
+ transport plugs in through the `Supabase::Realtime::Socket` interface; the
25
+ gem ships one production adapter built on `websocket-client-simple`.
26
+
27
+ This mirrors `supabase-py`'s decision to ship sync realtime as
28
+ `NotImplementedError`: WebSocket I/O is fundamentally event-driven and a
29
+ naive sync wrapper is more harmful than no wrapper at all. The
30
+ websocket-client-simple adapter runs the read loop on a background thread,
31
+ which means listener callbacks fire on that thread — bring your own
32
+ thread-safety to anything they touch.
33
+
34
+ ## Usage
35
+
36
+ ```ruby
37
+ require "supabase/realtime"
38
+ require "supabase/realtime/sockets/websocket_client_simple"
39
+
40
+ socket = Supabase::Realtime::Sockets::WebsocketClientSimple.new(
41
+ url: "wss://your-project.supabase.co/realtime/v1/websocket?apikey=#{key}"
42
+ )
43
+ client = Supabase::Realtime::Client.new(
44
+ url: "wss://your-project.supabase.co/realtime/v1",
45
+ params: { apikey: key, access_token: jwt },
46
+ socket: socket
47
+ )
48
+ client.connect
49
+
50
+ channel = client.channel("realtime:public:users")
51
+ channel.on_postgres_changes("INSERT", schema: "public", table: "users") { |p| puts p }
52
+ channel.on_postgres_changes("*", schema: "public", table: "users") { |p| puts p }
53
+ channel.on_broadcast("message") { |p| puts p }
54
+ channel.subscribe do |status, err|
55
+ puts status # "SUBSCRIBED" / "CHANNEL_ERROR" / "TIMED_OUT"
56
+ end
57
+
58
+ channel.send_broadcast("typing", { user: "u1" })
59
+ channel.track({ status: "online" })
60
+
61
+ # Presence
62
+ channel.presence.on_sync { puts channel.presence.state }
63
+ channel.presence.on_join { |key, presence| ... }
64
+ channel.presence.on_leave { |key, presence| ... }
65
+ ```
66
+
67
+ ## Testing
68
+
69
+ For unit testing, use `Supabase::Realtime::TestSocket` — an in-memory Socket
70
+ implementation with `inject(frame)` and `sent_frames` capture. See
71
+ [`spec/supabase/realtime/`](../../../spec/supabase/realtime/) in the repo
72
+ for usage.
73
+
74
+ ## Implementing your own Socket adapter
75
+
76
+ Implement these methods (the only contract `Client` assumes):
77
+
78
+ ```ruby
79
+ class MyAdapter
80
+ include Supabase::Realtime::Socket
81
+
82
+ def connect; ...; open_callbacks.each(&:call); end
83
+ def close; ...; close_callbacks.each(&:call); end
84
+ def send(payload); ...; end
85
+ def connected?; ...; end
86
+
87
+ # Whenever a frame arrives:
88
+ # message_callbacks.each { |cb| cb.call(raw_json_string) }
89
+ end
90
+ ```
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "message"
5
+ require_relative "presence"
6
+ require_relative "push"
7
+ require_relative "types"
8
+
9
+ module Supabase
10
+ module Realtime
11
+ # A topic subscription on a shared Socket connection. Each Channel:
12
+ # - tracks its own lifecycle state (closed/joining/joined/leaving/errored)
13
+ # - holds the listener callbacks for postgres changes / broadcast / presence / system
14
+ # - dispatches inbound messages from the Client to those callbacks
15
+ # - owns its Presence sync state
16
+ #
17
+ # Should be constructed via {Client#channel}, not directly.
18
+ class Channel
19
+ attr_reader :topic, :params, :state, :join_push, :presence, :pending_pushes
20
+
21
+ def initialize(topic, params: nil, socket: nil)
22
+ @topic = topic
23
+ @params = params || default_params
24
+ @socket = socket
25
+ @state = Types::ChannelStates::CLOSED
26
+ @joined_once = false
27
+ @presence = Presence.new
28
+
29
+ @broadcast_callbacks = [] # [{ event:, callback: }]
30
+ @postgres_changes_callbacks = [] # [{ event:, schema:, table:, filter:, callback: }]
31
+ @system_callbacks = []
32
+ @close_callbacks = []
33
+ @error_callbacks = []
34
+
35
+ @pending_pushes = {} # ref => Push, for matching phx_reply
36
+ @push_buffer = [] # outbound pushes queued while not yet joined
37
+
38
+ @join_push = Push.new(self, Types::ChannelEvents::JOIN, @params)
39
+ @subscribe_callback = nil
40
+
41
+ @join_push
42
+ .receive(Types::AckStatus::OK) { |_| on_join_ok }
43
+ .receive(Types::AckStatus::ERROR) { |p| on_join_error(p) }
44
+ .receive(Types::AckStatus::TIMEOUT) { |_| on_join_timeout }
45
+ end
46
+
47
+ # ----- State predicates -----
48
+
49
+ def closed?; @state == Types::ChannelStates::CLOSED; end
50
+ def errored?; @state == Types::ChannelStates::ERRORED; end
51
+ def joined?; @state == Types::ChannelStates::JOINED; end
52
+ def joining?; @state == Types::ChannelStates::JOINING; end
53
+ def leaving?; @state == Types::ChannelStates::LEAVING; end
54
+
55
+ # ----- Subscription -----
56
+
57
+ # Start the join handshake. Optional block fires when the join completes,
58
+ # receiving the SubscribeStates value (SUBSCRIBED / CHANNEL_ERROR / TIMED_OUT).
59
+ def subscribe(&block)
60
+ raise Errors::AlreadyJoinedError, "subscribe can only be called once per channel" if @joined_once
61
+
62
+ @joined_once = true
63
+ @subscribe_callback = block
64
+ @state = Types::ChannelStates::JOINING
65
+
66
+ @join_push.instance_variable_set(:@ref, @socket&.next_ref)
67
+ send_push(@join_push, register_pending: true)
68
+ self
69
+ end
70
+
71
+ # Tear down the subscription with a phx_leave push.
72
+ def unsubscribe
73
+ @state = Types::ChannelStates::LEAVING
74
+ ref = @socket&.next_ref
75
+ leave_push = Push.new(self, Types::ChannelEvents::LEAVE, {}, ref: ref)
76
+ send_push(leave_push, register_pending: false)
77
+ @state = Types::ChannelStates::CLOSED
78
+ self
79
+ end
80
+
81
+ # ----- Listener registration -----
82
+
83
+ # Register a postgres-changes listener. event may be "INSERT", "UPDATE",
84
+ # "DELETE", or "*" for all three. schema/table/filter narrow which rows
85
+ # fire the callback. Returns self so calls chain.
86
+ def on_postgres_changes(event, schema: nil, table: nil, filter: nil, &block)
87
+ unless %w[INSERT UPDATE DELETE *].include?(event)
88
+ raise ArgumentError, "postgres_changes event must be INSERT/UPDATE/DELETE/*"
89
+ end
90
+
91
+ @postgres_changes_callbacks << {
92
+ event: event, schema: schema, table: table, filter: filter, callback: block
93
+ }
94
+ self
95
+ end
96
+
97
+ def on_broadcast(event, &block)
98
+ @broadcast_callbacks << { event: event, callback: block }
99
+ self
100
+ end
101
+
102
+ def on_system(&block)
103
+ @system_callbacks << block
104
+ self
105
+ end
106
+
107
+ def on_close(&block)
108
+ @close_callbacks << block
109
+ self
110
+ end
111
+
112
+ def on_error(&block)
113
+ @error_callbacks << block
114
+ self
115
+ end
116
+
117
+ # ----- Outbound -----
118
+
119
+ # Send a custom broadcast message. The server will forward it to other
120
+ # subscribers of the same topic.
121
+ def send_broadcast(event, payload = {})
122
+ push = Push.new(self,
123
+ Types::ChannelEvents::BROADCAST,
124
+ { "type" => "broadcast", "event" => event, "payload" => payload })
125
+ send_push(push, register_pending: false)
126
+ self
127
+ end
128
+
129
+ # Track the local user in the channel's presence state.
130
+ def track(payload)
131
+ push = Push.new(self,
132
+ Types::ChannelEvents::PRESENCE,
133
+ { "type" => "presence", "event" => "track", "payload" => payload })
134
+ send_push(push, register_pending: false)
135
+ self
136
+ end
137
+
138
+ def untrack
139
+ push = Push.new(self,
140
+ Types::ChannelEvents::PRESENCE,
141
+ { "type" => "presence", "event" => "untrack" })
142
+ send_push(push, register_pending: false)
143
+ self
144
+ end
145
+
146
+ # ----- Inbound dispatch (called by Client) -----
147
+
148
+ # Route a parsed Message to the appropriate listeners. Returns true if the
149
+ # message belonged to this channel, false otherwise (so the Client knows
150
+ # whether to drop it).
151
+ def dispatch(message)
152
+ return false unless message.topic == @topic
153
+
154
+ case message.event
155
+ when Types::ChannelEvents::REPLY
156
+ dispatch_reply(message)
157
+ when Types::ChannelEvents::POSTGRES_CHANGES
158
+ dispatch_postgres_changes(message)
159
+ when Types::ChannelEvents::BROADCAST
160
+ dispatch_broadcast(message)
161
+ when Types::ChannelEvents::PRESENCE_STATE
162
+ @presence.sync_state(message.payload)
163
+ when Types::ChannelEvents::PRESENCE_DIFF
164
+ @presence.sync_diff(message.payload)
165
+ when Types::ChannelEvents::SYSTEM
166
+ @system_callbacks.each { |cb| cb.call(message.payload) }
167
+ when Types::ChannelEvents::CLOSE
168
+ @state = Types::ChannelStates::CLOSED
169
+ @close_callbacks.each { |cb| cb.call(message.payload) }
170
+ when Types::ChannelEvents::ERROR
171
+ @state = Types::ChannelStates::ERRORED
172
+ @error_callbacks.each { |cb| cb.call(message.payload) }
173
+ end
174
+
175
+ true
176
+ end
177
+
178
+ private
179
+
180
+ def default_params
181
+ {
182
+ "config" => {
183
+ "broadcast" => { "ack" => false, "self" => false },
184
+ "presence" => { "key" => "", "enabled" => false },
185
+ "private" => false
186
+ }
187
+ }
188
+ end
189
+
190
+ def send_push(push, register_pending:)
191
+ message = Message.new(
192
+ event: push.event,
193
+ topic: @topic,
194
+ payload: push.payload,
195
+ ref: push.ref,
196
+ join_ref: @join_push.ref
197
+ )
198
+
199
+ if can_send?
200
+ @pending_pushes[push.ref] = push if register_pending && push.ref
201
+ @socket&.push(message)
202
+ else
203
+ @push_buffer << [push, register_pending]
204
+ end
205
+ end
206
+
207
+ def can_send?
208
+ # The join push flushes while joining; the leave push flushes while leaving.
209
+ # Everything else (broadcasts, presence, custom pushes) only sends once joined.
210
+ [
211
+ Types::ChannelStates::JOINED,
212
+ Types::ChannelStates::JOINING,
213
+ Types::ChannelStates::LEAVING
214
+ ].include?(@state)
215
+ end
216
+
217
+ def dispatch_reply(message)
218
+ ref = message.ref
219
+ push = @pending_pushes.delete(ref)
220
+ return unless push
221
+
222
+ push.resolve(
223
+ status: message.payload["status"],
224
+ payload: message.payload["response"] || message.payload
225
+ )
226
+ end
227
+
228
+ def dispatch_postgres_changes(message)
229
+ # Payload shape: { "data" => { "type" => "INSERT", "schema" => "public", "table" => "users", ... }, "ids" => [...] }
230
+ data = message.payload["data"] || {}
231
+ change_type = data["type"]
232
+ schema = data["schema"]
233
+ table = data["table"]
234
+
235
+ @postgres_changes_callbacks.each do |binding|
236
+ next unless binding[:event] == change_type || binding[:event] == "*"
237
+ next if binding[:schema] && binding[:schema] != schema
238
+ next if binding[:table] && binding[:table] != table
239
+
240
+ binding[:callback].call(message.payload)
241
+ end
242
+ end
243
+
244
+ def dispatch_broadcast(message)
245
+ event = message.payload["event"]
246
+ @broadcast_callbacks.each do |binding|
247
+ binding[:callback].call(message.payload) if binding[:event] == event
248
+ end
249
+ end
250
+
251
+ def on_join_ok
252
+ @state = Types::ChannelStates::JOINED
253
+ flush_push_buffer
254
+ @subscribe_callback&.call(Types::SubscribeStates::SUBSCRIBED, nil)
255
+ end
256
+
257
+ def on_join_error(payload)
258
+ @state = Types::ChannelStates::ERRORED
259
+ @subscribe_callback&.call(Types::SubscribeStates::CHANNEL_ERROR, payload)
260
+ end
261
+
262
+ def on_join_timeout
263
+ @state = Types::ChannelStates::ERRORED
264
+ @subscribe_callback&.call(Types::SubscribeStates::TIMED_OUT, nil)
265
+ end
266
+
267
+ def flush_push_buffer
268
+ buffered = @push_buffer
269
+ @push_buffer = []
270
+ buffered.each { |push, register_pending| send_push(push, register_pending: register_pending) }
271
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ require_relative "channel"
7
+ require_relative "message"
8
+ require_relative "types"
9
+ require_relative "version"
10
+
11
+ module Supabase
12
+ module Realtime
13
+ # Top-level Realtime client. Owns one {Socket}, multiplexes Channels onto it,
14
+ # and dispatches inbound frames to whichever channel owns the topic.
15
+ #
16
+ # Bring your own {Socket} (e.g. websocket-client-simple adapter or async-websocket
17
+ # adapter). For unit tests, pass a {TestSocket}.
18
+ #
19
+ # socket = Supabase::Realtime::TestSocket.new
20
+ # client = Supabase::Realtime::Client.new(
21
+ # url: "wss://project.supabase.co/realtime/v1",
22
+ # params: { apikey: key },
23
+ # socket: socket
24
+ # )
25
+ # client.connect
26
+ #
27
+ # channel = client.channel("realtime:public:users")
28
+ # channel.on_postgres_changes("*", schema: "public", table: "users") { |p| puts p }
29
+ # channel.subscribe
30
+ class Client
31
+ attr_reader :url, :params, :access_token, :channels, :socket, :timeout
32
+
33
+ # @param url [String] WebSocket endpoint (ws:// or wss://). Plain http(s) are upgraded.
34
+ # @param params [Hash] query-string params merged onto the URL (e.g. apikey/access_token)
35
+ # @param socket [Socket, nil] inject your own transport (defaults to nil — caller wires it up)
36
+ # @param timeout [Numeric] default per-push timeout (seconds)
37
+ def initialize(url:, params: {}, socket: nil, timeout: Types::DEFAULT_TIMEOUT_SECONDS)
38
+ @url = normalize_url(url, params)
39
+ @params = params
40
+ @access_token = params[:access_token] || params["access_token"]
41
+ @channels = {}
42
+ @socket = socket
43
+ @timeout = timeout
44
+ @ref = 0
45
+
46
+ attach_socket if @socket
47
+ end
48
+
49
+ # Plug in a transport after construction (e.g. a websocket-client-simple wrapper).
50
+ def use_socket(socket)
51
+ @socket = socket
52
+ attach_socket
53
+ self
54
+ end
55
+
56
+ def connect
57
+ raise Errors::RealtimeError, "no socket attached — call #use_socket(socket) first" unless @socket
58
+
59
+ @socket.connect
60
+ self
61
+ end
62
+
63
+ def disconnect
64
+ @socket&.close
65
+ @channels.each_value { |ch| ch.instance_variable_set(:@state, Types::ChannelStates::CLOSED) }
66
+ self
67
+ end
68
+
69
+ def connected?
70
+ @socket && @socket.connected?
71
+ end
72
+
73
+ # Get or create a Channel for the given topic. Subsequent calls with the
74
+ # same topic return the same Channel instance, matching phoenix.js semantics.
75
+ def channel(topic, params: nil)
76
+ @channels[topic] ||= Channel.new(topic, params: params, socket: self)
77
+ end
78
+
79
+ def get_channels
80
+ @channels.values
81
+ end
82
+
83
+ def remove_channel(channel)
84
+ channel.unsubscribe
85
+ @channels.delete(channel.topic)
86
+ end
87
+
88
+ def remove_all_channels
89
+ @channels.values.each { |ch| ch.unsubscribe }
90
+ @channels.clear
91
+ end
92
+
93
+ # Update the access token, send it to every joined channel so RLS reflects
94
+ # the new auth context, and remember it for future joins.
95
+ def set_auth(token)
96
+ @access_token = token
97
+ @params["access_token"] = token if @params.is_a?(Hash)
98
+ return unless @socket && @socket.connected?
99
+
100
+ @channels.each_value do |channel|
101
+ next unless channel.joined?
102
+
103
+ msg = Message.new(
104
+ event: Types::ChannelEvents::ACCESS_TOKEN,
105
+ topic: channel.topic,
106
+ payload: { "access_token" => token },
107
+ ref: next_ref
108
+ )
109
+ @socket.send(JSON.generate(
110
+ "event" => msg.event,
111
+ "topic" => msg.topic,
112
+ "payload" => msg.payload,
113
+ "ref" => msg.ref,
114
+ "join_ref" => nil
115
+ ))
116
+ end
117
+ end
118
+
119
+ # Manually emit a heartbeat. Real adapters typically wire this onto a timer.
120
+ def send_heartbeat
121
+ return unless connected?
122
+
123
+ @socket.send(JSON.generate(
124
+ "event" => Types::ChannelEvents::HEARTBEAT,
125
+ "topic" => Types::PHOENIX_TOPIC,
126
+ "payload" => {},
127
+ "ref" => next_ref,
128
+ "join_ref" => nil
129
+ ))
130
+ end
131
+
132
+ # Used by Channel — increments a shared counter so refs are unique per socket.
133
+ def next_ref
134
+ @ref += 1
135
+ @ref.to_s
136
+ end
137
+
138
+ # Used by Channel#send_push.
139
+ def push(message)
140
+ return unless @socket
141
+
142
+ @socket.send(JSON.generate(
143
+ "event" => message.event,
144
+ "topic" => message.topic,
145
+ "payload" => message.payload,
146
+ "ref" => message.ref,
147
+ "join_ref" => message.join_ref
148
+ ))
149
+ end
150
+
151
+ private
152
+
153
+ def attach_socket
154
+ @socket.on_message do |raw|
155
+ handle_inbound(raw)
156
+ end
157
+ end
158
+
159
+ def handle_inbound(raw)
160
+ message = Message.parse(raw)
161
+ return if message.topic.nil?
162
+
163
+ @channels.each_value do |channel|
164
+ channel.dispatch(message) if channel.topic == message.topic
165
+ end
166
+ end
167
+
168
+ def normalize_url(url, params)
169
+ normalized = url.to_s.dup
170
+ normalized.sub!(%r{\Ahttp://}, "ws://")
171
+ normalized.sub!(%r{\Ahttps://}, "wss://")
172
+ normalized = "#{normalized}/websocket" unless normalized.end_with?("/websocket")
173
+
174
+ query = { "vsn" => Types::VSN }.merge(params.transform_keys(&:to_s)) if params && !params.empty?
175
+ query ||= { "vsn" => Types::VSN }
176
+
177
+ separator = normalized.include?("?") ? "&" : "?"
178
+ "#{normalized}#{separator}#{URI.encode_www_form(query)}"
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Supabase
4
+ module Realtime
5
+ module Errors
6
+ class RealtimeError < StandardError; end
7
+
8
+ # Raised when subscribe() is called more than once on the same Channel
9
+ # instance — the Phoenix protocol only allows one join per channel.
10
+ class AlreadyJoinedError < RealtimeError; end
11
+
12
+ # Raised when a push waits longer than its timeout for a reply.
13
+ class PushTimeoutError < RealtimeError; end
14
+
15
+ # Raised when a non-JSON or malformed frame arrives on the WebSocket.
16
+ class ProtocolError < RealtimeError; end
17
+ end
18
+ end
19
+ end