monkrb 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +133 -0
  3. data/README.md +6 -5
  4. data/exe/monk +45 -7
  5. data/lib/monk/jobs/adapters/pg.rb +272 -0
  6. data/lib/monk/jobs/args.rb +43 -0
  7. data/lib/monk/jobs/claim.rb +7 -0
  8. data/lib/monk/jobs/errors.rb +29 -0
  9. data/lib/monk/jobs/job.rb +117 -0
  10. data/lib/monk/jobs/runtime.rb +239 -0
  11. data/lib/monk/jobs/worker.rb +94 -0
  12. data/lib/monk/jobs.rb +229 -0
  13. data/lib/monk/live/client/monk_live.js +2 -2
  14. data/lib/monk/live/client/protocol.js +5 -0
  15. data/lib/monk/live.rb +10 -0
  16. data/lib/monk/mail/errors.rb +7 -0
  17. data/lib/monk/mail/later.rb +53 -0
  18. data/lib/monk/persistence/pg.rb +21 -1
  19. data/lib/monk/scaffold.rb +369 -70
  20. data/lib/monk/templates/auth/app/mailers/app_mailer.rb +22 -0
  21. data/lib/monk/templates/auth/config/auth.rb +7 -20
  22. data/lib/monk/templates/base/app/app.rb +9 -0
  23. data/lib/monk/templates/base/app/broadcasts/.keep +0 -0
  24. data/lib/monk/templates/base/app/helpers/.keep +0 -0
  25. data/lib/monk/templates/base/app/jobs/.keep +0 -0
  26. data/lib/monk/templates/base/app/mailers/.keep +0 -0
  27. data/lib/monk/templates/base/app/models/.keep +0 -0
  28. data/lib/monk/templates/base/app/presenters/.keep +0 -0
  29. data/lib/monk/templates/base/bin/websocket_server +6 -0
  30. data/lib/monk/templates/base/config/load.rb +18 -0
  31. data/lib/monk/templates/base/config.ru +3 -12
  32. data/lib/monk/templates/base/public/js/app.js +1 -1
  33. data/lib/monk/templates/jobs/app/jobs/hello_job.rb +9 -0
  34. data/lib/monk/templates/jobs/app/jobs/send_login_link.rb +28 -0
  35. data/lib/monk/templates/jobs/bin/jobs +18 -0
  36. data/lib/monk/templates/jobs/config/jobs.rb +15 -0
  37. data/lib/monk/templates/jobs/db/migrate/00000000000002_create_jobs_tables.down.sql +3 -0
  38. data/lib/monk/templates/jobs/db/migrate/00000000000002_create_jobs_tables.up.sql +48 -0
  39. data/lib/monk/templates/live/{config.ru → app/app.rb} +1 -6
  40. data/lib/monk/templates/live/{views → app/views}/index.erb +2 -2
  41. data/lib/monk/templates/live/bin/websocket_server +14 -2
  42. data/lib/monk/templates/live/config/live.rb +1 -1
  43. data/lib/monk/templates/live/config/live_pg.rb +1 -1
  44. data/lib/monk/templates/mail/config/mail.rb +2 -2
  45. data/lib/monk/templates/postgres/Dockerfile +3 -1
  46. data/lib/monk/templates/postgres/bin/console +4 -2
  47. data/lib/monk/version.rb +1 -1
  48. data/lib/monk/websocket/errors.rb +11 -0
  49. data/lib/monk/websocket/listeners.rb +23 -0
  50. data/lib/monk/websocket/pg_fanout.rb +55 -20
  51. data/lib/monk/websocket/redis_fanout.rb +57 -14
  52. data/lib/monk/websocket/registry.rb +6 -0
  53. data/lib/monk/websocket/server.rb +77 -17
  54. metadata +32 -7
  55. /data/lib/monk/templates/auth/{views → app/views}/mail/magic_link.erb +0 -0
  56. /data/lib/monk/templates/base/{views → app/views}/index.erb +0 -0
  57. /data/lib/monk/templates/base/{views → app/views}/layouts/app.erb +0 -0
  58. /data/lib/monk/templates/live/{views → app/views}/live/_hits.erb +0 -0
@@ -1,5 +1,7 @@
1
1
  require "redis"
2
2
  require "securerandom"
3
+ require_relative "errors"
4
+ require_relative "listeners"
3
5
 
4
6
  module Monk
5
7
  module WebSocket
@@ -9,7 +11,7 @@ module Monk
9
11
  # LISTEN/NOTIFY per docs/design/websocket.md Open Question 3).
10
12
  #
11
13
  # Wraps a Registry with the same public interface (#register,
