gemstack-realtime 0.3.6 → 0.4.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.
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module GemStack
6
+ module Realtime
7
+ # Who is on a presence channel (`channel "rooms:*", presence: true`),
8
+ # across every process.
9
+ #
10
+ # Each process counts its own connections per (channel, identity) and
11
+ # announces the first join and the last leave through the broker, then
12
+ # re-announces what it has every `presence_interval` seconds. Every process
13
+ # (this one included) builds the same registry from those announcements;
14
+ # an entry a process stops refreshing — it crashed or lost its broker —
15
+ # expires after three intervals. Browsers get `presence.join`/`presence.leave`
16
+ # when an identity appears or disappears (a second tab is not a new join).
17
+ class Presence
18
+ # Broker-only channel; browsers can't subscribe to it.
19
+ CHANNEL = "gemstack:presence"
20
+ # Keeps each announcement under the PostgreSQL broker's payload limit.
21
+ BATCH = 50
22
+
23
+ def initialize(interval: Realtime.config.presence_interval, grace: Realtime.config.presence_grace)
24
+ @interval = interval
25
+ @ttl = interval * 3
26
+ @grace = grace
27
+ @later = Queue.new
28
+ @mutex = Mutex.new
29
+ @local = {} # [channel, key] => { count:, meta: }
30
+ @state = {} # channel => { key => { meta:, nodes: { node => expires_at } } }
31
+ end
32
+
33
+ # This process's id; a forked process (Puma worker) gets its own.
34
+ def node
35
+ return @node if @node_pid == Process.pid
36
+
37
+ @node_pid = Process.pid
38
+ @node = "#{Process.pid}-#{SecureRandom.hex(4)}"
39
+ end
40
+
41
+ # A connection identified as `key` subscribed to a presence channel.
42
+ def track(channel, key, meta)
43
+ first = @mutex.synchronize do
44
+ entry = (@local[[channel, key]] ||= { count: 0, meta: meta })
45
+ entry[:count] += 1
46
+ entry[:count] == 1
47
+ end
48
+ start_timer
49
+ announce("join", [[channel, key, meta]]) if first
50
+ end
51
+
52
+ def untrack(channel, key)
53
+ last = @mutex.synchronize do
54
+ entry = @local[[channel, key]] or return
55
+ entry[:count] -= 1
56
+ @local.delete([channel, key]) if entry[:count] <= 0
57
+ entry[:count] <= 0
58
+ end
59
+ announce("leave", [[channel, key, nil]]) if last
60
+ end
61
+
62
+ # A dropped connection leaves only after `presence_grace` seconds, so a
63
+ # reconnect (or an SSE stream reopened with other channels) doesn't
64
+ # flicker the user out and back in. Runs off the event loop.
65
+ def untrack_later(channel, key)
66
+ @later << [monotonic + @grace, channel, key]
67
+ @mutex.synchronize { @leaver = Thread.new { leave_loop } unless @leaver&.alive? }
68
+ end
69
+
70
+ # [{ "id" => key, "meta" => {...} }, ...] for a channel, across processes.
71
+ def list(channel)
72
+ @mutex.synchronize do
73
+ (@state[channel] || {}).map { |key, entry| { "id" => key, "meta" => entry[:meta] } }
74
+ end
75
+ end
76
+
77
+ # An announcement from the broker (from any process, this one included).
78
+ def receive(message)
79
+ node = message.data["node"]
80
+ changes = @mutex.synchronize do
81
+ message.data["entries"].filter_map do |channel, key, meta|
82
+ if message.event == "leave"
83
+ [:leave, channel, key, nil] if drop(channel, key, node)
84
+ elsif add(channel, key, meta, node)
85
+ [:join, channel, key, meta]
86
+ end
87
+ end
88
+ end
89
+ notify(changes)
90
+ end
91
+
92
+ # Re-announces this process's entries and expires everyone else's stale ones.
93
+ def tick
94
+ entries = @mutex.synchronize { @local.map { |(channel, key), entry| [channel, key, entry[:meta]] } }
95
+ entries.each_slice(BATCH) { |batch| announce("refresh", batch) }
96
+ notify(@mutex.synchronize { expire })
97
+ end
98
+
99
+ def stop
100
+ [@timer, @leaver].each { |thread| thread&.kill }
101
+ @timer = @leaver = nil
102
+ end
103
+
104
+ private
105
+
106
+ def add(channel, key, meta, node)
107
+ entry = ((@state[channel] ||= {})[key] ||= { meta: meta, nodes: {} })
108
+ fresh = entry[:nodes].empty?
109
+ entry[:meta] = meta if meta
110
+ entry[:nodes][node] = monotonic + @ttl
111
+ fresh
112
+ end
113
+
114
+ def drop(channel, key, node)
115
+ entry = @state.dig(channel, key) or return false
116
+ entry[:nodes].delete(node)
117
+ return false unless entry[:nodes].empty?
118
+
119
+ remove(channel, key)
120
+ true
121
+ end
122
+
123
+ def expire
124
+ now = monotonic
125
+ lapsed = @state.flat_map do |channel, keys|
126
+ keys.filter_map do |key, entry|
127
+ entry[:nodes].delete_if { |_, expires_at| expires_at < now }
128
+ [:leave, channel, key, nil] if entry[:nodes].empty?
129
+ end
130
+ end
131
+ lapsed.each { |_, channel, key, _| remove(channel, key) }
132
+ end
133
+
134
+ def remove(channel, key)
135
+ @state[channel].delete(key)
136
+ @state.delete(channel) if @state[channel].empty?
137
+ end
138
+
139
+ def notify(changes)
140
+ changes.each do |op, channel, key, meta|
141
+ event = Message.new(nil, channel, "presence.#{op}", { "id" => key, "meta" => meta }.compact)
142
+ Realtime.hub.deliver_local(event)
143
+ end
144
+ end
145
+
146
+ def announce(kind, entries)
147
+ Realtime.broker.publish(Message.new(Realtime.next_id, CHANNEL, kind, { "node" => node, "entries" => entries }))
148
+ rescue StandardError => e
149
+ GemStack.logger.warn("realtime: presence announcement failed", error: e)
150
+ end
151
+
152
+ def start_timer
153
+ return if @timer&.alive? # a forked process has no timer thread: it starts one
154
+
155
+ @mutex.synchronize do
156
+ @timer = Thread.new { timer_loop } unless @timer&.alive?
157
+ end
158
+ end
159
+
160
+ # Same delay for everyone, so the queue is in due order.
161
+ def leave_loop
162
+ while (due, channel, key = @later.pop)
163
+ wait = due - monotonic
164
+ sleep(wait) if wait.positive?
165
+ untrack(channel, key)
166
+ end
167
+ rescue StandardError => e
168
+ GemStack.logger.warn("realtime: presence leave failed", error: e)
169
+ retry
170
+ end
171
+
172
+ def timer_loop
173
+ loop do
174
+ sleep @interval
175
+ begin
176
+ tick
177
+ rescue StandardError => e
178
+ GemStack.logger.warn("realtime: presence refresh failed", error: e)
179
+ end
180
+ end
181
+ end
182
+
183
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
184
+ end
185
+ end
186
+ end
@@ -4,11 +4,11 @@ require "nio"
4
4
 
