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.
- checksums.yaml +7 -0
- data/lib/supabase/README.md +90 -0
- data/lib/supabase/auth/README.md +172 -0
- data/lib/supabase/auth/admin_api.rb +218 -0
- data/lib/supabase/auth/admin_oauth_api.rb +51 -0
- data/lib/supabase/auth/api.rb +125 -0
- data/lib/supabase/auth/async/admin_api.rb +36 -0
- data/lib/supabase/auth/async/admin_oauth_api.rb +15 -0
- data/lib/supabase/auth/async/api.rb +32 -0
- data/lib/supabase/auth/async/client.rb +33 -0
- data/lib/supabase/auth/async.rb +14 -0
- data/lib/supabase/auth/client.rb +1217 -0
- data/lib/supabase/auth/constants.rb +32 -0
- data/lib/supabase/auth/errors.rb +207 -0
- data/lib/supabase/auth/helpers.rb +222 -0
- data/lib/supabase/auth/memory_storage.rb +25 -0
- data/lib/supabase/auth/storage.rb +19 -0
- data/lib/supabase/auth/timer.rb +40 -0
- data/lib/supabase/auth/types.rb +517 -0
- data/lib/supabase/auth/version.rb +7 -0
- data/lib/supabase/auth.rb +19 -0
- data/lib/supabase/client.rb +200 -0
- data/lib/supabase/client_options.rb +82 -0
- data/lib/supabase/functions/README.md +71 -0
- data/lib/supabase/functions/async/client.rb +45 -0
- data/lib/supabase/functions/async.rb +8 -0
- data/lib/supabase/functions/client.rb +174 -0
- data/lib/supabase/functions/errors.rb +38 -0
- data/lib/supabase/functions/types.rb +37 -0
- data/lib/supabase/functions/version.rb +7 -0
- data/lib/supabase/functions.rb +11 -0
- data/lib/supabase/postgrest/README.md +84 -0
- data/lib/supabase/postgrest/async/client.rb +50 -0
- data/lib/supabase/postgrest/async.rb +8 -0
- data/lib/supabase/postgrest/client.rb +136 -0
- data/lib/supabase/postgrest/errors.rb +49 -0
- data/lib/supabase/postgrest/request_builder.rb +657 -0
- data/lib/supabase/postgrest/types.rb +60 -0
- data/lib/supabase/postgrest/utils.rb +24 -0
- data/lib/supabase/postgrest/version.rb +7 -0
- data/lib/supabase/postgrest.rb +13 -0
- data/lib/supabase/realtime/README.md +90 -0
- data/lib/supabase/realtime/channel.rb +274 -0
- data/lib/supabase/realtime/client.rb +182 -0
- data/lib/supabase/realtime/errors.rb +19 -0
- data/lib/supabase/realtime/message.rb +38 -0
- data/lib/supabase/realtime/presence.rb +136 -0
- data/lib/supabase/realtime/push.rb +48 -0
- data/lib/supabase/realtime/socket.rb +40 -0
- data/lib/supabase/realtime/sockets/async_websocket.rb +175 -0
- data/lib/supabase/realtime/sockets/websocket_client_simple.rb +94 -0
- data/lib/supabase/realtime/test_socket.rb +65 -0
- data/lib/supabase/realtime/transformers.rb +26 -0
- data/lib/supabase/realtime/types.rb +70 -0
- data/lib/supabase/realtime/version.rb +7 -0
- data/lib/supabase/realtime.rb +18 -0
- data/lib/supabase/storage/README.md +108 -0
- data/lib/supabase/storage/analytics.rb +69 -0
- data/lib/supabase/storage/async/client.rb +52 -0
- data/lib/supabase/storage/async.rb +8 -0
- data/lib/supabase/storage/bucket_api.rb +65 -0
- data/lib/supabase/storage/client.rb +80 -0
- data/lib/supabase/storage/errors.rb +32 -0
- data/lib/supabase/storage/file_api.rb +281 -0
- data/lib/supabase/storage/request.rb +63 -0
- data/lib/supabase/storage/types.rb +236 -0
- data/lib/supabase/storage/utils.rb +35 -0
- data/lib/supabase/storage/vectors.rb +189 -0
- data/lib/supabase/storage/version.rb +7 -0
- data/lib/supabase/storage.rb +17 -0
- data/lib/supabase/version.rb +5 -0
- data/lib/supabase-auth.rb +3 -0
- data/lib/supabase.rb +63 -0
- 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,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
|