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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +4 -4
- data/lib/gemstack/realtime/connection.rb +51 -6
- data/lib/gemstack/realtime/dispatcher.rb +54 -0
- data/lib/gemstack/realtime/hub.rb +32 -4
- data/lib/gemstack/realtime/middleware.rb +206 -44
- data/lib/gemstack/realtime/presence.rb +186 -0
- data/lib/gemstack/realtime/streamer.rb +12 -9
- data/lib/gemstack/realtime/testing.rb +1 -1
- data/lib/gemstack/realtime/websocket/codec.rb +192 -0
- data/lib/gemstack/realtime/websocket/connection.rb +253 -0
- data/lib/gemstack/realtime.rb +136 -31
- metadata +9 -4
|
@@ -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
|
|
8
|
-
# process: it
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|