5
5
  module GemStack
6
6
  module Realtime
7
- # A single event-loop thread (nio4r) that owns every open stream in the
8
- # process: it notices disconnects (readable + EOF), flushes buffered
9
- # writes when sockets become writable, and sends heartbeats. Request
10
- # threads only hand connections over, so 10,000 open streams cost no
11
- # server threads.
7
+ # A single event-loop thread (nio4r) that owns every open connection in
8
+ # the process: it reads what clients send (WebSocket frames; for SSE only
9
+ # the EOF of a disconnect), flushes buffered writes when sockets become
10
+ # writable, and sends each transport's heartbeat. Request threads only
11
+ # hand connections over, so 10,000 open connections cost no server threads.
12
12
  class Streamer
13
13
  def initialize(heartbeat: Realtime.config.heartbeat, hub: Realtime.hub)
14
14
  @heartbeat = heartbeat
@@ -64,7 +64,7 @@ module GemStack
64
64
  drain_commands
65
65
  next if monotonic < next_beat
66
66
 
67
- @monitors.each_key { |connection| connection.push(": ping\n\n") }
67
+ @monitors.each_key(&:heartbeat)
68
68
  next_beat = monotonic + @heartbeat
69
69
  end
70
70
  rescue StandardError => e
@@ -97,14 +97,17 @@ module GemStack
97
97
  monitor = @monitors.delete(connection) or return
