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,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module Supabase
|
|
8
|
+
module Realtime
|
|
9
|
+
# A Phoenix Channel frame: { event, topic, payload, ref, join_ref }.
|
|
10
|
+
# Used both for outbound pushes and parsed inbound messages.
|
|
11
|
+
Message = Struct.new(:event, :topic, :payload, :ref, :join_ref, keyword_init: true) do
|
|
12
|
+
def to_json(*)
|
|
13
|
+
JSON.generate(
|
|
14
|
+
"event" => event,
|
|
15
|
+
"topic" => topic,
|
|
16
|
+
"payload" => payload,
|
|
17
|
+
"ref" => ref,
|
|
18
|
+
"join_ref" => join_ref
|
|
19
|
+
)
|
|
20
|
+
end
|
|
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.
|
|
24
|
+
def self.parse(raw)
|
|
25
|
+
json = JSON.parse(raw)
|
|
26
|
+
new(
|
|
27
|
+
event: json["event"],
|
|
28
|
+
topic: json["topic"],
|
|
29
|
+
payload: json["payload"] || {},
|
|
30
|
+
ref: json["ref"],
|
|
31
|
+
join_ref: json["join_ref"]
|
|
32
|
+
)
|
|
33
|
+
rescue JSON::ParserError => e
|
|
34
|
+
raise Errors::ProtocolError, "Malformed Phoenix frame: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Supabase
|
|
4
|
+
module Realtime
|
|
5
|
+
# Tracks presence state for one channel and implements the Phoenix Presence
|
|
6
|
+
# sync algorithm: presence_state replaces the local snapshot, presence_diff
|
|
7
|
+
# applies joins/leaves on top of it.
|
|
8
|
+
#
|
|
9
|
+
# The algorithm mirrors phoenix.js's Presence.syncState / Presence.syncDiff so
|
|
10
|
+
# callers porting from JS/Python see identical behavior.
|
|
11
|
+
class Presence
|
|
12
|
+
attr_reader :state
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@state = {}
|
|
16
|
+
@on_sync_callbacks = []
|
|
17
|
+
@on_join_callbacks = []
|
|
18
|
+
@on_leave_callbacks = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The first presence_state message after joining sends the full state. Any
|
|
22
|
+
# local metas we already have for a key but the server doesn't are emitted
|
|
23
|
+
# as leaves; anything new is emitted as a join.
|
|
24
|
+
def sync_state(new_state)
|
|
25
|
+
joins = {}
|
|
26
|
+
leaves = {}
|
|
27
|
+
|
|
28
|
+
@state.each do |key, presence|
|
|
29
|
+
leaves[key] = presence unless new_state.key?(key)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
new_state.each do |key, new_presence|
|
|
33
|
+
current = @state[key]
|
|
34
|
+
if current
|
|
35
|
+
joined = []
|
|
36
|
+
left = []
|
|
37
|
+
current_refs = metas(current).map { |m| m["phx_ref"] }
|
|
38
|
+
new_refs = metas(new_presence).map { |m| m["phx_ref"] }
|
|
39
|
+
joined = metas(new_presence).reject { |m| current_refs.include?(m["phx_ref"]) }
|
|
40
|
+
left = metas(current).reject { |m| new_refs.include?(m["phx_ref"]) }
|
|
41
|
+
joins[key] = { "metas" => joined } unless joined.empty?
|
|
42
|
+
leaves[key] = { "metas" => left } unless left.empty?
|
|
43
|
+
else
|
|
44
|
+
joins[key] = new_presence
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
@state = deep_copy(new_state)
|
|
49
|
+
emit_joins(joins)
|
|
50
|
+
emit_leaves(leaves)
|
|
51
|
+
@on_sync_callbacks.each(&:call)
|
|
52
|
+
@state
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Subsequent presence_diff messages carry only joins/leaves to apply.
|
|
56
|
+
def sync_diff(diff)
|
|
57
|
+
joins = diff["joins"] || {}
|
|
58
|
+
leaves = diff["leaves"] || {}
|
|
59
|
+
|
|
60
|
+
joins.each do |key, presence|
|
|
61
|
+
if @state[key]
|
|
62
|
+
existing_refs = metas(@state[key]).map { |m| m["phx_ref"] }
|
|
63
|
+
new_metas = metas(presence).reject { |m| existing_refs.include?(m["phx_ref"]) }
|
|
64
|
+
@state[key] = { "metas" => metas(@state[key]) + new_metas }
|
|
65
|
+
else
|
|
66
|
+
@state[key] = presence
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
leaves.each do |key, presence|
|
|
71
|
+
next unless @state[key]
|
|
72
|
+
|
|
73
|
+
leaving_refs = metas(presence).map { |m| m["phx_ref"] }
|
|
74
|
+
remaining = metas(@state[key]).reject { |m| leaving_refs.include?(m["phx_ref"]) }
|
|
75
|
+
if remaining.empty?
|
|
76
|
+
@state.delete(key)
|
|
77
|
+
else
|
|
78
|
+
@state[key] = { "metas" => remaining }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
emit_joins(joins)
|
|
83
|
+
emit_leaves(leaves)
|
|
84
|
+
@on_sync_callbacks.each(&:call)
|
|
85
|
+
@state
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# List every meta currently tracked, flat. Useful when callers don't care
|
|
89
|
+
# about the per-key grouping.
|
|
90
|
+
def list
|
|
91
|
+
@state.values.flat_map { |presence| metas(presence) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def on_sync(&block)
|
|
95
|
+
@on_sync_callbacks << block
|
|
96
|
+
self
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def on_join(&block)
|
|
100
|
+
@on_join_callbacks << block
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def on_leave(&block)
|
|
105
|
+
@on_leave_callbacks << block
|
|
106
|
+
self
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def any_callbacks?
|
|
110
|
+
[@on_sync_callbacks, @on_join_callbacks, @on_leave_callbacks].any? { |list| !list.empty? }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def metas(presence)
|
|
116
|
+
Array(presence && presence["metas"])
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def emit_joins(joins)
|
|
120
|
+
joins.each do |key, presence|
|
|
121
|
+
@on_join_callbacks.each { |cb| cb.call(key, presence) }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def emit_leaves(leaves)
|
|
126
|
+
leaves.each do |key, presence|
|
|
127
|
+
@on_leave_callbacks.each { |cb| cb.call(key, presence) }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def deep_copy(obj)
|
|
132
|
+
Marshal.load(Marshal.dump(obj))
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "types"
|
|
4
|
+
|
|
5
|
+
module Supabase
|
|
6
|
+
module Realtime
|
|
7
|
+
# One outbound Phoenix push, awaiting a reply. The channel matches incoming
|
|
8
|
+
# phx_reply messages to pushes by `ref` and fires the appropriate handler.
|
|
9
|
+
#
|
|
10
|
+
# `receive(:ok / :error / :timeout) { |payload| ... }` registers handlers
|
|
11
|
+
# before the push is sent, mirroring phoenix.js's Push API.
|
|
12
|
+
class Push
|
|
13
|
+
attr_reader :ref, :event, :payload, :received_status
|
|
14
|
+
|
|
15
|
+
def initialize(channel, event, payload = {}, ref: nil)
|
|
16
|
+
@channel = channel
|
|
17
|
+
@event = event
|
|
18
|
+
@payload = payload
|
|
19
|
+
@ref = ref
|
|
20
|
+
@handlers = Hash.new { |h, k| h[k] = [] }
|
|
21
|
+
@received_status = nil
|
|
22
|
+
@received_payload = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def receive(status, &block)
|
|
26
|
+
if @received_status == status
|
|
27
|
+
# Reply already arrived before this handler was attached — fire immediately.
|
|
28
|
+
block.call(@received_payload)
|
|
29
|
+
else
|
|
30
|
+
@handlers[status] << block
|
|
31
|
+
end
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Called by the Channel when a phx_reply with matching ref arrives.
|
|
36
|
+
def resolve(status:, payload:)
|
|
37
|
+
@received_status = status
|
|
38
|
+
@received_payload = payload
|
|
39
|
+
@handlers[status].each { |h| h.call(payload) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Called by the Channel if no reply arrives within the timeout window.
|
|
43
|
+
def time_out
|
|
44
|
+
resolve(status: Types::AckStatus::TIMEOUT, payload: {})
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Supabase
|
|
4
|
+
module Realtime
|
|
5
|
+
# Transport interface that {Client} talks to. A real implementation wraps a
|
|
6
|
+
# WebSocket; {TestSocket} stays in-memory for specs.
|
|
7
|
+
#
|
|
8
|
+
# Implementations must provide:
|
|
9
|
+
#
|
|
10
|
+
# - `connect` — open the underlying transport
|
|
11
|
+
# - `close` — tear it down
|
|
12
|
+
# - `send(payload)` — push one raw JSON frame to the server
|
|
13
|
+
# - `connected?` — boolean state predicate
|
|
14
|
+
# - `on_message(&blk)` — register an inbound-frame callback (raw JSON string)
|
|
15
|
+
# - `on_open(&blk)` — register an on-open callback
|
|
16
|
+
# - `on_close(&blk)` — register an on-close callback
|
|
17
|
+
#
|
|
18
|
+
# The Client never assumes a specific WebSocket library; bring your own
|
|
19
|
+
# (websocket-client-simple for sync, async-websocket for async, etc.) by
|
|
20
|
+
# implementing this interface.
|
|
21
|
+
module Socket
|
|
22
|
+
# The minimum surface. Including it gives default no-op implementations so
|
|
23
|
+
# subclasses can override piecemeal.
|
|
24
|
+
def connect; raise NotImplementedError; end
|
|
25
|
+
def close; raise NotImplementedError; end
|
|
26
|
+
def send(_payload); raise NotImplementedError; end
|
|
27
|
+
def connected?; raise NotImplementedError; end
|
|
28
|
+
|
|
29
|
+
def on_message(&blk); message_callbacks << blk; end
|
|
30
|
+
def on_open(&blk); open_callbacks << blk; end
|
|
31
|
+
def on_close(&blk); close_callbacks << blk; end
|
|
32
|
+
def on_error(&blk); error_callbacks << blk; end
|
|
33
|
+
|
|
34
|
+
def message_callbacks; @message_callbacks ||= []; end
|
|
35
|
+
def open_callbacks; @open_callbacks ||= []; end
|
|
36
|
+
def close_callbacks; @close_callbacks ||= []; end
|
|
37
|
+
def error_callbacks; @error_callbacks ||= []; end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "async/http/endpoint"
|
|
5
|
+
require "async/websocket/client"
|
|
6
|
+
require "protocol/websocket/message"
|
|
7
|
+
|
|
8
|
+
require_relative "../errors"
|
|
9
|
+
require_relative "../socket"
|
|
10
|
+
|
|
11
|
+
module Supabase
|
|
12
|
+
module Realtime
|
|
13
|
+
module Sockets
|
|
14
|
+
# {Socket} implementation backed by the async-websocket gem (socketry/async).
|
|
15
|
+
#
|
|
16
|
+
# Unlike {WebsocketClientSimple} — which spawns a background OS thread for
|
|
17
|
+
# the read loop — this adapter runs entirely inside the calling fiber's
|
|
18
|
+
# Async reactor. Pick it when:
|
|
19
|
+
#
|
|
20
|
+
# - your app already runs on the socketry/async stack (Falcon, or
|
|
21
|
+
# supabase-rb's own async REST clients via async-http-faraday), so
|
|
22
|
+
# a single reactor owns all I/O;
|
|
23
|
+
# - you want cooperative concurrency without cross-thread callback
|
|
24
|
+
# hops or mutexes on listener state.
|
|
25
|
+
#
|
|
26
|
+
# require "async"
|
|
27
|
+
# require "supabase/realtime"
|
|
28
|
+
# require "supabase/realtime/sockets/async_websocket"
|
|
29
|
+
#
|
|
30
|
+
# Async do
|
|
31
|
+
# socket = Supabase::Realtime::Sockets::AsyncWebsocket.new(url: ws_url)
|
|
32
|
+
# client = Supabase::Realtime::Client.new(url: ws_url, socket: socket)
|
|
33
|
+
# client.connect
|
|
34
|
+
#
|
|
35
|
+
# channel = client.channel("realtime:public:users")
|
|
36
|
+
# channel.on_postgres_changes("INSERT", schema: "public", table: "users") { |p| puts p }
|
|
37
|
+
# channel.subscribe
|
|
38
|
+
# end
|
|
39
|
+
#
|
|
40
|
+
# All callbacks (on_open / on_message / on_close / on_error, and every
|
|
41
|
+
# downstream channel listener) run inside the Async reactor on the same
|
|
42
|
+
# fiber tree as the caller — no thread hops, no mutexes required for
|
|
43
|
+
# state owned by the reactor.
|
|
44
|
+
class AsyncWebsocket
|
|
45
|
+
include Socket
|
|
46
|
+
|
|
47
|
+
# @param url [String] ws(s):// URL including query params
|
|
48
|
+
# @param headers [Hash] extra HTTP headers sent on the upgrade request
|
|
49
|
+
# @param parent [Async::Task, nil] reactor task to attach the session
|
|
50
|
+
# to. Defaults to {Async::Task.current?} resolved at connect-time, so
|
|
51
|
+
# callers must be inside an `Async { ... }` block.
|
|
52
|
+
# @param connector [#connect] dependency-injection seam — defaults to
|
|
53
|
+
# {Async::WebSocket::Client}. Tests pass a fake that returns a stub
|
|
54
|
+
# connection so no real socket is opened.
|
|
55
|
+
def initialize(url:, headers: {}, parent: nil, connector: ::Async::WebSocket::Client)
|
|
56
|
+
@url = url
|
|
57
|
+
@headers = headers
|
|
58
|
+
@parent = parent
|
|
59
|
+
@connector = connector
|
|
60
|
+
@connection = nil
|
|
61
|
+
@session = nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def connect
|
|
65
|
+
return if connected?
|
|
66
|
+
|
|
67
|
+
parent = @parent || ::Async::Task.current?
|
|
68
|
+
unless parent
|
|
69
|
+
raise Errors::RealtimeError,
|
|
70
|
+
"Supabase::Realtime::Sockets::AsyncWebsocket#connect must run inside an Async { ... } block " \
|
|
71
|
+
"(or be constructed with parent:)"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
endpoint = ::Async::HTTP::Endpoint.parse(@url)
|
|
75
|
+
ready = ::Async::Promise.new
|
|
76
|
+
|
|
77
|
+
@session = parent.async do
|
|
78
|
+
@connector.connect(endpoint, headers: header_pairs) do |connection|
|
|
79
|
+
@connection = connection
|
|
80
|
+
fire_open
|
|
81
|
+
ready.resolve(true)
|
|
82
|
+
|
|
83
|
+
read_loop(connection)
|
|
84
|
+
end
|
|
85
|
+
rescue => err
|
|
86
|
+
fire_error(err)
|
|
87
|
+
ready.reject(err) unless ready.resolved?
|
|
88
|
+
ensure
|
|
89
|
+
fire_close
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Cooperative wait — Promise buffers the resolution, so this returns
|
|
93
|
+
# immediately whether the session task got there first or not. After
|
|
94
|
+
# this, callers can rely on connected?.
|
|
95
|
+
ready.wait
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def close
|
|
100
|
+
conn = @connection
|
|
101
|
+
session = @session
|
|
102
|
+
@connection = nil
|
|
103
|
+
@session = nil
|
|
104
|
+
|
|
105
|
+
# Closing the connection makes #read return nil → read_loop exits →
|
|
106
|
+
# the session task terminates naturally. This is more reliable than
|
|
107
|
+
# task.stop, which doesn't always interrupt a fiber blocked on a
|
|
108
|
+
# non-IO suspend point (queues, notifications).
|
|
109
|
+
begin
|
|
110
|
+
conn&.close
|
|
111
|
+
rescue StandardError
|
|
112
|
+
# Connection may already be torn down — ignore.
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Belt-and-braces: if the connection didn't unblock the read, stop
|
|
116
|
+
# the task as a fallback. No-op if it already finished.
|
|
117
|
+
session&.stop
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def send(payload)
|
|
121
|
+
conn = @connection
|
|
122
|
+
return unless conn
|
|
123
|
+
|
|
124
|
+
# Connection#write auto-wraps UTF-8 strings in a text frame, which is
|
|
125
|
+
# what the Phoenix protocol expects.
|
|
126
|
+
conn.write(payload)
|
|
127
|
+
conn.flush
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def connected?
|
|
131
|
+
!@connection.nil?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# ----- Internal callback fan-outs (public so the read task can reach them) -----
|
|
135
|
+
|
|
136
|
+
def fire_open
|
|
137
|
+
open_callbacks.each(&:call)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def fire_message(payload)
|
|
141
|
+
message_callbacks.each { |cb| cb.call(payload) }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def fire_close
|
|
145
|
+
return if @connection.nil? && close_callbacks.empty?
|
|
146
|
+
|
|
147
|
+
@connection = nil
|
|
148
|
+
close_callbacks.each(&:call)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def fire_error(err)
|
|
152
|
+
error_callbacks.each { |cb| cb.call(err) }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def header_pairs
|
|
158
|
+
@headers.map { |k, v| [k.to_s, v.to_s] }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def read_loop(connection)
|
|
162
|
+
while (message = connection.read)
|
|
163
|
+
next unless message.is_a?(::Protocol::WebSocket::TextMessage)
|
|
164
|
+
|
|
165
|
+
fire_message(message.buffer.to_s)
|
|
166
|
+
end
|
|
167
|
+
rescue ::Async::Stop
|
|
168
|
+
# graceful shutdown — initiated by #close
|
|
169
|
+
rescue => err
|
|
170
|
+
fire_error(err)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "websocket-client-simple"
|
|
4
|
+
|
|
5
|
+
require_relative "../socket"
|
|
6
|
+
|
|
7
|
+
module Supabase
|
|
8
|
+
module Realtime
|
|
9
|
+
module Sockets
|
|
10
|
+
# {Socket} implementation backed by the websocket-client-simple gem.
|
|
11
|
+
#
|
|
12
|
+
# The underlying gem spawns a background thread to read frames from the
|
|
13
|
+
# server, so every callback this adapter fires (on_open / on_message /
|
|
14
|
+
# on_close / on_error — and downstream, every user listener registered
|
|
15
|
+
# on a Channel) runs on **that background thread**. Callers are
|
|
16
|
+
# responsible for thread-safety in their listeners.
|
|
17
|
+
#
|
|
18
|
+
# require "supabase/realtime"
|
|
19
|
+
# require "supabase/realtime/sockets/websocket_client_simple"
|
|
20
|
+
#
|
|
21
|
+
# socket = Supabase::Realtime::Sockets::WebsocketClientSimple.new(url: ws_url)
|
|
22
|
+
# client = Supabase::Realtime::Client.new(url: ws_url, socket: socket)
|
|
23
|
+
# client.connect
|
|
24
|
+
class WebsocketClientSimple
|
|
25
|
+
include Socket
|
|
26
|
+
|
|
27
|
+
# @param url [String] full ws(s):// URL including query params
|
|
28
|
+
# @param headers [Hash] extra HTTP headers sent on the upgrade request
|
|
29
|
+
# @param connector [#connect] dependency injection seam — defaults to
|
|
30
|
+
# ::WebSocket::Client::Simple. Tests pass a fake that returns a stub
|
|
31
|
+
# WS client so we never open a real socket.
|
|
32
|
+
def initialize(url:, headers: {}, connector: ::WebSocket::Client::Simple)
|
|
33
|
+
@url = url
|
|
34
|
+
@headers = headers
|
|
35
|
+
@connector = connector
|
|
36
|
+
@ws = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def connect
|
|
40
|
+
return if connected?
|
|
41
|
+
|
|
42
|
+
self_ref = self
|
|
43
|
+
@ws = @connector.connect(@url, headers: @headers) do |client|
|
|
44
|
+
client.on(:open) { self_ref.fire_open }
|
|
45
|
+
client.on(:message) { |msg| self_ref.fire_message(msg) }
|
|
46
|
+
client.on(:close) { |reason| self_ref.fire_close(reason) }
|
|
47
|
+
client.on(:error) { |err| self_ref.fire_error(err) }
|
|
48
|
+
end
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def close
|
|
53
|
+
@ws&.close
|
|
54
|
+
@ws = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def send(payload)
|
|
58
|
+
@ws&.send(payload)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def connected?
|
|
62
|
+
!@ws.nil? && @ws.open?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# ----- Internal callback shims (called by the WS background thread) -----
|
|
66
|
+
# Public so the connect block can reach them, not part of the Socket
|
|
67
|
+
# contract callers should use.
|
|
68
|
+
|
|
69
|
+
def fire_open
|
|
70
|
+
open_callbacks.each(&:call)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# websocket-client-simple yields a Frame::Incoming object whose #data
|
|
74
|
+
# holds the payload and whose #type is :text / :binary / :ping / etc.
|
|
75
|
+
# We only forward text frames — Phoenix doesn't use binary.
|
|
76
|
+
def fire_message(msg)
|
|
77
|
+
return unless msg.respond_to?(:type) ? msg.type == :text : true
|
|
78
|
+
|
|
79
|
+
payload = msg.respond_to?(:data) ? msg.data : msg.to_s
|
|
80
|
+
message_callbacks.each { |cb| cb.call(payload) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def fire_close(_reason = nil)
|
|
84
|
+
@ws = nil
|
|
85
|
+
close_callbacks.each(&:call)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def fire_error(err)
|
|
89
|
+
error_callbacks.each { |cb| cb.call(err) }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "socket"
|
|
4
|
+
|
|
5
|
+
module Supabase
|
|
6
|
+
module Realtime
|
|
7
|
+
# In-memory {Socket} implementation for specs and local prototyping. Captures
|
|
8
|
+
# every frame the client sends in `sent_frames`, and exposes `inject(frame)`
|
|
9
|
+
# so a test can simulate a server response.
|
|
10
|
+
#
|
|
11
|
+
# Not intended for production use — bring a real WebSocket adapter for that.
|
|
12
|
+
class TestSocket
|
|
13
|
+
include Socket
|
|
14
|
+
|
|
15
|
+
attr_reader :sent_frames
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@connected = false
|
|
19
|
+
@sent_frames = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def connect
|
|
23
|
+
@connected = true
|
|
24
|
+
open_callbacks.each(&:call)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def close
|
|
28
|
+
@connected = false
|
|
29
|
+
close_callbacks.each(&:call)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def send(payload)
|
|
33
|
+
@sent_frames << payload
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def connected?
|
|
37
|
+
@connected
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# ----- Test helpers -----
|
|
41
|
+
|
|
42
|
+
# Push a JSON frame as if it came from the server. Accepts a JSON String
|
|
43
|
+
# or a Hash (which gets JSON-encoded for you).
|
|
44
|
+
def inject(frame)
|
|
45
|
+
raw = frame.is_a?(String) ? frame : JSON.generate(frame)
|
|
46
|
+
message_callbacks.each { |cb| cb.call(raw) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Convenience: the last frame the client pushed, parsed back to a Hash.
|
|
50
|
+
def last_sent_frame
|
|
51
|
+
return nil if @sent_frames.empty?
|
|
52
|
+
|
|
53
|
+
JSON.parse(@sent_frames.last)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def sent_events
|
|
57
|
+
@sent_frames.map { |f| JSON.parse(f)["event"] }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def reset_sent_frames
|
|
61
|
+
@sent_frames = []
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Supabase
|
|
4
|
+
module Realtime
|
|
5
|
+
# Mirrors realtime-py's `realtime.transformers`. Today this is a single
|
|
6
|
+
# helper used to derive the HTTP endpoint from the WebSocket URL so callers
|
|
7
|
+
# can hit the realtime REST surface (e.g. /api/broadcast, /connections).
|
|
8
|
+
module Transformers
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Converts a realtime socket URL into its HTTP equivalent.
|
|
12
|
+
#
|
|
13
|
+
# http_endpoint_url("wss://x.supabase.co/realtime/v1/websocket")
|
|
14
|
+
# # => "https://x.supabase.co/realtime/v1"
|
|
15
|
+
#
|
|
16
|
+
# Replaces the leading `ws`/`wss` scheme with `http`/`https`, strips any
|
|
17
|
+
# `/socket/websocket`, `/socket`, or `/websocket` suffix, and trims
|
|
18
|
+
# trailing slashes. Mirrors py's regex chain verbatim.
|
|
19
|
+
def http_endpoint_url(socket_url)
|
|
20
|
+
url = socket_url.to_s.sub(/\Aws/i, "http")
|
|
21
|
+
url = url.sub(%r{(/socket/websocket|/socket|/websocket)/?\z}i, "")
|
|
22
|
+
url.sub(%r{/+\z}, "")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Supabase
|
|
4
|
+
module Realtime
|
|
5
|
+
module Types
|
|
6
|
+
# Phoenix protocol version this client speaks. Matches supabase-py.
|
|
7
|
+
VSN = "1.0.0"
|
|
8
|
+
|
|
9
|
+
DEFAULT_TIMEOUT_SECONDS = 10
|
|
10
|
+
DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 25
|
|
11
|
+
|
|
12
|
+
# The special topic the Phoenix server uses for heartbeats and connection-level
|
|
13
|
+
# control messages.
|
|
14
|
+
PHOENIX_TOPIC = "phoenix"
|
|
15
|
+
|
|
16
|
+
# Phoenix event names — mirror supabase-py's ChannelEvents enum 1:1 so docs
|
|
17
|
+
# and message dumps line up.
|
|
18
|
+
module ChannelEvents
|
|
19
|
+
CLOSE = "phx_close"
|
|
20
|
+
ERROR = "phx_error"
|
|
21
|
+
JOIN = "phx_join"
|
|
22
|
+
REPLY = "phx_reply"
|
|
23
|
+
LEAVE = "phx_leave"
|
|
24
|
+
HEARTBEAT = "heartbeat"
|
|
25
|
+
ACCESS_TOKEN = "access_token"
|
|
26
|
+
BROADCAST = "broadcast"
|
|
27
|
+
PRESENCE = "presence"
|
|
28
|
+
PRESENCE_STATE = "presence_state"
|
|
29
|
+
PRESENCE_DIFF = "presence_diff"
|
|
30
|
+
SYSTEM = "system"
|
|
31
|
+
POSTGRES_CHANGES = "postgres_changes"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Channel lifecycle states.
|
|
35
|
+
module ChannelStates
|
|
36
|
+
CLOSED = :closed
|
|
37
|
+
ERRORED = :errored
|
|
38
|
+
JOINED = :joined
|
|
39
|
+
JOINING = :joining
|
|
40
|
+
LEAVING = :leaving
|
|
41
|
+
ALL = [CLOSED, ERRORED, JOINED, JOINING, LEAVING].freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# States passed to Channel#subscribe's callback so callers know whether the
|
|
45
|
+
# join succeeded.
|
|
46
|
+
module SubscribeStates
|
|
47
|
+
SUBSCRIBED = "SUBSCRIBED"
|
|
48
|
+
TIMED_OUT = "TIMED_OUT"
|
|
49
|
+
CLOSED = "CLOSED"
|
|
50
|
+
CHANNEL_ERROR = "CHANNEL_ERROR"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Server replies to a phx_join push with one of these statuses.
|
|
54
|
+
module AckStatus
|
|
55
|
+
OK = "ok"
|
|
56
|
+
ERROR = "error"
|
|
57
|
+
TIMEOUT = "timeout"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Postgres-change event filters callers pass to Channel#on_postgres_changes.
|
|
61
|
+
# "*" subscribes to all three events.
|
|
62
|
+
module PostgresChangesEvent
|
|
63
|
+
ALL = "*"
|
|
64
|
+
INSERT = "INSERT"
|
|
65
|
+
UPDATE = "UPDATE"
|
|
66
|
+
DELETE = "DELETE"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|