wsocket-io 0.1.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 (5) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +105 -0
  4. data/lib/wsocket_io.rb +405 -0
  5. metadata +74 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a49398de6e4dfd18df620b3e3fb1d367906fd9a13e4977cddf16e641b8f12b3b
4
+ data.tar.gz: a750836ba920e6a68b76bd9855ceaa6206158ae24f99fea80b3ae5d4ca07e0ee
5
+ SHA512:
6
+ metadata.gz: 57566fcd09e83871eabb16ef9b5d4cd11f4440a9a2e872968c86d48f082bb1ef575ad4a3a567a2f3838ac4d45dea0e5f8f804af7cdf1fc6b3ad53bd06225dfdd
7
+ data.tar.gz: 1a98eb61c17b8c86c341dc4d4dde05945bc4761e03bbd8b269636c7d5b0a9b8de855f6bc04cba8aeb50ad8b7408d9fae845549336254a177417c1e8db979e842
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wSocket
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # wSocket SDK for Ruby
2
+
3
+ Official Ruby SDK for wSocket — realtime pub/sub, presence, history, and push notifications.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem 'wsocket-io'
11
+ ```
12
+
13
+ Then run:
14
+
15
+ ```bash
16
+ bundle install
17
+ ```
18
+
19
+ Or install directly:
20
+
21
+ ```bash
22
+ gem install wsocket-io
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ```ruby
28
+ require 'wsocket_io'
29
+
30
+ client = WSocketIO::Client.new('wss://node00.wsocket.online', 'your-api-key')
31
+
32
+ client.on_connect { puts 'Connected!' }
33
+
34
+ client.connect
35
+
36
+ channel = client.pubsub.channel('chat')
37
+
38
+ channel.subscribe do |data, meta|
39
+ puts "Received: #{data}"
40
+ end
41
+
42
+ channel.publish({ text: 'Hello from Ruby!' })
43
+ ```
44
+
45
+ ## Presence
46
+
47
+ ```ruby
48
+ channel = client.pubsub.channel('room')
49
+
50
+ channel.presence.enter(data: { name: 'Alice' })
51
+
52
+ channel.presence.on_enter do |member|
53
+ puts "#{member.client_id} entered"
54
+ end
55
+
56
+ channel.presence.on_leave do |member|
57
+ puts "#{member.client_id} left"
58
+ end
59
+
60
+ channel.presence.get
61
+ channel.presence.on_members do |members|
62
+ puts "Online: #{members.length}"
63
+ end
64
+ ```
65
+
66
+ ## History
67
+
68
+ ```ruby
69
+ channel.history(limit: 50)
70
+ channel.on_history do |result|
71
+ result.messages.each do |msg|
72
+ puts "#{msg.publisher_id}: #{msg.data}"
73
+ end
74
+ end
75
+ ```
76
+
77
+ ## Push Notifications
78
+
79
+ ```ruby
80
+ push = WSocketIO::PushClient.new(
81
+ base_url: 'https://node00.wsocket.online',
82
+ token: 'secret',
83
+ app_id: 'app1'
84
+ )
85
+
86
+ # Register FCM device
87
+ push.register_fcm(device_token: fcm_token, member_id: 'user-123')
88
+
89
+ # Send to a member
90
+ push.send_to_member('user-123', payload: {
91
+ title: 'New message',
92
+ body: 'You have a new message'
93
+ })
94
+
95
+ # Broadcast
96
+ push.broadcast(payload: { title: 'Announcement', body: 'Server update' })
97
+ ```
98
+
99
+ ## Requirements
100
+
101
+ - Ruby 3.0+
102
+
103
+ ## License
104
+
105
+ MIT
data/lib/wsocket_io.rb ADDED
@@ -0,0 +1,405 @@
1
+ # frozen_string_literal: true
2
+
3
+ # wSocket Ruby SDK — Realtime Pub/Sub client with Presence, History, and Push.
4
+ #
5
+ # Usage:
6
+ # client = WSocketIO::Client.new('ws://localhost:9001', 'your-api-key')
7
+ # client.connect
8
+ # ch = client.pubsub.channel('chat')
9
+ # ch.subscribe { |data, meta| puts data }
10
+ # ch.publish({ text: 'hello' })
11
+
12
+ require 'json'
13
+ require 'websocket-client-simple'
14
+ require 'net/http'
15
+ require 'uri'
16
+ require 'securerandom'
17
+ require 'base64'
18
+
19
+ module WSocketIO
20
+ # ─── Types ──────────────────────────────────────────────────
21
+
22
+ MessageMeta = Struct.new(:id, :channel, :timestamp, keyword_init: true)
23
+
24
+ PresenceMember = Struct.new(:client_id, :data, :joined_at, keyword_init: true) do
25
+ def initialize(client_id: '', data: nil, joined_at: 0)
26
+ super
27
+ end
28
+ end
29
+
30
+ HistoryMessage = Struct.new(:id, :channel, :data, :publisher_id, :timestamp, :sequence, keyword_init: true) do
31
+ def initialize(id: '', channel: '', data: nil, publisher_id: '', timestamp: 0, sequence: 0)
32
+ super
33
+ end
34
+ end
35
+
36
+ HistoryResult = Struct.new(:channel, :messages, :has_more, keyword_init: true) do
37
+ def initialize(channel: '', messages: [], has_more: false)
38
+ super
39
+ end
40
+ end
41
+
42
+ Options = Struct.new(:auto_reconnect, :max_reconnect_attempts, :reconnect_delay, :token, :recover, keyword_init: true) do
43
+ def initialize(auto_reconnect: true, max_reconnect_attempts: 10, reconnect_delay: 1.0, token: nil, recover: true)
44
+ super
45
+ end
46
+ end
47
+
48
+ # ─── Presence ───────────────────────────────────────────────
49
+
50
+ class Presence
51
+ def initialize(channel_name, send_fn)
52
+ @channel_name = channel_name
53
+ @send_fn = send_fn
54
+ @enter_cbs = []
55
+ @leave_cbs = []
56
+ @update_cbs = []
57
+ @members_cbs = []
58
+ end
59
+
60
+ def enter(data: nil)
61
+ @send_fn.call({ action: 'presence.enter', channel: @channel_name, data: data })
62
+ self
63
+ end
64
+
65
+ def leave
66
+ @send_fn.call({ action: 'presence.leave', channel: @channel_name })
67
+ self
68
+ end
69
+
70
+ def update(data)
71
+ @send_fn.call({ action: 'presence.update', channel: @channel_name, data: data })
72
+ self
73
+ end
74
+
75
+ def get
76
+ @send_fn.call({ action: 'presence.get', channel: @channel_name })
77
+ self
78
+ end
79
+
80
+ def on_enter(&block) = (@enter_cbs << block; self)
81
+ def on_leave(&block) = (@leave_cbs << block; self)
82
+ def on_update(&block) = (@update_cbs << block; self)
83
+ def on_members(&block) = (@members_cbs << block; self)
84
+
85
+ def handle_event(action, data)
86
+ case action
87
+ when 'presence.enter'
88
+ m = parse_member(data)
89
+ @enter_cbs.each { |cb| cb.call(m) }
90
+ when 'presence.leave'
91
+ m = parse_member(data)
92
+ @leave_cbs.each { |cb| cb.call(m) }
93
+ when 'presence.update'
94
+ m = parse_member(data)
95
+ @update_cbs.each { |cb| cb.call(m) }
96
+ when 'presence.members'
97
+ members = (data['members'] || []).map { |d| parse_member(d) }
98
+ @members_cbs.each { |cb| cb.call(members) }
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def parse_member(d)
105
+ PresenceMember.new(
106
+ client_id: d['clientId'] || '',
107
+ data: d['data'],
108
+ joined_at: d['joinedAt'] || 0
109
+ )
110
+ end
111
+ end
112
+
113
+ # ─── Channel ────────────────────────────────────────────────
114
+
115
+ class Channel
116
+ attr_reader :name, :presence
117
+
118
+ def initialize(name, send_fn)
119
+ @name = name
120
+ @send_fn = send_fn
121
+ @message_cbs = []
122
+ @history_cbs = []
123
+ @presence = Presence.new(name, send_fn)
124
+ end
125
+
126
+ def subscribe(&callback)
127
+ @message_cbs << callback if callback
128
+ @send_fn.call({ action: 'subscribe', channel: @name })
129
+ self
130
+ end
131
+
132
+ def unsubscribe
133
+ @send_fn.call({ action: 'unsubscribe', channel: @name })
134
+ @message_cbs.clear
135
+ self
136
+ end
137
+
138
+ def publish(data, persist: nil)
139
+ msg = { action: 'publish', channel: @name, data: data, id: SecureRandom.uuid }
140
+ msg[:persist] = persist unless persist.nil?
141
+ @send_fn.call(msg)
142
+ self
143
+ end
144
+
145
+ def history(limit: nil, before: nil, after: nil, direction: nil)
146
+ opts = { action: 'history', channel: @name }
147
+ opts[:limit] = limit if limit
148
+ opts[:before] = before if before
149
+ opts[:after] = after if after
150
+ opts[:direction] = direction if direction
151
+ @send_fn.call(opts)
152
+ self
153
+ end
154
+
155
+ def on_history(&block)
156
+ @history_cbs << block
157
+ self
158
+ end
159
+
160
+ def handle_message(data, meta)
161
+ @message_cbs.each { |cb| cb.call(data, meta) }
162
+ end
163
+
164
+ def handle_history(result)
165
+ @history_cbs.each { |cb| cb.call(result) }
166
+ end
167
+ end
168
+
169
+ # ─── PubSub Namespace ──────────────────────────────────────
170
+
171
+ class PubSubNamespace
172
+ def initialize(client)
173
+ @client = client
174
+ end
175
+
176
+ def channel(name)
177
+ @client.channel(name)
178
+ end
179
+ end
180
+
181
+ # ─── Push Client ────────────────────────────────────────────
182
+
183
+ class PushClient
184
+ def initialize(base_url:, token:, app_id:)
185
+ @base_url = base_url
186
+ @token = token
187
+ @app_id = app_id
188
+ end
189
+
190
+ def register_fcm(device_token:, member_id:)
191
+ post('register', { memberId: member_id, platform: 'fcm', subscription: { deviceToken: device_token } })
192
+ end
193
+
194
+ def register_apns(device_token:, member_id:)
195
+ post('register', { memberId: member_id, platform: 'apns', subscription: { deviceToken: device_token } })
196
+ end
197
+
198
+ def send_to_member(member_id, payload:)
199
+ post('send', { memberId: member_id, payload: payload })
200
+ end
201
+
202
+ def broadcast(payload:)
203
+ post('broadcast', { payload: payload })
204
+ end
205
+
206
+ def unregister(member_id, platform: nil)
207
+ body = { memberId: member_id }
208
+ body[:platform] = platform if platform
209
+ uri = URI("#{@base_url}/api/push/unregister")
210
+ req = Net::HTTP::Delete.new(uri, headers)
211
+ req.body = body.to_json
212
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') { |http| http.request(req) }
213
+ end
214
+
215
+ private
216
+
217
+ def headers
218
+ { 'Authorization' => "Bearer #{@token}", 'X-App-Id' => @app_id, 'Content-Type' => 'application/json' }
219
+ end
220
+
221
+ def post(path, body)
222
+ uri = URI("#{@base_url}/api/push/#{path}")
223
+ req = Net::HTTP::Post.new(uri, headers)
224
+ req.body = body.to_json
225
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') { |http| http.request(req) }
226
+ end
227
+ end
228
+
229
+ # ─── Client ─────────────────────────────────────────────────
230
+
231
+ class Client
232
+ attr_reader :pubsub
233
+
234
+ def initialize(url, api_key, options = Options.new)
235
+ @url = url
236
+ @api_key = api_key
237
+ @options = options
238
+ @channels = {}
239
+ @subscribed_channels = {}
240
+ @last_message_ts = 0
241
+ @reconnect_attempts = 0
242
+ @connected = false
243
+ @ws = nil
244
+
245
+ @on_connect_cbs = []
246
+ @on_disconnect_cbs = []
247
+ @on_error_cbs = []
248
+
249
+ @pubsub = PubSubNamespace.new(self)
250
+ end
251
+
252
+ def on_connect(&block) = (@on_connect_cbs << block; self)
253
+ def on_disconnect(&block) = (@on_disconnect_cbs << block; self)
254
+ def on_error(&block) = (@on_error_cbs << block; self)
255
+ def connected? = @connected
256
+
257
+ def connect
258
+ ws_url = @url.dup
259
+ ws_url += @url.include?('?') ? '&' : '?'
260
+ ws_url += "key=#{@api_key}"
261
+ ws_url += "&token=#{@options.token}" if @options.token
262
+
263
+ client = self
264
+ @ws = WebSocket::Client::Simple.connect(ws_url) do |ws|
265
+ ws.on :open do
266
+ client.send(:handle_open)
267
+ end
268
+
269
+ ws.on :message do |msg|
270
+ client.send(:handle_raw_message, msg.data)
271
+ end
272
+
273
+ ws.on :close do |e|
274
+ client.send(:handle_close, e)
275
+ end
276
+
277
+ ws.on :error do |e|
278
+ client.send(:handle_error, e)
279
+ end
280
+ end
281
+
282
+ self
283
+ end
284
+
285
+ def disconnect
286
+ @connected = false
287
+ @ws&.close
288
+ end
289
+
290
+ def channel(name)
291
+ @channels[name] ||= Channel.new(name, method(:send_msg))
292
+ end
293
+
294
+ def configure_push(base_url:, token:, app_id:)
295
+ PushClient.new(base_url: base_url, token: token, app_id: app_id)
296
+ end
297
+
298
+ private
299
+
300
+ def send_msg(msg)
301
+ return unless @connected
302
+
303
+ hash = stringify_keys(msg)
304
+ @ws&.send(hash.to_json)
305
+ end
306
+
307
+ def handle_open
308
+ @connected = true
309
+ @reconnect_attempts = 0
310
+
311
+ if @options.recover && !@subscribed_channels.empty? && @last_message_ts > 0
312
+ resume_data = { channels: @subscribed_channels.keys, since: @last_message_ts }
313
+ token = Base64.urlsafe_encode64(resume_data.to_json, padding: false)
314
+ send_msg({ action: 'resume', token: token })
315
+ else
316
+ @subscribed_channels.each_key do |ch|
317
+ send_msg({ action: 'subscribe', channel: ch })
318
+ end
319
+ end
320
+
321
+ @on_connect_cbs.each(&:call)
322
+ end
323
+
324
+ def handle_raw_message(raw)
325
+ msg = JSON.parse(raw)
326
+ action = msg['action']
327
+ return unless action
328
+
329
+ channel_name = msg['channel']
330
+
331
+ case action
332
+ when 'message'
333
+ ch = channel_name && @channels[channel_name]
334
+ return unless ch
335
+
336
+ ts = msg['timestamp']&.to_i || (Time.now.to_f * 1000).to_i
337
+ @last_message_ts = ts if ts > @last_message_ts
338
+ meta = MessageMeta.new(id: msg['id'] || '', channel: channel_name, timestamp: ts)
339
+ ch.handle_message(msg['data'], meta)
340
+
341
+ when 'subscribed'
342
+ @subscribed_channels[channel_name] = true if channel_name
343
+
344
+ when 'unsubscribed'
345
+ @subscribed_channels.delete(channel_name) if channel_name
346
+
347
+ when 'history'
348
+ ch = channel_name && @channels[channel_name]
349
+ return unless ch
350
+
351
+ messages = (msg['messages'] || []).map do |m|
352
+ HistoryMessage.new(
353
+ id: m['id'] || '', channel: channel_name,
354
+ data: m['data'], publisher_id: m['publisherId'] || '',
355
+ timestamp: m['timestamp']&.to_i || 0,
356
+ sequence: m['sequence']&.to_i || 0
357
+ )
358
+ end
359
+ ch.handle_history(HistoryResult.new(channel: channel_name, messages: messages, has_more: msg['hasMore'] == true))
360
+
361
+ when 'presence.enter', 'presence.leave', 'presence.update', 'presence.members'
362
+ ch = channel_name && @channels[channel_name]
363
+ return unless ch
364
+
365
+ ch.presence.handle_event(action, msg)
366
+
367
+ when 'error'
368
+ err = msg['error'] || 'Unknown error'
369
+ @on_error_cbs.each { |cb| cb.call(RuntimeError.new(err)) }
370
+ end
371
+ rescue StandardError => e
372
+ @on_error_cbs.each { |cb| cb.call(e) }
373
+ end
374
+
375
+ def handle_close(_event)
376
+ @connected = false
377
+ @on_disconnect_cbs.each { |cb| cb.call(1000) }
378
+ maybe_reconnect
379
+ end
380
+
381
+ def handle_error(error)
382
+ @connected = false
383
+ @on_error_cbs.each { |cb| cb.call(error) }
384
+ maybe_reconnect
385
+ end
386
+
387
+ def maybe_reconnect
388
+ return unless @options.auto_reconnect
389
+ return if @reconnect_attempts >= @options.max_reconnect_attempts
390
+
391
+ @reconnect_attempts += 1
392
+ delay = @options.reconnect_delay * @reconnect_attempts
393
+ Thread.new do
394
+ sleep(delay)
395
+ connect unless @connected
396
+ end
397
+ end
398
+
399
+ def stringify_keys(hash)
400
+ hash.transform_keys(&:to_s).transform_values do |v|
401
+ v.is_a?(Hash) ? stringify_keys(v) : v
402
+ end
403
+ end
404
+ end
405
+ end
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wsocket-io
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - wSocket
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: websocket-client-simple
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.8'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.8'
27
+ - !ruby/object:Gem::Dependency
28
+ name: json
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.6'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.6'
41
+ description: Official Ruby SDK for wSocket — realtime pub/sub, presence, history,
42
+ and push notifications.
43
+ email: sdk@wsocket.io
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - LICENSE
49
+ - README.md
50
+ - lib/wsocket_io.rb
51
+ homepage: https://github.com/wsocket-io/sdk-ruby
52
+ licenses:
53
+ - MIT
54
+ metadata: {}
55
+ post_install_message:
56
+ rdoc_options: []
57
+ require_paths:
58
+ - lib
59
+ required_ruby_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '3.0'
64
+ required_rubygems_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ requirements: []
70
+ rubygems_version: 3.3.5
71
+ signing_key:
72
+ specification_version: 4
73
+ summary: wSocket SDK for Ruby
74
+ test_files: []