12
- # #unregister, #count, #broadcast), so an app swaps which object it
14
+ # #unregister, #count, #broadcast, #listen!), so an app swaps which object it
13
15
  # holds and nothing else changes. #broadcast still delivers to this
14
16
  # process's local Registry directly -- same latency and reliability as
15
17
  # a plain Registry if Redis is briefly unavailable -- and additionally
@@ -41,13 +43,43 @@ module Monk
41
43
  @redis_url = redis_url.dup.freeze
42
44
  @origin = SecureRandom.uuid.freeze
43
45
 
46
+ # No live connection lives on this object -- #listen! runs its
47
+ # subscriber in a Ractor of its own, and #publisher below opens one
48
+ # client per Ractor, lazily -- so there's nothing left unshareable
49
+ # and this instance can freeze itself the same way Registry does.
50
+ # That matters for real use: an app holds this behind a module
51
+ # constant (e.g. ChatServer::REGISTRY, mirroring Registry's own
52
+ # existing usage) read from every connection's own Ractor, which
53
+ # raises Ractor::IsolationError unless the constant's value is
54
+ # shareable.
55
+ freeze
56
+ end
57
+
58
+ # Starts relaying other processes' broadcasts into this process's
59
+ # registry: a subscriber Ractor with its own Redis connection. Only
60
+ # the process that holds sockets calls this (bin/websocket_server, at
61
+ # boot, from the main Ractor) -- a publish-only process (bin/server,
62
+ # bin/jobs) never does, so it holds no subscriber connection at all.
63
+ # Returns once the psubscribe is in effect, so nothing broadcast
64
+ # after it can be missed; raises ListenError if Redis can't be
65
+ # reached. Calling it again is a no-op.
66
+ def listen!
67
+ return self if Listeners.listening?(@origin)
68
+
69
+ ready = Ractor::Port.new
44
70
  # Ractor.new's block args must be shareable: registry is (Registry
45
71
  # freezes itself around a Ractor, which is inherently shareable),
46
- # redis_url and @origin are plain frozen Strings. The Redis client
47
- # itself is built inside the block, never passed in -- mirrors
48
- # PG::Connection's per-Ractor pattern (persistence/pg.rb).
49
- @subscriber = Ractor.new(registry, @redis_url, @origin) do |registry, url, origin|
72
+ # the URL and origin are frozen Strings, a Port always is. The
73
+ # Redis client itself is built inside the block, never passed in --
74
+ # mirrors PG::Connection's per-Ractor pattern (persistence/pg.rb).
75
+ Ractor.new(@registry, @redis_url, @origin, ready) do |registry, url, origin, ready|
76
+ subscribed = false
50
77
  Redis.new(url: url).psubscribe("#{Monk::WebSocket::RedisFanout::CHANNEL_PREFIX}*") do |on|
78
+ on.psubscribe do
79
+ subscribed = true
80
+ ready.send(:ok)
81
+ end
82
+
51
83
  on.pmessage do |_pattern, channel, envelope|
52
84
  sender, payload = envelope.split("\0", 2)
53
85
  next if sender == origin
@@ -66,19 +98,30 @@ module Monk
66
98
  registry.broadcast(key, payload)
67
99
  end
68
100
  end
101
+ rescue StandardError => e
102
+ raise if subscribed
103
+
104
+ ready.send("#{e.class}: #{e.message}")
69
105
  end
70
106
 
71
- # No live connection lives on this object -- #publisher below opens
72
- # one per Ractor, lazily -- so there's nothing left unshareable and
73
- # this instance can freeze itself the same way Registry does. That
74
- # matters for real use: an app holds this behind a module constant
75
- # (e.g. ChatServer::REGISTRY, mirroring Registry's own existing
76
- # usage) read from every connection's own Ractor, which raises
77
- # Ractor::IsolationError unless the constant's value is shareable.
78
- freeze
107
+ result = ready.receive
108
+ raise ListenError, "Monk::WebSocket::RedisFanout#listen! couldn't subscribe: #{result}" unless result == :ok
109
+
110
+ Listeners.add(@origin)
111
+ self
112
+ end
113
+
114
+ def register(key, port)
115
+ unless Listeners.listening?(@origin)
116
+ raise NotListeningError,
117
+ "Monk::WebSocket::RedisFanout#register before #listen!: this socket would never get " \
118
+ "other processes' broadcasts -- call listen! once at boot in the process that holds " \
119
+ "sockets (bin/websocket_server: Monk::Live.listen!)"
120
+ end
121
+
122
+ @registry.register(key, port)
79
123
  end
80
124
 
81
- def register(key, port) = @registry.register(key, port)
82
125
  def unregister(key, port) = @registry.unregister(key, port)
