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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 59e752348de1175634362fbae61d888cd27dafc64e137b817864a6142ec79b7d
|
|
4
|
+
data.tar.gz: 22ce9e17ea1123b4e84e5081279d6b7bf981ed53185044200a03d145751bdc12
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0ffddee26789b127d330c8e9659cb0a60dde9aa3963d30de442f3edf4a86d78094f707a315274c29078c14ae52d261f28ecc2e42e9eb47910b66d07179525d1a
|
|
7
|
+
data.tar.gz: cd9b65c59b693fbcc887678e027e1f3d0f8b20e130fc5dc55bc6f2af8e7f712cbb260bd0fed019c917ad4d13bc39f3a807bb4f932a43ee9ceab8e6eed741c61d
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# gemstack-realtime
|
|
2
2
|
|
|
3
|
-
GemStack realtime
|
|
3
|
+
GemStack realtime (WebSockets or Server-Sent Events): broadcasts, channels, presence, browser messages.
|
|
4
4
|
|
|
5
|
-
Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby
|
|
6
|
-
|
|
5
|
+
Part of [GemStack](https://github.com/gemstack-rb/gemstack), a modular Ruby web application framework with
|
|
6
|
+
a Next.js frontend, by [Adware Technologies](https://www.adwaretech.com). All GemStack gems are developed together in that repository and released with the same
|
|
7
7
|
version.
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
@@ -11,7 +11,7 @@ version.
|
|
|
11
11
|
Optional module — `gemstack add realtime`:
|
|
12
12
|
|
|
13
13
|
```ruby
|
|
14
|
-
gem "gemstack-realtime", "~> 0.
|
|
14
|
+
gem "gemstack-realtime", "~> 0.4.0"
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
## Documentation
|
|
@@ -2,16 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
module GemStack
|
|
4
4
|
module Realtime
|
|
5
|
-
# One browser
|
|
5
|
+
# One browser connection on a hijacked socket — the Server-Sent Events
|
|
6
|
+
# stream; WebSocket::Connection builds on it. #push never blocks:
|
|
6
7
|
# bytes the socket can't take yet are buffered and flushed by the
|
|
7
8
|
# Streamer when it becomes writable. A client more than max_buffer bytes
|
|
8
9
|
# behind is disconnected (it reconnects and replays or refetches).
|
|
9
10
|
class Connection
|
|
10
|
-
attr_reader :io, :channels
|
|
11
|
+
attr_reader :io, :channels, :identity
|
|
11
12
|
|
|
12
|
-
def initialize(io, channels, streamer:, max_buffer: Realtime.config.max_buffer)
|
|
13
|
+
def initialize(io, channels, streamer:, identity: nil, max_buffer: Realtime.config.max_buffer)
|
|
13
14
|
@io = io
|
|
14
|
-
@
|
|
15
|
+
@identity = identity
|
|
16
|
+
@presence = {} # channel => identity key
|
|
17
|
+
@channels = Set.new(channels)
|
|
15
18
|
@streamer = streamer
|
|
16
19
|
@max_buffer = max_buffer
|
|
17
20
|
@buffer = String.new(encoding: Encoding::BINARY)
|
|
@@ -22,6 +25,30 @@ module GemStack
|
|
|
22
25
|
def closed? = @closed
|
|
23
26
|
def pending? = @mutex.synchronize { !@buffer.empty? }
|
|
24
27
|
|
|
28
|
+
# Transport hooks: how a broadcast and a keep-alive are written. The
|
|
29
|
+
# keep-alive is a data event (comments are invisible to EventSource), so
|
|
30
|
+
# the browser can tell a quiet connection from a dead one.
|
|
31
|
+
def deliver(message) = push(message.sse)
|
|
32
|
+
def heartbeat = push(PING)
|
|
33
|
+
PING = "data: #{JSON.generate(id: nil, channel: nil, event: "gemstack.ping", data: nil)}\n\n".freeze
|
|
34
|
+
|
|
35
|
+
# SSE presence: the stream's presence channels, tracked while it's open.
|
|
36
|
+
def track_presence(channel)
|
|
37
|
+
key, meta = Realtime.identity_key(identity)
|
|
38
|
+
return [] unless key
|
|
39
|
+
|
|
40
|
+
@presence[channel] = key
|
|
41
|
+
Realtime.presence.track(channel, key, meta)
|
|
42
|
+
list = Realtime.presence.list(channel)
|
|
43
|
+
list.any? { |entry| entry["id"] == key } ? list : list + [{ "id" => key, "meta" => meta }]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Called by the Streamer once the socket is gone.
|
|
47
|
+
def disconnected
|
|
48
|
+
@presence.each { |channel, key| Realtime.presence.untrack_later(channel, key) }
|
|
49
|
+
@presence.clear
|
|
50
|
+
end
|
|
51
|
+
|
|
25
52
|
def push(bytes)
|
|
26
53
|
wants_write = @mutex.synchronize do
|
|
27
54
|
return false if @closed
|
|
@@ -38,8 +65,26 @@ module GemStack
|
|
|
38
65
|
true
|
|
39
66
|
end
|
|
40
67
|
|
|
41
|
-
# Writes as much as the socket accepts. Returns true when fully flushed
|
|
42
|
-
|
|
68
|
+
# Writes as much as the socket accepts. Returns true when fully flushed
|
|
69
|
+
# (and closes the socket if #close_after_flush asked for it).
|
|
70
|
+
def flush
|
|
71
|
+
@mutex.synchronize do
|
|
72
|
+
return true if @closed
|
|
73
|
+
|
|
74
|
+
flushed = flush_locked
|
|
75
|
+
close_locked if flushed && @close_after_flush
|
|
76
|
+
flushed
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Sends what's buffered, then closes (e.g. after a WebSocket close frame).
|
|
81
|
+
def close_after_flush
|
|
82
|
+
done = @mutex.synchronize do
|
|
83
|
+
@close_after_flush = true
|
|
84
|
+
flush_locked.tap { |flushed| close_locked if flushed }
|
|
85
|
+
end
|
|
86
|
+
@streamer.want_write(self) unless done
|
|
87
|
+
end
|
|
43
88
|
|
|
44
89
|
def close
|
|
45
90
|
@mutex.synchronize { close_locked }
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Realtime
|
|
5
|
+
# Runs application code for WebSocket connections — channel authorization
|
|
6
|
+
# and `receive` handlers — on a small thread pool, never on the event loop,
|
|
7
|
+
# so a slow query can't stall other connections. A connection's messages
|
|
8
|
+
# are handled one at a time, in the order they arrived (Connection keeps
|
|
9
|
+
# the inbox; the dispatcher only lends it a thread). Code runs under the
|
|
10
|
+
# application's interlock, like a request, so it never overlaps a reload.
|
|
11
|
+
class Dispatcher
|
|
12
|
+
def self.instance
|
|
13
|
+
@mutex ||= Mutex.new
|
|
14
|
+
@instance || @mutex.synchronize { @instance ||= new }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.reset!
|
|
18
|
+
@instance&.stop
|
|
19
|
+
@instance = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(size: Realtime.config.workers)
|
|
23
|
+
@queue = Queue.new
|
|
24
|
+
@threads = Array.new(size) { Thread.new { work } }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Asks a worker to run connection.process_inbox (or a callable).
|
|
28
|
+
def schedule(connection) = @queue << connection
|
|
29
|
+
|
|
30
|
+
def stop
|
|
31
|
+
@queue.close
|
|
32
|
+
@threads.each { |thread| thread.join(2) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# In an app (require "gemstack"), under its interlock so code reloading
|
|
38
|
+
# waits; the realtime gem used on its own has no application.
|
|
39
|
+
def with_application(&)
|
|
40
|
+
GemStack.respond_to?(:application) ? GemStack.application.interlock.shared(&) : yield
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def work
|
|
44
|
+
while (connection = @queue.pop)
|
|
45
|
+
begin
|
|
46
|
+
with_application { connection.respond_to?(:process_inbox) ? connection.process_inbox : connection.call }
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
GemStack.logger.error("realtime: handler crashed", error: e, backtrace: Array(e.backtrace).first(10))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module GemStack
|
|
4
4
|
module Realtime
|
|
5
|
-
# This process's subscriptions and replay history
|
|
6
|
-
# calls #deliver;
|
|
5
|
+
# This process's subscriptions and replay history, whatever the transport.
|
|
6
|
+
# The broker's listener calls #deliver; each connection encodes the message
|
|
7
|
+
# for its transport (a WebSocket frame, or an SSE event) and is only ever
|
|
8
|
+
# written to without blocking.
|
|
7
9
|
class Hub
|
|
8
10
|
Entry = Struct.new(:message, :at)
|
|
9
11
|
|
|
@@ -21,6 +23,23 @@ module GemStack
|
|
|
21
23
|
connection
|
|
22
24
|
end
|
|
23
25
|
|
|
26
|
+
# WebSocket connections subscribe and unsubscribe while open.
|
|
27
|
+
def subscribe(connection, channel)
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
connection.channels << channel
|
|
30
|
+
@subscriptions[channel] << connection
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def unsubscribe(connection, channel)
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
connection.channels.delete(channel)
|
|
37
|
+
subscribers = @subscriptions[channel]
|
|
38
|
+
subscribers.delete(connection)
|
|
39
|
+
@subscriptions.delete(channel) if subscribers.empty?
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
24
43
|
def remove(connection)
|
|
25
44
|
@mutex.synchronize do
|
|
26
45
|
connection.channels.each do |channel|
|
|
@@ -38,16 +57,25 @@ module GemStack
|
|
|
38
57
|
end
|
|
39
58
|
|
|
40
59
|
def deliver(message)
|
|
60
|
+
return Realtime.presence.receive(message) if message.channel == Presence::CHANNEL
|
|
61
|
+
|
|
41
62
|
subscribers = @mutex.synchronize do
|
|
42
63
|
@history << Entry.new(message, monotonic)
|
|
43
64
|
@history.shift while @history.size > @replay_size
|
|
44
65
|
@subscriptions.key?(message.channel) ? @subscriptions[message.channel].to_a : []
|
|
45
66
|
end
|
|
46
|
-
|
|
47
|
-
subscribers.each { |connection| connection.push(payload) }
|
|
67
|
+
subscribers.each { |connection| connection.deliver(message) }
|
|
48
68
|
subscribers.size
|
|
49
69
|
end
|
|
50
70
|
|
|
71
|
+
# To this process's subscribers only, without replay history (presence changes).
|
|
72
|
+
def deliver_local(message)
|
|
73
|
+
subscribers = @mutex.synchronize do
|
|
74
|
+
@subscriptions.key?(message.channel) ? @subscriptions[message.channel].to_a : []
|
|
75
|
+
end
|
|
76
|
+
subscribers.each { |connection| connection.deliver(message) }
|
|
77
|
+
end
|
|
78
|
+
|
|
51
79
|
# Events on `channels` published after the event with id `last_id`.
|
|
52
80
|
# Returns [messages, gap]; gap is true when last_id is no longer in the
|
|
53
81
|
# history, so the client may have missed events and should refetch.
|
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
3
5
|
module GemStack
|
|
4
6
|
module Realtime
|
|
5
|
-
#
|
|
7
|
+
# The realtime endpoint, <api_path>/realtime, with two transports
|
|
8
|
+
# (config.realtime.transports) behind the same channels, presence and
|
|
9
|
+
# handlers:
|
|
10
|
+
#
|
|
11
|
+
# GET + Upgrade: websocket WebSocket: everything over one connection
|
|
12
|
+
# (WebSocket::Connection has the protocol)
|
|
13
|
+
# GET ?channels=a,b Server-Sent Events: the stream of events
|
|
14
|
+
# [&last_event_id=…] for those channels (reopened to change them)
|
|
15
|
+
# POST {channel, event, data} a browser message for a `receive` handler,
|
|
16
|
+
# when it isn't on a WebSocket
|
|
6
17
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# Puma) and
|
|
11
|
-
# response is a standard text/event-stream: events missed since
|
|
12
|
-
# Last-Event-ID are replayed first, or a `gemstack.gap` event tells the
|
|
13
|
-
# client to refetch because they're no longer available.
|
|
18
|
+
# Every request checks the Origin (the API's own, config.http.cors.origins
|
|
19
|
+
# or realtime.allowed_origins) and runs config/channels.rb's `identify`.
|
|
20
|
+
# Long-lived connections take the socket over from the server (Rack full
|
|
21
|
+
# hijack, as Puma supports) and live on the Streamer's event loop.
|
|
14
22
|
class Middleware
|
|
15
|
-
|
|
23
|
+
SSE_HEADERS = [
|
|
16
24
|
"HTTP/1.1 200 OK", "content-type: text/event-stream; charset=utf-8", "cache-control: no-cache, no-transform",
|
|
17
25
|
"x-accel-buffering: no", "x-content-type-options: nosniff", "connection: close"
|
|
18
26
|
].freeze
|
|
@@ -23,25 +31,98 @@ module GemStack
|
|
|
23
31
|
end
|
|
24
32
|
|
|
25
33
|
def call(env)
|
|
26
|
-
return @app.call(env) unless env[Rack::PATH_INFO] == path
|
|
34
|
+
return @app.call(env) unless env[Rack::PATH_INFO] == path
|
|
27
35
|
|
|
28
36
|
request = Rack::Request.new(env)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
raise Forbidden.new("Not allowed to subscribe to #{denied.join(", ")}",
|
|
33
|
-
code: "channel_forbidden")
|
|
37
|
+
unless allowed_origin?(request)
|
|
38
|
+
raise Forbidden.new("Cross-origin realtime request refused",
|
|
39
|
+
code: "origin_forbidden")
|
|
34
40
|
end
|
|
35
41
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
case env[Rack::REQUEST_METHOD]
|
|
43
|
+
when "GET"
|
|
44
|
+
websocket = env["HTTP_UPGRADE"].to_s.casecmp?("websocket")
|
|
45
|
+
return transport_disabled(websocket ? :websocket : :sse) unless enabled?(websocket ? :websocket : :sse)
|
|
46
|
+
|
|
47
|
+
websocket ? WebSocketHandshake.call(env, request) : stream(env, request)
|
|
48
|
+
when "POST"
|
|
49
|
+
enabled?(:sse) ? Messages.call(request) : transport_disabled(:sse)
|
|
50
|
+
else
|
|
51
|
+
raise MethodNotAllowed.new("Use GET or POST", headers: { "allow" => "GET, POST" })
|
|
52
|
+
end
|
|
40
53
|
end
|
|
41
54
|
|
|
42
55
|
private
|
|
43
56
|
|
|
44
57
|
def path = @path ||= Realtime.config.path
|
|
58
|
+
def enabled?(transport) = Realtime.config.transports.map(&:to_sym).include?(transport)
|
|
59
|
+
|
|
60
|
+
def transport_disabled(transport)
|
|
61
|
+
if transport == :websocket
|
|
62
|
+
raise NotFound.new("The WebSocket transport is disabled (config.realtime.transports)",
|
|
63
|
+
code: "transport_disabled")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
body = JSON.generate(error: { code: "transport_disabled",
|
|
67
|
+
message: "Server-Sent Events are disabled; use WebSocket" })
|
|
68
|
+
[426, { "upgrade" => "websocket", "content-type" => "application/json" }, [body]]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Browsers always send Origin with a WebSocket or a POST; same-origin
|
|
72
|
+
# GETs and other clients (servers, mobile apps) may not, and aren't
|
|
73
|
+
# subject to cross-site hijacking.
|
|
74
|
+
def allowed_origin?(request)
|
|
75
|
+
origin = request.get_header("HTTP_ORIGIN")
|
|
76
|
+
return true if origin.nil? || origin.empty?
|
|
77
|
+
|
|
78
|
+
allowed = Realtime.config.allowed_origins
|
|
79
|
+
return allowed.include?(origin) if allowed
|
|
80
|
+
return true if GemStack.config.http.cors.origins.include?(origin)
|
|
81
|
+
|
|
82
|
+
same_origin?(request, origin)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def same_origin?(request, origin)
|
|
86
|
+
uri = URI.parse(origin)
|
|
87
|
+
uri.host.to_s.casecmp?(request.host) && uri.port == request.port
|
|
88
|
+
rescue URI::InvalidURIError
|
|
89
|
+
false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The stream's response bypasses the CORS middleware: an allowed other
|
|
93
|
+
# origin (EventSource withCredentials) needs these itself.
|
|
94
|
+
def cors_headers(request)
|
|
95
|
+
origin = request.get_header("HTTP_ORIGIN")
|
|
96
|
+
return [] if origin.nil? || origin.empty? || same_origin?(request, origin)
|
|
97
|
+
|
|
98
|
+
["access-control-allow-origin: #{origin}", "access-control-allow-credentials: true", "vary: Origin"]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# ── Server-Sent Events ───────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
# Authorizes each channel (a refused one gets `gemstack.denied` and the
|
|
104
|
+
# others keep working), tracks presence, replays what was missed since
|
|
105
|
+
# Last-Event-ID — or sends `gemstack.gap` — then streams.
|
|
106
|
+
def stream(env, request)
|
|
107
|
+
requested = requested_channels(request)
|
|
108
|
+
identity = Realtime.channels.identify_request(request)
|
|
109
|
+
rules = requested.to_h { |channel| [channel, Realtime.channels.authorize(channel, request)] }
|
|
110
|
+
channels = rules.select { |_, rule| rule }.keys
|
|
111
|
+
Realtime.listen!
|
|
112
|
+
last_id = env["HTTP_LAST_EVENT_ID"] || request.GET["last_event_id"]
|
|
113
|
+
return stream_on_request_thread(channels, identity, last_id, rules) unless env["rack.hijack?"]
|
|
114
|
+
|
|
115
|
+
io = env["rack.hijack"].call
|
|
116
|
+
io.write("#{(SSE_HEADERS + cors_headers(request)).join("\r\n")}\r\n\r\n")
|
|
117
|
+
streamer = Streamer.instance
|
|
118
|
+
connection = Connection.new(io, channels, streamer: streamer, identity: identity)
|
|
119
|
+
# Presence first, before the hub delivers to it: its own join isn't news to it.
|
|
120
|
+
text = preamble(connection, rules)
|
|
121
|
+
Realtime.hub.add(connection)
|
|
122
|
+
connection.push(text << replay(connection, last_id))
|
|
123
|
+
streamer.add(connection)
|
|
124
|
+
[200, {}, []] # ignored by the server after a full hijack
|
|
125
|
+
end
|
|
45
126
|
|
|
46
127
|
def requested_channels(request)
|
|
47
128
|
channels = request.GET["channels"].to_s.split(",").map(&:strip).reject(&:empty?).uniq
|
|
@@ -50,7 +131,7 @@ module GemStack
|
|
|
50
131
|
max = Realtime.config.max_channels
|
|
51
132
|
raise BadRequest.new("At most #{max} channels per connection", code: "too_many_channels") if channels.size > max
|
|
52
133
|
|
|
53
|
-
invalid = channels.grep_v(CHANNEL_NAME)
|
|
134
|
+
invalid = channels.grep_v(CHANNEL_NAME) + (channels & [Presence::CHANNEL])
|
|
54
135
|
if invalid.any?
|
|
55
136
|
raise InvalidChannel.new("Invalid channel name(s): #{invalid.join(", ")}",
|
|
56
137
|
code: "invalid_channel")
|
|
@@ -59,54 +140,59 @@ module GemStack
|
|
|
59
140
|
channels
|
|
60
141
|
end
|
|
61
142
|
|
|
62
|
-
|
|
63
|
-
# doesn't fail the others: its handlers get a `gemstack.denied` event.
|
|
64
|
-
def preamble(channels, last_id, denied)
|
|
65
|
-
messages, gap = Realtime.hub.replay(channels, last_id)
|
|
143
|
+
def preamble(connection, rules)
|
|
66
144
|
text = "retry: #{Realtime.config.retry_ms}\n\n"
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
145
|
+
text << system_event("gemstack.welcome", nil, { "connection_id" => SecureRandom.hex(8), "transport" => "sse",
|
|
146
|
+
"heartbeat" => Realtime.config.heartbeat })
|
|
147
|
+
rules.each do |channel, rule|
|
|
148
|
+
if rule.nil?
|
|
149
|
+
text << system_event("gemstack.denied", channel, "forbidden")
|
|
150
|
+
elsif rule.presence
|
|
151
|
+
text << system_event("gemstack.presence", channel, connection.track_presence(channel))
|
|
152
|
+
end
|
|
153
|
+
end
|
|
70
154
|
text
|
|
71
155
|
end
|
|
72
156
|
|
|
73
|
-
def
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
io.write("#{HEADERS.join("\r\n")}\r\n\r\n")
|
|
78
|
-
streamer = Streamer.instance
|
|
79
|
-
connection = Connection.new(io, channels, streamer: streamer)
|
|
80
|
-
Realtime.hub.add(connection)
|
|
81
|
-
connection.push(preamble)
|
|
82
|
-
streamer.add(connection)
|
|
83
|
-
[200, {}, []] # ignored by the server after a full hijack
|
|
157
|
+
def replay(connection, last_id)
|
|
158
|
+
messages, gap = Realtime.hub.replay(connection.channels.to_a, last_id.to_s)
|
|
159
|
+
text = messages.map(&:sse).join
|
|
160
|
+
gap ? text + system_event("gemstack.gap", nil) : text
|
|
84
161
|
end
|
|
85
162
|
|
|
163
|
+
def system_event(name, channel, data = nil) = "data: #{Message.new(nil, channel, name, data).json}\n\n"
|
|
164
|
+
|
|
86
165
|
# Servers without full hijack: stream from the request thread (holds it
|
|
87
|
-
# for the life of the connection).
|
|
88
|
-
def
|
|
166
|
+
# for the life of the connection; no presence).
|
|
167
|
+
def stream_on_request_thread(channels, identity, last_id, rules)
|
|
89
168
|
unless @warned
|
|
90
169
|
GemStack.logger.warn("realtime: server doesn't support rack.hijack; streaming on a request thread")
|
|
91
170
|
end
|
|
92
171
|
@warned = true
|
|
93
|
-
body = QueueBody.new(channels,
|
|
172
|
+
body = QueueBody.new(channels, identity)
|
|
173
|
+
body.push(rules.filter_map do |channel, rule|
|
|
174
|
+
system_event("gemstack.denied", channel, "forbidden") unless rule
|
|
175
|
+
end.join)
|
|
176
|
+
messages, gap = Realtime.hub.replay(channels, last_id.to_s)
|
|
177
|
+
messages.each { |message| body.push(message.sse) }
|
|
178
|
+
body.push(system_event("gemstack.gap", nil)) if gap
|
|
94
179
|
[200, { "content-type" => "text/event-stream; charset=utf-8", "cache-control" => "no-cache, no-transform" },
|
|
95
180
|
body]
|
|
96
181
|
end
|
|
97
182
|
|
|
98
183
|
# A Rack body fed by the hub (fallback path). Closing it unsubscribes.
|
|
99
184
|
class QueueBody
|
|
100
|
-
attr_reader :channels
|
|
185
|
+
attr_reader :channels, :identity
|
|
101
186
|
|
|
102
|
-
def initialize(channels,
|
|
187
|
+
def initialize(channels, identity)
|
|
103
188
|
@channels = channels.freeze
|
|
189
|
+
@identity = identity
|
|
104
190
|
@queue = Queue.new
|
|
105
|
-
@queue << preamble
|
|
106
191
|
Realtime.hub.add(self)
|
|
107
192
|
end
|
|
108
193
|
|
|
109
194
|
def push(bytes) = @queue << bytes
|
|
195
|
+
def deliver(message) = push(message.sse)
|
|
110
196
|
def closed? = @queue.closed?
|
|
111
197
|
|
|
112
198
|
def each
|
|
@@ -121,5 +207,81 @@ module GemStack
|
|
|
121
207
|
end
|
|
122
208
|
end
|
|
123
209
|
end
|
|
210
|
+
|
|
211
|
+
# The WebSocket opening handshake: validates it, runs `identify` while the
|
|
212
|
+
# request's cookies are at hand, then hands the socket to the Streamer.
|
|
213
|
+
module WebSocketHandshake
|
|
214
|
+
module_function
|
|
215
|
+
|
|
216
|
+
def call(env, request)
|
|
217
|
+
key = env["HTTP_SEC_WEBSOCKET_KEY"]
|
|
218
|
+
unless env["HTTP_CONNECTION"].to_s.downcase.include?("upgrade") && WebSocket::Codec.valid_key?(key)
|
|
219
|
+
raise BadRequest.new("Invalid WebSocket handshake", code: "invalid_handshake")
|
|
220
|
+
end
|
|
221
|
+
unless env["HTTP_SEC_WEBSOCKET_VERSION"] == "13"
|
|
222
|
+
return [426, { "sec-websocket-version" => "13", "content-type" => "text/plain" }, ["WebSocket version 13"]]
|
|
223
|
+
end
|
|
224
|
+
unless env["rack.hijack?"]
|
|
225
|
+
raise ServiceUnavailable.new("WebSockets need a server with Rack hijacking, such as Puma",
|
|
226
|
+
code: "websocket_unsupported")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
identity = Realtime.channels.identify_request(request)
|
|
230
|
+
Realtime.listen!
|
|
231
|
+
io = env["rack.hijack"].call
|
|
232
|
+
io.write(WebSocket::Codec.handshake_response(key))
|
|
233
|
+
streamer = Streamer.instance
|
|
234
|
+
connection = WebSocket::Connection.new(io, request: request, identity: identity, streamer: streamer)
|
|
235
|
+
connection.welcome
|
|
236
|
+
streamer.add(connection)
|
|
237
|
+
[200, {}, []] # ignored by the server after a full hijack
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# POST <api_path>/realtime {"channel", "event", "data"} — a browser message
|
|
242
|
+
# for a `receive` handler over plain HTTP (the SSE transport's way up).
|
|
243
|
+
# Allowed when the sender may subscribe to the channel. The handler's
|
|
244
|
+
# return value is the response's "data"; its errors are the usual JSON
|
|
245
|
+
# error envelope.
|
|
246
|
+
module Messages
|
|
247
|
+
module_function
|
|
248
|
+
|
|
249
|
+
def call(request)
|
|
250
|
+
unless request.media_type == "application/json"
|
|
251
|
+
raise UnsupportedMediaType.new("Send JSON", code: "unsupported_media_type")
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
body = request.body.read(Realtime.config.max_message_size + 1).to_s
|
|
255
|
+
if body.bytesize > Realtime.config.max_message_size
|
|
256
|
+
raise GemStack::PayloadTooLarge.new("Message too large",
|
|
257
|
+
code: "message_too_big")
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
message = JSON.parse(body)
|
|
261
|
+
raise BadRequest.new("Expected a JSON object", code: "invalid_message") unless message.is_a?(Hash)
|
|
262
|
+
|
|
263
|
+
handle(request, message["channel"].to_s, message["event"].to_s, message["data"])
|
|
264
|
+
rescue JSON::ParserError
|
|
265
|
+
raise BadRequest.new("Invalid JSON", code: "invalid_json")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def handle(request, channel, event, data)
|
|
269
|
+
raise InvalidChannel.new("Invalid channel #{channel.inspect}", code: "invalid_channel") unless
|
|
270
|
+
CHANNEL_NAME.match?(channel) && channel != Presence::CHANNEL
|
|
271
|
+
|
|
272
|
+
Realtime.channels.identify_request(request)
|
|
273
|
+
Realtime.channels.authorize(channel, request) or
|
|
274
|
+
raise Forbidden.new("Not allowed on #{channel}", code: "forbidden")
|
|
275
|
+
handler, params = Realtime.channels.receiver(channel)
|
|
276
|
+
raise NotFound.new("#{channel} doesn't accept messages", code: "no_handler") unless handler
|
|
277
|
+
|
|
278
|
+
sender = Sender.new(request, request.env[Channels::IDENTITY])
|
|
279
|
+
result = handler.call(WebSocket::Incoming.new(channel, event, data, params, sender))
|
|
280
|
+
[200, { "content-type" => "application/json" },
|
|
281
|
+
[HTTP::JSONCodec.default.dump({ data: Serializer.render(result) })]]
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
Sender = Struct.new(:request, :identity)
|
|
285
|
+
end
|
|
124
286
|
end
|
|
125
287
|
end
|