98
98
  monitor.close
99
99
  @hub.remove(connection)
100
+ connection.disconnected if connection.respond_to?(:disconnected)
100
101
  end
101
102
 
102
103
  def ready(monitor)
103
104
  connection = monitor.value
104
105
  if monitor.readable?
105
- # SSE clients never send data after the request; readable means EOF.
106
- data = connection.io.read_nonblock(1024, exception: false)
107
- return connection.close if data.nil?
106
+ data = connection.io.read_nonblock(16 * 1024, exception: false)
107
+ return connection.close if data.nil? # EOF: the client went away
108
+
109
+ # WebSocket frames; SSE clients send nothing after the request.
110
+ connection.receive(data) if data.is_a?(String) && connection.respond_to?(:receive)
108
111
  end
109
112
  return unless monitor.writable?
110
113
 
@@ -21,7 +21,7 @@ module GemStack
21
21
  end
22
22
  end
23
23
 
24
- def broadcasts = Realtime.broker.messages
24
+ def broadcasts = Realtime.broker.messages.reject { |m| m.channel == Presence::CHANNEL }
25
25
 
26
26
  # A broadcast on channel (optionally with this event name / data) happened.
27
27
  def assert_broadcast(channel, event = nil, data: nil)
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha1"
4
+
5
+ module GemStack
6
+ module Realtime
7
+ module WebSocket
8
+ # The wire format of RFC 6455 for the server side: the opening handshake,
9
+ # an incremental frame parser for (masked) client frames and the encoder
10
+ # for (unmasked) server frames. No extensions are negotiated. It only
11
+ # turns bytes into messages and back; Connection decides what they mean,
12
+ # so another implementation can replace it.
13
+ module Codec
14
+ GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
15
+
16
+ OPCODES = { continuation: 0x0, text: 0x1, binary: 0x2, close: 0x8, ping: 0x9, pong: 0xA }.freeze
17
+ NAMES = OPCODES.invert.freeze
18
+
19
+ # Close codes (RFC 6455 §7.4.1).
20
+ NORMAL = 1000
21
+ GOING_AWAY = 1001
22
+ PROTOCOL_ERROR = 1002
23
+ UNSUPPORTED_DATA = 1003
24
+ INVALID_PAYLOAD = 1007
25
+ POLICY_VIOLATION = 1008
26
+ MESSAGE_TOO_BIG = 1009
27
+ INTERNAL_ERROR = 1011
28
+
29
+ class Error < StandardError
30
+ attr_reader :code
31
+
32
+ def initialize(message, code: PROTOCOL_ERROR)
33
+ super(message)
34
+ @code = code
35
+ end
36
+ end
37
+
38
+ module_function
39
+
40
+ # Sec-WebSocket-Accept for a client's Sec-WebSocket-Key.
41
+ def accept_key(key) = [Digest::SHA1.digest("#{key}#{GUID}")].pack("m0")
42
+
43
+ # A valid Sec-WebSocket-Key is 16 random bytes, base64-encoded.
44
+ def valid_key?(key) = key.to_s.match?(%r{\A[A-Za-z0-9+/]{22}==\z})
45
+
46
+ def handshake_response(key)
47
+ "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" \
48
+ "Sec-WebSocket-Accept: #{accept_key(key)}\r\n\r\n"
49
+ end
50
+
51
+ def text(string) = frame(:text, string.encode(Encoding::UTF_8))
52
+ def ping(payload = "") = frame(:ping, payload)
53
+ def pong(payload = "") = frame(:pong, payload)
54
+ def close(code = NORMAL, reason = "") = frame(:close, [code].pack("n") + reason.to_s.byteslice(0, 123))
55
+
56
+ # One final, unmasked frame.
57
+ def frame(type, payload)
58
+ payload = payload.b
59
+ length = payload.bytesize
60
+ head = [0x80 | OPCODES.fetch(type)].pack("C")
61
+ head << if length < 126 then [length].pack("C")
62
+ elsif length < 65_536 then [126, length].pack("Cn")
63
+ else [127, length].pack("CQ>")
64
+ end
65
+ head << payload
66
+ end
67
+
68
+ # Applies (or removes) a 4-byte masking key.
69
+ def unmask(payload, mask)
70
+ return payload if payload.empty?
71
+
72
+ key = mask.unpack("C4")
73
+ bytes = payload.unpack("C*")
74
+ bytes.each_index { |i| bytes[i] ^= key[i & 3] }
75
+ bytes.pack("C*")
76
+ end
77
+
78
+ # feed(bytes) yields [:text, String] / [:ping, String] / [:pong, String] /
79
+ # [:close, code, reason] as complete messages arrive (fragmented messages
80
+ # are reassembled; control frames may arrive between fragments). Raises
81
+ # Codec::Error, with the close code to send, on any protocol violation.
82
+ class Parser
83
+ def initialize(max_message_size:)
84
+ @max = max_message_size
85
+ @buffer = String.new(encoding: Encoding::BINARY)
86
+ @fragments = nil
87
+ end
88
+
89
+ def feed(bytes)
90
+ @buffer << bytes.b
91
+ while (frame = next_frame)
92
+ message = assemble(*frame)
93
+ yield message if message
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ # [opcode, fin, payload] of the next complete frame, or nil.
100
+ def next_frame
101
+ return nil if @buffer.bytesize < 2
102
+
103
+ first, second = @buffer.unpack("CC")
104
+ raise Error, "reserved bits set (no extensions are negotiated)" if first.anybits?(0x70)
105
+ raise Error, "client frames must be masked" unless second.anybits?(0x80)
106
+
107
+ fin = first.anybits?(0x80)
108
+ opcode = first & 0x0F
109
+ length, offset = payload_length(second & 0x7F)
110
+ return nil unless length
111
+
112
+ control = opcode >= 0x8
113
+ raise Error, "control frames can't be fragmented or exceed 125 bytes" if control && (!fin || length > 125)
114
+ raise Error.new("message is larger than #{@max} bytes", code: MESSAGE_TOO_BIG) if length > @max
115
+ return nil if @buffer.bytesize < offset + 4 + length
116
+
117
+ mask = @buffer.byteslice(offset, 4)
118
+ payload = Codec.unmask(@buffer.byteslice(offset + 4, length), mask)
119
+ @buffer = @buffer.byteslice(offset + 4 + length, @buffer.bytesize)
120
+ [opcode, fin, payload]
121
+ end
122
+
123
+ def payload_length(short)
124
+ case short
125
+ when 126
126
+ return nil if @buffer.bytesize < 4
127
+
128
+ [@buffer.byteslice(2, 2).unpack1("n"), 4]
129
+ when 127
130
+ return nil if @buffer.bytesize < 10
131
+
132
+ length = @buffer.byteslice(2, 8).unpack1("Q>")
133
+ raise Error, "invalid payload length" if length >= 2**63
134
+
135
+ [length, 10]
136
+ else [short, 2]
137
+ end
138
+ end
139
+
140
+ def assemble(opcode, fin, payload)
141
+ case NAMES[opcode]
142
+ when :ping, :pong then [NAMES[opcode], payload]
143
+ when :close then close_frame(payload)
144
+ when :text, :binary then start_message(opcode, fin, payload)
145
+ when :continuation then continue_message(fin, payload)
146
+ else raise Error, "unknown opcode #{opcode}"
147
+ end
148
+ end
149
+
150
+ def close_frame(payload)
151
+ return [:close, NORMAL, ""] if payload.empty?
152
+ raise Error, "close frame with a 1-byte payload" if payload.bytesize == 1
153
+
154
+ reason = payload.byteslice(2, payload.bytesize).force_encoding(Encoding::UTF_8)
155
+ raise Error.new("close reason isn't UTF-8", code: INVALID_PAYLOAD) unless reason.valid_encoding?
156
+
157
+ [:close, payload.unpack1("n"), reason]
158
+ end
159
+
160
+ def start_message(opcode, fin, payload)
161
+ raise Error, "new message before the previous one finished" if @fragments
162
+
163
+ @type = opcode
164
+ @fragments = payload
165
+ fin ? finish_message : nil
166
+ end
167
+
168
+ def continue_message(fin, payload)
169
+ raise Error, "continuation frame without a message" unless @fragments
170
+ if @fragments.bytesize + payload.bytesize > @max
171
+ raise Error.new("message is larger than #{@max} bytes", code: MESSAGE_TOO_BIG)
172
+ end
173
+
174
+ @fragments << payload
175
+ fin ? finish_message : nil
176
+ end
177
+
178
+ def finish_message
179
+ data = @fragments
180
+ @fragments = nil
181
+ raise Error.new("binary messages aren't supported", code: UNSUPPORTED_DATA) if @type == OPCODES[:binary]
182
+
183
+ text = data.force_encoding(Encoding::UTF_8)
184
+ raise Error.new("text message isn't valid UTF-8", code: INVALID_PAYLOAD) unless text.valid_encoding?
185
+
186
+ [:text, text]
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module GemStack
7
+ module Realtime
8
+ module WebSocket
9
+ # One browser's WebSocket. Frames are parsed on the event loop; anything
10
+ # that runs application code (authorizing a subscription, a `receive`
11
+ # handler) goes through the connection's inbox to the Dispatcher, in order.
12
+ #
13
+ # Messages are JSON objects with a "type":
14
+ #
15
+ # browser → server
16
+ # { type: "subscribe", channel, last_id?, ref? } replays events after last_id
17
+ # { type: "unsubscribe", channel, ref? }
18
+ # { type: "message", channel, event, data?, ref? } handled by `receive` in config/channels.rb
19
+ # { type: "ping" }
20
+ #
21
+ # server → browser
22
+ # { type: "welcome", connection_id, heartbeat }
23
+ # { type: "subscribed", channel, ref?, presence? } / { type: "unsubscribed", channel, ref? }
24
+ # { type: "denied", channel, ref?, code } the channel was refused
25
+ # { type: "event", id, channel, event, data } a broadcast
26
+ # { type: "gap", channel } events missed while away are gone: refetch
27
+ # { type: "presence", channel, event: "join"|"leave", id, meta? }
28
+ # { type: "reply", ref, ok, data? | error? } the result of a "message"
29
+ # { type: "error", code, message } { type: "pong" }
30
+ class Connection < Realtime::Connection
31
+ attr_reader :id, :request, :identity
32
+
33
+ MAX_INBOX = 100
34
+
35
+ def initialize(io, request:, identity:, streamer:, dispatcher: Dispatcher.instance,
36
+ max_buffer: Realtime.config.max_buffer)
37
+ super(io, [], streamer: streamer, max_buffer: max_buffer)
38
+ @id = SecureRandom.hex(8)
39
+ @request = request
40
+ @identity = identity
41
+ @dispatcher = dispatcher
42
+ @parser = Codec::Parser.new(max_message_size: Realtime.config.max_message_size)
43
+ @inbox = []
44
+ @inbox_mutex = Mutex.new
45
+ @processing = false
46
+ @presence = {} # channel => identity key
47
+ @last_seen = monotonic
48
+ @window = [monotonic.floor, 0]
49
+ end
50
+
51
+ def welcome = send_json(type: "welcome", connection_id: id, heartbeat: Realtime.config.heartbeat)
52
+
53
+ # ── event loop ───────────────────────────────────────────────────
54
+
55
+ def deliver(message)
56
+ if message.event.start_with?("presence.")
57
+ send_json(type: "presence", channel: message.channel, event: message.event.delete_prefix("presence."),
58
+ **message.data.transform_keys(&:to_sym))
59
+ else
60
+ push(message.ws_frame)
61
+ end
62
+ end
63
+
64
+ # A ping frame every heartbeat; a client silent for three is gone.
65
+ def heartbeat
66
+ return fail_connection(Codec::GOING_AWAY, "heartbeat timeout") if silent?
67
+
68
+ push(Codec.ping)
69
+ end
70
+
71
+ def receive(bytes)
72
+ @last_seen = monotonic
73
+ @parser.feed(bytes) { |type, payload, reason| frame(type, payload, reason) }
74
+ rescue Codec::Error => e
75
+ fail_connection(e.code, e.message)
76
+ end
77
+
78
+ # Called by the Streamer once the socket is gone.
79
+ def disconnected
80
+ @presence.each { |channel, key| Realtime.presence.untrack_later(channel, key) }
81
+ @presence.clear
82
+ end
83
+
84
+ # ── dispatcher ───────────────────────────────────────────────────
85
+
86
+ def process_inbox
87
+ while (message = next_message)
88
+ handle(message) unless closed?
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ def frame(type, payload, reason)
95
+ case type
96
+ when :text then incoming(payload)
97
+ when :ping then push(Codec.pong(payload))
98
+ when :close
99
+ push(Codec.close(payload == 1005 ? Codec::NORMAL : payload, reason.to_s))
100
+ close_after_flush
101
+ end
102
+ end
103
+
104
+ def incoming(text)
105
+ message = JSON.parse(text)
106
+ return error("invalid_message", "expected a JSON object") unless message.is_a?(Hash)
107
+
108
+ case message["type"]
109
+ when "ping" then send_json(type: "pong")
110
+ when "subscribe", "unsubscribe", "message" then enqueue(message)
111
+ else error("unknown_type", "unknown message type #{message["type"].inspect}", message["ref"])
112
+ end
113
+ rescue JSON::ParserError
114
+ error("invalid_json", "messages are JSON objects")
115
+ end
116
+
117
+ def enqueue(message)
118
+ return reply_error(message["ref"], "rate_limited", "too many messages") if rate_limited?
119
+
120
+ start = @inbox_mutex.synchronize do
121
+ return reply_error(message["ref"], "busy", "too many pending messages") if @inbox.size >= MAX_INBOX
122
+
123
+ @inbox << message
124
+ !@processing && (@processing = true)
125
+ end
126
+ @dispatcher.schedule(self) if start
127
+ end
128
+
129
+ def next_message
130
+ @inbox_mutex.synchronize do
131
+ @inbox.shift.tap { |message| @processing = false unless message }
132
+ end
133
+ end
134
+
135
+ def handle(message)
136
+ case message["type"]
137
+ when "subscribe" then subscribe(message["channel"].to_s, message["last_id"], message["ref"])
138
+ when "unsubscribe" then unsubscribe(message["channel"].to_s, message["ref"])
139
+ when "message" then receive_message(message)
140
+ end
141
+ end
142
+
143
+ def subscribe(channel, last_id, ref)
144
+ return send_json(type: "subscribed", channel: channel, ref: ref) if channels.include?(channel)
145
+ return deny(channel, ref, "invalid_channel") unless subscribable?(channel)
146
+ return deny(channel, ref, "too_many_channels") if channels.size >= Realtime.config.max_channels
147
+
148
+ rule = Realtime.channels.authorize(channel, request) or return deny(channel, ref, "forbidden")
149
+ tracked = track(channel, rule) # announce first: our own join isn't sent back to us
150
+ Realtime.hub.subscribe(self, channel)
151
+ send_json({ type: "subscribed", channel: channel, ref: ref,
152
+ presence: presence_list(channel, tracked) }.compact)
153
+ messages, gap = Realtime.hub.replay([channel], last_id.to_s)
154
+ messages.each { |message| deliver(message) }
155
+ send_json(type: "gap", channel: channel) if gap
156
+ end
157
+
158
+ def unsubscribe(channel, ref)
159
+ Realtime.hub.unsubscribe(self, channel)
160
+ key = @presence.delete(channel)
161
+ Realtime.presence.untrack(channel, key) if key
162
+ send_json({ type: "unsubscribed", channel: channel, ref: ref }.compact)
163
+ end
164
+
165
+ # Presence channels: [key, meta] of an identified connection (tracked),
166
+ # false for an anonymous one, nil for channels without presence.
167
+ def track(channel, rule)
168
+ return nil unless rule.presence
169
+
170
+ key, meta = Realtime.identity_key(identity)
171
+ return false unless key
172
+
173
+ @presence[channel] = key
174
+ Realtime.presence.track(channel, key, meta)
175
+ [key, meta]
176
+ end
177
+
178
+ # Who's here, including this connection right away (its announcement
179
+ # may still be travelling through the broker).
180
+ def presence_list(channel, tracked)
181
+ return nil if tracked.nil?
182
+
183
+ list = Realtime.presence.list(channel)
184
+ key, meta = tracked
185
+ return list if !key || list.any? { |entry| entry["id"] == key }
186
+
187
+ list + [{ "id" => key, "meta" => meta }]
188
+ end
189
+
190
+ def receive_message(message)
191
+ channel = message["channel"].to_s
192
+ ref = message["ref"]
193
+ return reply_error(ref, "not_subscribed", "subscribe to #{channel} first") unless channels.include?(channel)
194
+
195
+ handler, params = Realtime.channels.receiver(channel)
196
+ return reply_error(ref, "no_handler", "#{channel} doesn't accept messages") unless handler
197
+
198
+ incoming = Incoming.new(channel, message["event"].to_s, message["data"], params, self)
199
+ result = handler.call(incoming)
200
+ send_json({ type: "reply", ref: ref, ok: true, data: Serializer.render(result) }.compact) if ref
201
+ rescue GemStack::Error => e
202
+ reply_error(ref, e.code, e.message)
203
+ rescue StandardError => e
204
+ GemStack.logger.error("realtime: receive handler failed", channel: channel, error: e,
205
+ backtrace: Array(e.backtrace).first(10))
206
+ reply_error(ref, "internal_error", "the message couldn't be handled")
207
+ end
208
+
209
+ def subscribable?(channel) = CHANNEL_NAME.match?(channel) && channel != Presence::CHANNEL
210
+
211
+ def deny(channel, ref, code) = send_json({ type: "denied", channel: channel, ref: ref, code: code }.compact)
212
+
213
+ def reply_error(ref, code, message)
214
+ return error(code, message) unless ref
215
+
216
+ send_json(type: "reply", ref: ref, ok: false, error: { code: code, message: message })
217
+ end
218
+
219
+ def error(code, message, ref = nil)
220
+ send_json({ type: "error", code: code, message: message, ref: ref }.compact)
221
+ end
222
+
223
+ def send_json(hash) = push(Codec.text(HTTP::JSONCodec.default.dump(hash)))
224
+
225
+ def fail_connection(code, reason)
226
+ GemStack.logger.debug("realtime: closing websocket", code: code, reason: reason)
227
+ push(Codec.close(code, reason))
228
+ close_after_flush
229
+ end
230
+
231
+ def silent? = monotonic - @last_seen > Realtime.config.heartbeat * 3
232
+
233
+ # At most max_messages_per_second messages that run application code.
234
+ def rate_limited?
235
+ second = monotonic.floor
236
+ @window = [second, 0] unless @window.first == second
237
+ @window[1] += 1
238
+ @window[1] > Realtime.config.max_messages_per_second
239
+ end
240
+
241
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
242
+ end
243
+
244
+ # What a `receive` handler gets (config/channels.rb):
245
+ # message.channel, message.event, message.data, message.params (the * segments),
246
+ # message.identity (from `identify`), message.request (the WebSocket handshake).
247
+ Incoming = Struct.new(:channel, :event, :data, :params, :connection) do
248
+ def identity = connection.identity
249
+ def request = connection.request
250
+ end
251
+ end
252
+ end
253
+ end