83
126
  def count(key) = @registry.count(key)
84
127
 
@@ -67,6 +67,12 @@ module Monk
67
67
  ask(:count, key, nil)
68
68
  end
69
69
 
70
+ # Nothing to listen to: every broadcast reaching this registry comes
71
+ # from this process. Here so a WS process calls #listen! the same
72
+ # way whether it holds a Registry or a RedisFanout/PgFanout around
73
+ # one.
74
+ def listen! = self
75
+
70
76
  private
71
77
 
72
78
  def ask(op, key, arg)
@@ -25,9 +25,20 @@ module Monk
25
25
  # docs/history/chat-gap-analysis.md) -- authenticate: true only checks the
26
26
  # credential once, at the handshake, so a session revoked or
27
27
  # expired afterward would otherwise leave the socket live
28
- # indefinitely. Requires authenticate: true (there's no credential
29
- # to reverify without it) and defaults to nil (disabled), same as
30
- # ping_interval:.
28
+ # indefinitely. Requires authenticate: true or :optional (there's no
29
+ # credential to reverify without it) and defaults to nil (disabled),
30
+ # same as ping_interval:. Under :optional it watches only the
31
+ # connections that have a session.
32
+ #
33
+ # authenticate: :optional is the middle mode (docs/design/websocket.md,
34
+ # "Anonymous connections"): a connection with a valid session gets its
35
+ # subject, and one without -- no credential, or a revoked or expired
36
+ # one -- comes in anonymous (subject nil) instead of being refused.
37
+ # What an anonymous connection may then do is the handler's call:
38
+ # Monk::Live's rules deny it everything a rule doesn't open with
39
+ # `anonymous: true`. The Origin check still applies: to a cookie, as
40
+ # under true, and to an anonymous connection that sends an Origin,
41
+ # since only a browser does.
31
42
  #
32
43
  # max_payload_size: (bytes) -- see Connection::DEFAULT_MAX_PAYLOAD_SIZE
33
44
  # for what it guards against (gap 5). Unlike ping_interval:/
@@ -35,6 +46,11 @@ module Monk
35
46
  # hostile client on a public endpoint, not an opt-in behavior
36
47
  # change, so a server that doesn't mention it still gets the
37
48
  # default cap rather than no cap at all.
