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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 99221dd4e22c0d2d66849a4d1038f8c579c1560bd48a26a65a9c4a7459ac6d16
4
- data.tar.gz: 9e2084b4bbe0926eb0920136017d54f627307f76b0bff561db8d46f694677138
3
+ metadata.gz: 59e752348de1175634362fbae61d888cd27dafc64e137b817864a6142ec79b7d
4
+ data.tar.gz: 22ce9e17ea1123b4e84e5081279d6b7bf981ed53185044200a03d145751bdc12
5
5
  SHA512:
6
- metadata.gz: e7343b3dba100bde89e39182e9dde6315ac3ac302abc36ce7ef4e8da8ae6603b7b2ca44870b78aae194707e671d1ab471117d48cf9bf653dc9b646b68f08a81f
7
- data.tar.gz: 557727578ba1bc834a3639c5dc0053954a477808a74576562a008c1b5d83315594db500e81d07f2ce248489dee5d69742fdb1d2bb812ce51b378363055dd9866
6
+ metadata.gz: 0ffddee26789b127d330c8e9659cb0a60dde9aa3963d30de442f3edf4a86d78094f707a315274c29078c14ae52d261f28ecc2e42e9eb47910b66d07179525d1a
7
+ data.tar.gz: cd9b65c59b693fbcc887678e027e1f3d0f8b20e130fc5dc55bc6f2af8e7f712cbb260bd0fed019c917ad4d13bc39f3a807bb4f932a43ee9ceab8e6eed741c61d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
6
+
3
7
  ## 0.3.6
4
8
 
5
9
  See the [GemStack changelog](https://github.com/gemstack-rb/gemstack/blob/main/CHANGELOG.md).
data/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # gemstack-realtime
2
2
 
3
- GemStack realtime: GemStack.broadcast to browsers over Server-Sent Events.
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 API framework for Next.js
6
- applications by [Adware Technologies](https://www.adwaretech.com). All GemStack gems are developed together in that repository and released with the same
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.3.6"
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's event stream on a hijacked socket. #push never blocks:
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
- @channels = channels.freeze
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
- def flush = @mutex.synchronize { @closed || flush_locked }
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. The broker's listener
6
- # calls #deliver; connections are only ever written to without blocking.
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
- payload = message.sse
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
- # GET <api_path>/realtime?channels=a,b[&last_event_id=...]
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
- # Validates and authorizes the channels (400 as a JSON error; 403 only when
8
- # none is allowed — refused channels otherwise get a gemstack.denied event), then
9
- # takes the socket over from the server (Rack full hijack, supported by
10
- # Puma) and hands it to the Streamer, freeing the request thread. The
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
- HEADERS = [
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 && env[Rack::REQUEST_METHOD] == "GET"
34
+ return @app.call(env) unless env[Rack::PATH_INFO] == path
27
35
 
28
36
  request = Rack::Request.new(env)
29
- requested = requested_channels(request)
30
- channels, denied = requested.partition { |channel| Realtime.channels.authorized?(channel, request) }
31
- if channels.empty?
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
- Realtime.listen!
37
- last_id = env["HTTP_LAST_EVENT_ID"] || request.GET["last_event_id"]
38
- preamble = preamble(channels, last_id, denied)
39
- env["rack.hijack?"] ? hijack(env, channels, preamble) : stream(channels, preamble)
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
- # One multiplexed stream serves many subscriptions, so a refused channel
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
- denied.each { |channel| text << system_event("gemstack.denied", channel) }
68
- messages.each { |message| text << message.sse }
69
- text << system_event("gemstack.gap", nil) if gap
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 system_event(name, channel) = "data: #{Message.new(nil, channel, name, nil).json}\n\n"
74
-
75
- def hijack(env, channels, preamble)
76
- io = env["rack.hijack"].call
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 stream(channels, preamble)
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, preamble)
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, preamble)
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