pocketbase 0.1.1

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.
@@ -0,0 +1,379 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "thread"
7
+ require "timeout"
8
+
9
+ module PocketBase
10
+ module Services
11
+ # Realtime (SSE) service (port of js-sdk RealtimeService).
12
+ #
13
+ # unsub = pb.realtime.subscribe("posts/*") { |e| puts e["action"] }
14
+ # # ...
15
+ # unsub.call
16
+ #
17
+ # The SSE stream runs in a background thread with automatic reconnect
18
+ # (200ms → 2s backoff, mirroring the js-sdk intervals).
19
+ class RealtimeService < BaseService
20
+ RECONNECT_INTERVALS = [0.2, 0.3, 0.5, 1.0, 1.2, 1.5, 2.0].freeze
21
+ CONNECT_TIMEOUT = 15
22
+
23
+ attr_reader :client_id
24
+ # Callback invoked on explicit disconnect or stream interruption.
25
+ # Receives active subscription keys after connection identity is cleared.
26
+ # Runs on the caller thread for disconnect, reader thread for interruption.
27
+ # @return [Proc, nil]
28
+ attr_accessor :on_disconnect
29
+
30
+ def initialize(client)
31
+ super
32
+ @client_id = ""
33
+ @subscriptions = {} # key => Array of procs
34
+ @last_sent = []
35
+ @mutex = Mutex.new
36
+ @connect_mutex = Mutex.new
37
+ @submit_mutex = Mutex.new
38
+ @connect_queue = Queue.new
39
+ @awaiting_initial = false
40
+ @reader_thread = nil
41
+ @stop = false
42
+ @connect_gen = 0
43
+ @on_disconnect = nil
44
+ end
45
+
46
+ # Whether the realtime SSE connection is established.
47
+ def connected?
48
+ @mutex.synchronize { !@client_id.empty? && @reader_thread&.alive? }
49
+ end
50
+
51
+ # Registers a subscription listener for a topic.
52
+ # Returns an unsubscribe lambda removing only this listener.
53
+ # Unknown top-level options (filter, expand, fields) become query options.
54
+ # Failed registration removes this listener and can be retried.
55
+ #
56
+ # @param topic [String] SSE topic, e.g. "posts/*" or "@oauth2"
57
+ # @param options [Hash] Query/params, headers, and extra query options
58
+ # @yield [data] Parsed event data, delivered on the SSE reader thread
59
+ # @yieldparam data [Hash] Event payload
60
+ # @return [Proc] Callable removing only this listener
61
+ # @raise [ArgumentError] If topic or callback is missing
62
+ # @raise [ClientResponseError] If connection or subscription update fails
63
+ # @example Listen for record changes
64
+ # unsubscribe = client.realtime.subscribe("posts/*") { |event| puts event["action"] }
65
+ # unsubscribe.call
66
+ def subscribe(topic, options = {}, &callback)
67
+ raise ArgumentError, "topic must be set" if topic.nil? || topic.to_s.empty?
68
+ raise ArgumentError, "callback block required" unless callback
69
+
70
+ key = topic.to_s
71
+ unless options.nil? || options.empty?
72
+ options = send_options({}, options)
73
+ payload = {
74
+ "query" => options[:query] || options["query"],
75
+ "headers" => options[:headers] || options["headers"],
76
+ }.compact
77
+ key += (key.include?("?") ? "&" : "?") +
78
+ "options=#{Utils.encode_uri_component(JSON.generate(payload))}"
79
+ end
80
+
81
+ listener = ->(data) { callback.call(data) }
82
+ first_for_key = false
83
+ @mutex.synchronize do
84
+ @subscriptions[key] ||= []
85
+ first_for_key = @subscriptions[key].empty?
86
+ @subscriptions[key] << listener
87
+ end
88
+
89
+ begin
90
+ connect unless connected?
91
+ submit_subscriptions if first_for_key && connected?
92
+ rescue StandardError
93
+ @mutex.synchronize do
94
+ @subscriptions[key]&.delete(listener)
95
+ @subscriptions.delete(key) if @subscriptions[key]&.empty?
96
+ end
97
+ disconnect if @mutex.synchronize { @subscriptions.empty? }
98
+ raise
99
+ end
100
+
101
+ -> { unsubscribe_listener(topic.to_s, listener) }
102
+ end
103
+
104
+ # Unsubscribes from a topic, or from everything when topic is nil.
105
+ # Closes the SSE connection when no subscriptions remain.
106
+ def unsubscribe(topic = nil)
107
+ @mutex.synchronize do
108
+ if topic.nil?
109
+ @subscriptions = {}
110
+ else
111
+ matching_keys(topic.to_s).each { |k| @subscriptions.delete(k) }
112
+ end
113
+ end
114
+ submit_subscriptions
115
+ nil
116
+ end
117
+
118
+ # Unsubscribes from all subscriptions starting with the given prefix.
119
+ def unsubscribe_by_prefix(prefix)
120
+ removed = false
121
+ @mutex.synchronize do
122
+ @subscriptions.keys.each do |key|
123
+ if (key + "?").start_with?(prefix.to_s)
124
+ @subscriptions.delete(key)
125
+ removed = true
126
+ end
127
+ end
128
+ end
129
+ submit_subscriptions if removed
130
+ nil
131
+ end
132
+
133
+ # Closes the SSE connection (keeps registered subscriptions).
134
+ # Stops and joins the reader, including when the stream is idle.
135
+ # When called from a listener, the reader exits after callback delivery.
136
+ # A later subscription reconnects and restores registered subscriptions.
137
+ #
138
+ # @return [nil]
139
+ def disconnect
140
+ keys = []
141
+ had_client = false
142
+ @mutex.synchronize do
143
+ had_client = !@client_id.empty?
144
+ keys = @subscriptions.keys
145
+ @stop = true
146
+ @client_id = ""
147
+ @last_sent = []
148
+ end
149
+ @on_disconnect&.call(keys) if had_client
150
+
151
+ thread = @mutex.synchronize { @reader_thread }
152
+ if thread && thread != Thread.current
153
+ thread.kill
154
+ thread.join
155
+ end
156
+ @mutex.synchronize { @reader_thread = nil if @reader_thread == thread }
157
+ nil
158
+ end
159
+
160
+ private
161
+
162
+ def stopped?
163
+ @mutex.synchronize { @stop }
164
+ end
165
+
166
+ def connect
167
+ @connect_mutex.synchronize do
168
+ return if connected?
169
+
170
+ gen = @mutex.synchronize do
171
+ @connect_gen += 1
172
+ @awaiting_initial = true
173
+ @stop = false
174
+ if @reader_thread.nil? || !@reader_thread.alive?
175
+ @reader_thread = Thread.new { reader_loop }
176
+ end
177
+ @connect_gen
178
+ end
179
+
180
+ begin
181
+ deadline = Time.now + CONNECT_TIMEOUT
182
+ loop do
183
+ remaining = deadline - Time.now
184
+ if remaining <= 0
185
+ raise ClientResponseError.new("message" => "Realtime connect took too long.")
186
+ end
187
+
188
+ item_gen, payload = Timeout.timeout(remaining) { @connect_queue.pop }
189
+ next unless item_gen == gen # ignore stale signals from older connects
190
+
191
+ raise payload if payload.is_a?(Exception)
192
+
193
+ break
194
+ end
195
+ rescue Timeout::Error
196
+ raise ClientResponseError.new("message" => "Realtime connect took too long.")
197
+ ensure
198
+ @mutex.synchronize { @awaiting_initial = false }
199
+ end
200
+
201
+ submit_subscriptions
202
+ end
203
+ end
204
+
205
+ def reader_loop
206
+ attempts = 0
207
+ loop do
208
+ break if stopped?
209
+
210
+ begin
211
+ stream_sse
212
+ rescue StandardError => e
213
+ gen = @mutex.synchronize { @awaiting_initial ? @connect_gen : nil }
214
+ if gen
215
+ @connect_queue << [gen, e]
216
+ break
217
+ end
218
+ end
219
+ keys = @mutex.synchronize do
220
+ had_client = !@client_id.empty?
221
+ @client_id = ""
222
+ @last_sent = []
223
+ had_client ? @subscriptions.keys : nil
224
+ end
225
+ @on_disconnect&.call(keys) if keys
226
+
227
+ break if stopped?
228
+
229
+ has_subs = @mutex.synchronize { @subscriptions.any? { |_, v| v.any? } }
230
+ unless has_subs
231
+ @mutex.synchronize { @client_id = ""; @last_sent = [] }
232
+ break
233
+ end
234
+
235
+ sleep RECONNECT_INTERVALS[[attempts, RECONNECT_INTERVALS.length - 1].min]
236
+ attempts += 1
237
+ break if stopped?
238
+
239
+ end
240
+ end
241
+
242
+ def stream_sse
243
+ uri = URI.parse(@client.build_url("/api/realtime"))
244
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
245
+ req = Net::HTTP::Get.new(uri.request_uri)
246
+ req["Accept"] = "text/event-stream"
247
+ token = @client.auth_store.token
248
+ req["Authorization"] = token unless token.nil? || token.empty?
249
+
250
+ http.request(req) do |resp|
251
+ if resp.code.to_i >= 400
252
+ raise ClientResponseError.new(
253
+ "url" => @client.build_url("/api/realtime"),
254
+ "status" => resp.code.to_i,
255
+ "response" => (JSON.parse(resp.body) rescue {})
256
+ )
257
+ end
258
+
259
+ buffer = +""
260
+ event = nil
261
+ data_lines = []
262
+ event_id = nil
263
+
264
+ resp.read_body do |chunk|
265
+ break if stopped?
266
+
267
+ buffer << chunk
268
+ while (idx = buffer.index("\n"))
269
+ line = buffer.slice!(0, idx + 1).chomp
270
+ if line.empty?
271
+ dispatch_event(event, data_lines.join("\n"), event_id)
272
+ event = nil
273
+ data_lines = []
274
+ elsif line.start_with?("event:")
275
+ event = line[6..].sub(/\A /, "")
276
+ elsif line.start_with?("data:")
277
+ data_lines << line[5..].sub(/\A /, "")
278
+ elsif line.start_with?("id:")
279
+ event_id = line[3..].sub(/\A /, "")
280
+ end
281
+ # ":" comment/heartbeat lines are ignored
282
+ end
283
+ end
284
+ end
285
+ end
286
+ end
287
+
288
+ def dispatch_event(event, data, event_id)
289
+ if event == "PB_CONNECT"
290
+ @mutex.synchronize do
291
+ @client_id = event_id.to_s
292
+ @last_sent = []
293
+ end
294
+ gen = @mutex.synchronize { @awaiting_initial ? @connect_gen : nil }
295
+ @connect_queue << [gen, :connected] if gen
296
+ deliver("PB_CONNECT", parse_data(data))
297
+ unless gen
298
+ # Reconnect: submit here so the reader loop can back off and
299
+ # retry when the submit fails (a raise aborts the stream and
300
+ # triggers a reconnect in reader_loop).
301
+ #
302
+ # Initial connect: #connect submits synchronously after the
303
+ # queue pop, so subscribe() only returns once the server has
304
+ # confirmed the subscriptions (avoids a create-before-submit race).
305
+ submit_subscriptions
306
+ end
307
+ return
308
+ end
309
+
310
+ return if event.nil? || event.empty?
311
+
312
+ deliver(event, parse_data(data))
313
+ end
314
+
315
+ def deliver(key, data)
316
+ listeners = @mutex.synchronize { (@subscriptions[key] || []).dup }
317
+ listeners.each do |listener|
318
+ begin
319
+ listener.call(data)
320
+ rescue StandardError
321
+ nil
322
+ end
323
+ end
324
+ end
325
+
326
+ def parse_data(data)
327
+ JSON.parse(data)
328
+ rescue StandardError
329
+ {}
330
+ end
331
+
332
+ def submit_subscriptions
333
+ if @mutex.synchronize { @subscriptions.empty? }
334
+ disconnect
335
+ return
336
+ end
337
+
338
+ @submit_mutex.synchronize do
339
+ keys = @mutex.synchronize { @subscriptions.select { |_, v| v.any? }.keys }
340
+ client_id = @mutex.synchronize { @client_id }
341
+ return if client_id.empty?
342
+
343
+ last_sent = @mutex.synchronize { @last_sent }
344
+ return if (keys - last_sent).empty? && (last_sent - keys).empty?
345
+
346
+ @client.send_request(
347
+ "/api/realtime",
348
+ method: "POST",
349
+ body: { "clientId" => client_id, "subscriptions" => keys }
350
+ )
351
+ @mutex.synchronize { @last_sent = keys if @client_id == client_id }
352
+ end
353
+ rescue ClientResponseError
354
+ raise
355
+ rescue StandardError => e
356
+ raise ClientResponseError.wrap(e)
357
+ end
358
+
359
+ def unsubscribe_listener(topic, listener)
360
+ @mutex.synchronize do
361
+ matching_keys(topic).each do |key|
362
+ arr = @subscriptions[key]
363
+ next unless arr
364
+
365
+ arr.delete(listener)
366
+ @subscriptions.delete(key) if arr.empty?
367
+ end
368
+ end
369
+ submit_subscriptions
370
+ nil
371
+ end
372
+
373
+ def matching_keys(topic)
374
+ t = topic.include?("?") ? topic : topic + "?"
375
+ @subscriptions.keys.select { |k| (k + "?").start_with?(t) }
376
+ end
377
+ end
378
+ end
379
+ end