49
+ AUTHENTICATE_MODES = [false, true, :optional].freeze
50
+
51
+ # The signals #run treats as "stop": Ctrl+C and TERM.
52
+ STOP_SIGNALS = [Signal.list.fetch("INT"), Signal.list.fetch("TERM")].freeze
53
+
38
54
  def initialize(
39
55
  port:, bind: "0.0.0.0", allowed_origins: nil, authenticate: false, ping_interval: nil,
40
56
  reverify_interval: nil, max_payload_size: Connection::DEFAULT_MAX_PAYLOAD_SIZE
@@ -43,11 +59,15 @@ module Monk
43
59
  raise ArgumentError, "ping_interval must be positive, got #{ping_interval.inspect}"
44
60
  end
45
61
 
62
+ unless AUTHENTICATE_MODES.include?(authenticate)
63
+ raise ArgumentError, "authenticate must be false, true or :optional, got #{authenticate.inspect}"
64
+ end
65
+
46
66
  if reverify_interval
47
67
  unless reverify_interval.positive?
48
68
  raise ArgumentError, "reverify_interval must be positive, got #{reverify_interval.inspect}"
49
69
  end
50
- raise ArgumentError, "reverify_interval requires authenticate: true" unless authenticate
70
+ raise ArgumentError, "reverify_interval requires authenticate: true or :optional" unless authenticate
51
71
  end
52
72
 
53
73
  unless max_payload_size.positive?
@@ -57,8 +77,8 @@ module Monk
57
77
  if authenticate
58
78
  unless defined?(Monk::Auth) && Monk::Auth.config
59
79
  raise Monk::AuthNotConfiguredError,
60
- "Monk::WebSocket::Server.new(authenticate: true) requires Monk::Auth to already be " \
61
- "configured -- call Monk::Auth.configure first"
80
+ "Monk::WebSocket::Server.new(authenticate: #{authenticate.inspect}) requires Monk::Auth to " \
81
+ "already be configured -- call Monk::Auth.configure first"
62
82
  end
63
83
 
64
84
  # Monk::Auth's config is a plain, unfrozen Hash until this runs
@@ -107,15 +127,22 @@ module Monk
107
127
  end
108
128
  ractor.send(socket, move: true)
109
129
  end
110
- rescue Interrupt
111
- # Ctrl+C's default Ruby behavior: raise Interrupt in the thread
112
- # blocked in the syscall, here TCPServer#accept. Unrescued, that's
113
- # an unhandled exception -- Ruby prints its backtrace and exits
114
- # non-zero, which reads as a crash even though this is the normal,
115
- # intended way to stop a long-running server. Registry#ask's
116
- # Ractor::ClosedError rescue (registry.rb) guards a different
117
- # failure point in this same shutdown -- a live connection's
118
- # cleanup racing the registry Ractor's teardown -- not this one.
130
+ rescue SignalException => e
131
+ raise unless STOP_SIGNALS.include?(e.signo)
132
+
133
+ # Ctrl+C (INT, raised as Interrupt) and TERM (what docker stop,
134
+ # Kubernetes, Fly, Render and systemd send on every deploy) both
135
+ # land here: Ruby raises them in the thread blocked in
136
+ # TCPServer#accept. Unrescued, INT prints a backtrace and TERM ends
137
+ # the process by the signal (exit 143) -- either reads as a crash
138
+ # though it's the normal way to stop a long-running server. Here
139
+ # the server stops accepting and #run returns, so the script ends
140
+ # with exit 0, as bin/server and bin/jobs do. Open connections are
141
+ # dropped when the process exits, with no close frame
142
+ # (docs/design/websocket.md, "Stopping the server").
143
+ # Registry#ask's Ractor::ClosedError rescue (registry.rb) guards a
144
+ # different failure point in this same shutdown -- a live
145
+ # connection's cleanup racing the registry Ractor's teardown.
119
146
  @tcp_server.close
120
147
  end
121
148
 
@@ -138,9 +165,14 @@ module Monk
138
165
  end
139
166
 
140
167
  subject = nil
141
- if authenticate
168
+ case authenticate
169
+ when true
142
170
  subject = authenticate!(socket, headers, allowed_origins)
143
171
  return unless subject
172
+ when :optional
173
+ return unless origin_allowed_for_optional?(socket, headers, allowed_origins)
174
+
175
+ subject = identify(headers)
144
176
  end
145
177
 
146
178
  socket.write(Handshake.response_for(request))
@@ -148,7 +180,7 @@ module Monk
148
180
  connection = Connection.new(socket, subject: subject, max_payload_size: max_payload_size)
149
181
  connection.start_heartbeat(ping_interval) if ping_interval
150
182
  reverify_thread =
151
- if reverify_interval
183
+ if reverify_interval && subject
152
184
  start_reverify(connection, Handshake.credential_from(headers)[:token], reverify_interval)
153
185
  end
154
186
  begin
@@ -194,6 +226,34 @@ module Monk
194
226
  end
195
227
  private_class_method :authenticate!
196
228
 
229
+ # authenticate: :optional's Origin check, before anything is verified.
230
+ # A Bearer connection is exempt, as under true. A cookie needs an
231
+ # allowed Origin, as under true. An anonymous connection that sends an
232
+ # Origin needs an allowed one too: only a browser sends it, and this
233
+ # stops another site's page from opening sockets here on its
234
+ # visitors' behalf; one that sends none (not a browser) gets in.
235
+ # Writes the 403 itself and returns false when refused.
236
+ def self.origin_allowed_for_optional?(socket, headers, allowed_origins)
237
+ return true unless allowed_origins
238
+
239
+ credential = Handshake.credential_from(headers)
240
+ return true if credential&.fetch(:via) == :bearer
241
+
242
+ origin = headers["origin"]
243
+ allowed = credential ? origin && allowed_origins.include?(origin) : origin.nil? || allowed_origins.include?(origin)
244
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n") unless allowed
245
+ allowed
246
+ end
247
+ private_class_method :origin_allowed_for_optional?
248
+
249
+ # The subject of a valid session, or nil -- no credential, or one
250
+ # Monk::Auth no longer accepts, both mean anonymous under :optional.
251
+ def self.identify(headers)
252
+ credential = Handshake.credential_from(headers)
253
+ credential && Monk::Auth.verify(credential[:token])
254
+ end
255
+ private_class_method :identify
256
+
197
257
  # Sleeps in `interval`-sized steps, re-running Monk::Auth.verify
198
258
  # against the same raw credential each time, until it comes back
199
259
  # nil (revoked or expired) -- then closes the connection with the
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: monkrb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.0
4
+ version: 0.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matteo Folin
@@ -176,6 +176,14 @@ files:
176
176
  - lib/monk/environment.rb
177
177
  - lib/monk/errors.rb
178
178
  - lib/monk/freeze_hooks.rb
179
+ - lib/monk/jobs.rb
180
+ - lib/monk/jobs/adapters/pg.rb
181
+ - lib/monk/jobs/args.rb
182
+ - lib/monk/jobs/claim.rb
183
+ - lib/monk/jobs/errors.rb
184
+ - lib/monk/jobs/job.rb
185
+ - lib/monk/jobs/runtime.rb
186
+ - lib/monk/jobs/worker.rb
179
187
  - lib/monk/live.rb
180
188
  - lib/monk/live/client/idiomorph.LICENSE
181
189
  - lib/monk/live/client/idiomorph.js
@@ -192,6 +200,7 @@ files:
192
200
  - lib/monk/mail.rb
193
201
  - lib/monk/mail/address.rb
194
202
  - lib/monk/mail/errors.rb
203
+ - lib/monk/mail/later.rb
195
204
  - lib/monk/mail/message.rb
196
205
  - lib/monk/mail/mime.rb
197
206
  - lib/monk/mail/transports.rb
@@ -206,29 +215,44 @@ files:
206
215
  - lib/monk/scaffold.rb
207
216
  - lib/monk/settings.rb
208
217
  - lib/monk/state_ractor.rb
218
+ - lib/monk/templates/auth/app/mailers/app_mailer.rb
219
+ - lib/monk/templates/auth/app/views/mail/magic_link.erb
209
220
  - lib/monk/templates/auth/config/auth.rb
210
221
  - lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.down.sql
211
222
  - lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.up.sql
212
- - lib/monk/templates/auth/views/mail/magic_link.erb
213
223
  - lib/monk/templates/base/.dockerignore
214
224
  - lib/monk/templates/base/.gitignore
215
225
  - lib/monk/templates/base/.ruby-version
216
226
  - lib/monk/templates/base/Dockerfile
217
227
  - lib/monk/templates/base/Gemfile
228
+ - lib/monk/templates/base/app/app.rb
229
+ - lib/monk/templates/base/app/broadcasts/.keep
230
+ - lib/monk/templates/base/app/helpers/.keep
231
+ - lib/monk/templates/base/app/jobs/.keep
232
+ - lib/monk/templates/base/app/mailers/.keep
233
+ - lib/monk/templates/base/app/models/.keep
234
+ - lib/monk/templates/base/app/presenters/.keep
235
+ - lib/monk/templates/base/app/views/index.erb
236
+ - lib/monk/templates/base/app/views/layouts/app.erb
218
237
  - lib/monk/templates/base/bin/server
219
238
  - lib/monk/templates/base/bin/websocket_server
220
239
  - lib/monk/templates/base/config.ru
240
+ - lib/monk/templates/base/config/load.rb
221
241
  - lib/monk/templates/base/config/settings.rb
222
242
  - lib/monk/templates/base/public/css/app.css
223
243
  - lib/monk/templates/base/public/js/app.js
224
- - lib/monk/templates/base/views/index.erb
225
- - lib/monk/templates/base/views/layouts/app.erb
244
+ - lib/monk/templates/jobs/app/jobs/hello_job.rb
245
+ - lib/monk/templates/jobs/app/jobs/send_login_link.rb
246
+ - lib/monk/templates/jobs/bin/jobs
247
+ - lib/monk/templates/jobs/config/jobs.rb
248
+ - lib/monk/templates/jobs/db/migrate/00000000000002_create_jobs_tables.down.sql
249
+ - lib/monk/templates/jobs/db/migrate/00000000000002_create_jobs_tables.up.sql
250
+ - lib/monk/templates/live/app/app.rb
251
+ - lib/monk/templates/live/app/views/index.erb
252
+ - lib/monk/templates/live/app/views/live/_hits.erb
226
253
  - lib/monk/templates/live/bin/websocket_server
227
- - lib/monk/templates/live/config.ru
228
254
  - lib/monk/templates/live/config/live.rb
229
255
  - lib/monk/templates/live/config/live_pg.rb
230
- - lib/monk/templates/live/views/index.erb
231
- - lib/monk/templates/live/views/live/_hits.erb
232
256
  - lib/monk/templates/mail/Gemfile.extra
233
257
  - lib/monk/templates/mail/config/mail.rb
234
258
  - lib/monk/templates/postgres/Dockerfile
@@ -245,6 +269,7 @@ files:
245
269
  - lib/monk/websocket/errors.rb
246
270
  - lib/monk/websocket/frame.rb
247
271
  - lib/monk/websocket/handshake.rb
272
+ - lib/monk/websocket/listeners.rb
248
273
  - lib/monk/websocket/pg_fanout.rb
249
274
  - lib/monk/websocket/redis_fanout.rb
250
275
  - lib/monk/websocket/registry.rb