monkrb 0.18.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/README.md +2 -2
  4. data/lib/monk/live/client/monk_live.js +2 -2
  5. data/lib/monk/live/client/protocol.js +5 -0
  6. data/lib/monk/live.rb +10 -0
  7. data/lib/monk/scaffold.rb +152 -77
  8. data/lib/monk/templates/auth/app/mailers/app_mailer.rb +22 -0
  9. data/lib/monk/templates/auth/config/auth.rb +7 -20
  10. data/lib/monk/templates/base/app/app.rb +9 -0
  11. data/lib/monk/templates/base/app/broadcasts/.keep +0 -0
  12. data/lib/monk/templates/base/app/helpers/.keep +0 -0
  13. data/lib/monk/templates/base/app/jobs/.keep +0 -0
  14. data/lib/monk/templates/base/app/mailers/.keep +0 -0
  15. data/lib/monk/templates/base/app/models/.keep +0 -0
  16. data/lib/monk/templates/base/app/presenters/.keep +0 -0
  17. data/lib/monk/templates/base/bin/websocket_server +6 -0
  18. data/lib/monk/templates/base/config/load.rb +18 -0
  19. data/lib/monk/templates/base/config.ru +3 -12
  20. data/lib/monk/templates/base/public/js/app.js +1 -1
  21. data/lib/monk/templates/jobs/{jobs → app/jobs}/send_login_link.rb +2 -1
  22. data/lib/monk/templates/jobs/bin/jobs +5 -13
  23. data/lib/monk/templates/jobs/config/jobs.rb +2 -4
  24. data/lib/monk/templates/live/{config.ru → app/app.rb} +1 -6
  25. data/lib/monk/templates/live/{views → app/views}/index.erb +2 -2
  26. data/lib/monk/templates/live/bin/websocket_server +14 -2
  27. data/lib/monk/templates/live/config/live.rb +1 -1
  28. data/lib/monk/templates/live/config/live_pg.rb +1 -1
  29. data/lib/monk/templates/mail/config/mail.rb +2 -2
  30. data/lib/monk/templates/postgres/bin/console +4 -2
  31. data/lib/monk/version.rb +1 -1
  32. data/lib/monk/websocket/errors.rb +11 -0
  33. data/lib/monk/websocket/listeners.rb +23 -0
  34. data/lib/monk/websocket/pg_fanout.rb +55 -20
  35. data/lib/monk/websocket/redis_fanout.rb +57 -14
  36. data/lib/monk/websocket/registry.rb +6 -0
  37. data/lib/monk/websocket/server.rb +77 -17
  38. metadata +19 -9
  39. /data/lib/monk/templates/auth/{views → app/views}/mail/magic_link.erb +0 -0
  40. /data/lib/monk/templates/base/{views → app/views}/index.erb +0 -0
  41. /data/lib/monk/templates/base/{views → app/views}/layouts/app.erb +0 -0
  42. /data/lib/monk/templates/jobs/{jobs → app/jobs}/hello_job.rb +0 -0
  43. /data/lib/monk/templates/live/{views → app/views}/live/_hits.erb +0 -0
@@ -0,0 +1,22 @@
1
+ # The emails this app sends, through Monk::Mail (config/mail.rb). Module
2
+ # constants and module methods rather than an instance, since Monk::Auth's
3
+ # deliver: hook must be Ractor-shareable (a lambda built at a file's top
4
+ # level, where self isn't, would fail) -- the same constraint as
5
+ # Monk::Live.authorize blocks.
6
+ module AppMailer
7
+ # Monk::Auth's deliver: hook (config/auth.rb), called by
8
+ # Monk::Auth.deliver_link. The HTML part is
9
+ # app/views/mail/magic_link.erb. Swap the body for SMS or any other
10
+ # channel; see docs/guides/auth.md.
11
+ MAGIC_LINK = lambda do |email:, link:, token:|
12
+ # Development only (a no-op elsewhere): the link on the console, plus a
13
+ # QR code for a phone if rqrcode is in the Gemfile.
14
+ Monk::Auth.log_dev_link(link, subject: email)
15
+ Monk::Mail.deliver(
16
+ to: email,
17
+ subject: "Your login link",
18
+ text: "Here's your login link:\n\n#{link}\n\nIf you didn't ask for it, you can ignore this email.",
19
+ html: Monk::Mail.render("mail/magic_link", link: link),
20
+ )
21
+ end
22
+ end
@@ -7,25 +7,12 @@ require_relative "persistence"
7
7
  # "#{Monk::Settings[:public_url]}/auth/callback/#{token}"), see its comment
8
8
  # there and auth.md's "Sending the magic link".
9
9
 
10
- # Sends the magic link by email, through Monk::Mail (config/mail.rb, which
11
- # config.ru requires) -- a module constant, not a lambda inline in
12
- # Auth.configure below, since self at this file's top level isn't
13
- # Ractor-shareable (same constraint as Monk::Live.authorize blocks). The
14
- # HTML part is views/mail/magic_link.erb. Swap the body for SMS or any
15
- # other channel; see docs/guides/auth.md.
16
- module AppMailer
17
- DELIVER = lambda do |email:, link:, token:|
18
- # Development only (a no-op elsewhere): the link on the console, plus a
19
- # QR code for a phone if rqrcode is in the Gemfile.
20
- Monk::Auth.log_dev_link(link, subject: email)
21
- Monk::Mail.deliver(
22
- to: email,
23
- subject: "Your login link",
24
- text: "Here's your login link:\n\n#{link}\n\nIf you didn't ask for it, you can ignore this email.",
25
- html: Monk::Mail.render("mail/magic_link", link: link),
26
- )
27
- end
28
- end
10
+ # Sends the magic link by email: AppMailer::MAGIC_LINK, in
11
+ # app/mailers/app_mailer.rb. Required here, not left to config/load.rb:
12
+ # Monk::Auth.configure checks deliver: right away, and bin/websocket_server
13
+ # loads this file without config/load.rb. The mailer only touches
14
+ # Monk::Mail when it's called, so that process needs no mail config.
15
+ require_relative "../app/mailers/app_mailer"
29
16
 
30
17
  Monk::Auth.configure(
31
18
  db_name: :primary,
@@ -34,5 +21,5 @@ Monk::Auth.configure(
34
21
  session_ttl: 1_209_600, # seconds a session stays valid (14 days)
35
22
  redirect_allowlist: [], # paths request_login(redirect_to:) is allowed to target
36
23
  secure: !Monk.env.development?, # Secure cookie flag; off in dev so plain-http testing works (Safari drops it)
37
- deliver: AppMailer::DELIVER,
24
+ deliver: AppMailer::MAGIC_LINK,
38
25
  )
@@ -0,0 +1,9 @@
1
+ class App < Monk::Base
2
+ views "app/views"
3
+ layout "layouts/app"
4
+ assets "public"
5
+
6
+ get("/") { @title = "App"; render "index" }
7
+ get("/hello") { "hello from monk" }
8
+ get("/api/hello") { json(message: "hello from monk") }
9
+ end
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -48,6 +48,12 @@ module ChatServer
48
48
  end
49
49
  end
50
50
 
51
+ # This process holds the sockets, so it's the one that listens for other
52
+ # processes' broadcasts: with REDIS_URL, a subscriber connection, opened
53
+ # here at boot (an unreachable Redis fails right here). A plain Registry
54
+ # has nothing to listen to, and listen! does nothing.
55
+ ChatServer::REGISTRY.listen!
56
+
51
57
  authenticate = defined?(Monk::Auth) && !!Monk::Auth.config
52
58
  port = ENV.fetch("WS_PORT", "9293").to_i
53
59
  # Defaults to public_url (config/settings.rb) so one PUBLIC_URL env var
@@ -0,0 +1,18 @@
1
+ # Loads everything the app runs on: the settings, each Monk module's config,
2
+ # then the app's own code under app/. Required by config.ru and the tests;
3
+ # it never boots the app itself (that's config.ru's Monk.boot).
4
+ require_relative "settings"
5
+
6
+ # The app's code, one directory per role, in the order they call each
7
+ # other: presenters read models, helpers, mailers and broadcasts use both,
8
+ # and jobs call all of them. Everything is loaded here, before Monk.boot
9
+ # freezes the app -- nothing can be loaded later from a worker Ractor.
10
+ #
11
+ # The order only matters for code that runs while a file loads (a
12
+ # superclass, a constant in a class body, a Monk::Context.include): such a
13
+ # file require_relative's what it needs first. Code inside methods runs
14
+ # later, so it may use any role, even one loaded after it. See Monk's
15
+ # docs/guides/scaffolding.md, "Where code goes".
16
+ %w[models presenters helpers mailers broadcasts jobs].each do |role|
17
+ Dir[File.expand_path("../app/#{role}/**/*.rb", __dir__)].sort.each { |file| require file }
18
+ end
@@ -1,13 +1,4 @@
1
- require_relative "config/settings"
1
+ require_relative "config/load" # the configs and app/ (config/load.rb)
2
+ require_relative "app/app"
2
3
 
3
- class App < Monk::Base
4
- views "views"
5
- layout "layouts/app"
6
- assets "public"
7
-
8
- get("/") { @title = "App"; render "index" }
9
- get("/hello") { "hello from monk" }
10
- get("/api/hello") { json(message: "hello from monk") }
11
- end
12
-
13
- run Monk.boot(App)
4
+ run Monk.boot(App) # Boot: freezes the app and serves it
@@ -1,5 +1,5 @@
1
1
  // Served as-is and loaded as an ES module -- no build step. Import your
2
2
  // own files by relative path with a real extension
3
3
  // (import { x } from "./x.js"), and anything third-party through the
4
- // import map in views/layouts/app.erb.
4
+ // import map in app/views/layouts/app.erb.
5
5
  console.log("monk");
@@ -21,7 +21,8 @@ class SendLoginLink < Monk::Job
21
21
  token = Monk::Auth.request_login(email, redirect_to: redirect_to)
22
22
  # Your callback route; config/settings.rb's public_url is the origin.
23
23
  link = "#{Monk::Settings[:public_url]}/auth/callback/#{token}"
24
- # Calls config/auth.rb's deliver: -- a synchronous send, here in the job.
24
+ # Calls config/auth.rb's deliver: (AppMailer::MAGIC_LINK) -- a
25
+ # synchronous send, here in the job.
25
26
  Monk::Auth.deliver_link(email: email, link: link, token: token)
26
27
  end
27
28
  end
@@ -1,22 +1,14 @@
1
1
  #!/usr/bin/env ruby
2
2
  require "bundler/setup"
3
- require_relative "../config/settings"
4
-
5
- # Loaded when this app has them, so jobs can send mail or use Monk::Auth --
6
- # a missing file is a harmless no-op, the same way bin/websocket_server
7
- # treats config/auth.rb.
8
- %w[mail auth].each do |name|
9
- require_relative "../config/#{name}"
10
- rescue LoadError
11
- nil
12
- end
13
-
14
- require_relative "../config/jobs"
3
+ # The configs and app/, as the web process loads them, so a job can use
4
+ # the app's models and mailers, send mail, or push a Live update --
5
+ # config/load.rb, never Monk.boot: this process serves no routes.
6
+ require_relative "../config/load"
15
7
  require "monk/jobs/runtime"
16
8
 
17
9
  # The app's templates, so a job can render one (a mail's HTML part, say):
18
10
  # there's no App class here to point Monk::Views at them.
19
- Monk::Views.root = File.expand_path("../views", __dir__)
11
+ Monk::Views.root = File.expand_path("../app/views", __dir__)
20
12
 
21
13
  workers = Integer(ENV.fetch("JOBS_WORKERS", "5"))
22
14
  queues = ENV.fetch("JOBS_QUEUES", "default").split(",").map(&:strip)
@@ -4,8 +4,8 @@ require_relative "persistence"
4
4
 
5
5
  # Background jobs (Monk::Jobs): enqueue from any route, e.g.
6
6
  # HelloJob.enqueue("world"); bin/jobs runs them. Every job is a class under
7
- # jobs/, loaded below so the web process can enqueue it and bin/jobs can
8
- # run it.
7
+ # app/jobs/, which config/load.rb loads after this file, so the web process
8
+ # can enqueue it and bin/jobs can run it.
9
9
  #
10
10
  # The queue lives in the app's own database, so a job can be enqueued
11
11
  # inside the app's own transaction: HelloJob.enqueue("world", conn: conn).
@@ -13,5 +13,3 @@ require_relative "persistence"
13
13
  # a database of its own -- register it in config/persistence.rb and pass
14
14
  # its name here (Monk's docs/guides/jobs.md, "Keeping the queue healthy").
15
15
  Monk::Jobs.configure(db_name: :primary)
16
-
17
- Dir[File.expand_path("../jobs/*.rb", __dir__)].sort.each { |file| require file }
@@ -1,8 +1,5 @@
1
- require_relative "config/settings"
2
- require_relative "config/live"
3
-
4
1
  class App < Monk::Base
5
- views "views"
2
+ views "app/views"
6
3
  layout "layouts/app"
7
4
  assets "public"
8
5
 
@@ -23,5 +20,3 @@ class App < Monk::Base
23
20
  get("/hello") { "hello from monk" }
24
21
  get("/api/hello") { json(message: "hello from monk") }
25
22
  end
26
-
27
- run Monk.boot(App)
@@ -12,7 +12,7 @@
12
12
 
13
13
  <p>
14
14
  The pieces: <code>live_topic</code> in this view, the partial
15
- <code>views/live/_hits.erb</code>, <code>Monk::Live.patch</code> in
16
- <code>POST /hit</code> (<code>config.ru</code>), and the rules and Redis
15
+ <code>app/views/live/_hits.erb</code>, <code>Monk::Live.patch</code> in
16
+ <code>POST /hit</code> (<code>app/app.rb</code>), and the rules and Redis
17
17
  wiring in <code>config/live.rb</code>.
18
18
  </p>
@@ -13,11 +13,23 @@ end
13
13
 
14
14
  # Monk::Live: the browsers' sockets end here. The handler takes their
15
15
  # subscribe/unsubscribe messages, checks each topic against the rules in
16
- # config/live.rb, and relays whatever config.ru's process publishes over
16
+ # config/live.rb, and relays whatever bin/server's process publishes over
17
17
  # Redis.
18
18
  require_relative "../config/live"
19
19
 
20
- authenticate = defined?(Monk::Auth) && !!Monk::Auth.config
20
+ # This process holds the browsers' sockets, so it's the only one that
21
+ # listens for updates published elsewhere (bin/server, bin/jobs): the
22
+ # fanout's subscriber connection is opened here, at boot, and an
23
+ # unreachable Redis/Postgres fails right here.
24
+ Monk::Live.listen!
25
+
26
+ # With Monk::Auth (--auth), a socket with a valid session gets its user,
27
+ # and one without -- a visitor, or a session since revoked -- comes in
28
+ # anonymous instead of refused. The rules in config/live.rb decide what
29
+ # each may subscribe to: a topic is open to visitors only if its rule says
30
+ # `anonymous: true`, like the demo's "hits". Without Monk::Auth every
31
+ # socket is anonymous.
32
+ authenticate = defined?(Monk::Auth) && Monk::Auth.config ? :optional : false
21
33
  port = ENV.fetch("WS_PORT", "9293").to_i
22
34
  # Defaults to public_url (config/settings.rb) so one PUBLIC_URL env var
23
35
  # keeps this in sync with the app's actual origin instead of two vars
@@ -1,4 +1,4 @@
1
- # Monk::Live wiring, required by both processes: config.ru (this app
1
+ # Monk::Live wiring, required by both processes: config/load.rb (this app
2
2
  # publishes updates, e.g. from a route) and bin/websocket_server (the browsers'
3
3
  # sockets terminate there and are handed the updates).
4
4
  #
@@ -1,4 +1,4 @@
1
- # Monk::Live wiring, required by both processes: config.ru (this app
1
+ # Monk::Live wiring, required by both processes: config/load.rb (this app
2
2
  # publishes updates, e.g. from a route) and bin/websocket_server (the browsers'
3
3
  # sockets terminate there and are handed the updates).
4
4
  #
@@ -2,8 +2,8 @@ require "monk"
2
2
  require "monk/mail"
3
3
 
4
4
  # Sends email with Monk::Mail.deliver(to:, subject:, text:, html:) from
5
- # any route -- and, with --auth, the magic links config/auth.rb's AppMailer
6
- # delivers. See docs/guides/mail.md. Required from config.ru (not from
5
+ # any route -- and, with --auth, the magic links AppMailer::MAGIC_LINK
6
+ # (app/mailers/app_mailer.rb) delivers. See docs/guides/mail.md. Required from config/load.rb (not from
7
7
  # config/auth.rb: bin/websocket_server loads that too, and never sends mail).
8
8
  Monk::Settings.configure do
9
9
  # Where mail goes. Unset in development means log:// (printed to the
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env ruby
2
2
  require "bundler/setup"
3
- require_relative "../config/settings"
4
- require_relative "../config/persistence"
3
+ # The configs and app/ (models, jobs, mailers...), as the app sees them --
4
+ # config/load.rb, never Monk.boot, so nothing is frozen and you can poke at
5
+ # anything.
6
+ require_relative "../config/load"
5
7
  require "irb"
6
8
 
7
9
  IRB.start(__FILE__)
data/lib/monk/version.rb CHANGED
@@ -5,5 +5,5 @@ module Monk
5
5
  # class of bug .freeze! guards against for routes/error handlers/models,
6
6
  # just on a top-level constant that every worker Ractor reads on boot
7
7
  # (found live under kino: GET /hello 500'd until this was frozen).
8
- VERSION = "0.18.0".freeze
8
+ VERSION = "0.19.0".freeze
9
9
  end
@@ -12,5 +12,16 @@ module Monk
12
12
  # tradeoff.
13
13
  class PayloadTooLargeError < StandardError
14
14
  end
15
+
16
+ # A fanout's #register before its #listen!: a socket registered there
17
+ # would never get another process's broadcasts, so this says so at the
18
+ # first subscription instead of losing them silently.
19
+ class NotListeningError < StandardError
20
+ end
21
+
22
+ # #listen! couldn't open its subscriber connection (Redis or Postgres
23
+ # unreachable, bad credentials) -- raised in the WS process's boot.
24
+ class ListenError < StandardError
25
+ end
15
26
  end
16
27
  end
@@ -0,0 +1,23 @@
1
+ module Monk
2
+ module WebSocket
3
+ # Which fanouts in this process have called #listen!, by origin id.
4
+ # A fanout freezes itself (every connection Ractor reads it through a
5
+ # module constant), so it can't record this on itself; a frozen list
6
+ # in a module ivar is written from the main Ractor, where #listen! runs
7
+ # at boot, and read from any Ractor, where #register runs.
8
+ module Listeners
9
+ @origins = [].freeze
10
+
11
+ class << self
12
+ def listening?(origin) = @origins.include?(origin)
13
+
14
+ # Main Ractor only -- a module ivar write from another Ractor raises
15
+ # Ractor::IsolationError, which is the right answer for a boot-time
16
+ # call made from the wrong place.
17
+ def add(origin)
18
+ @origins = Ractor.make_shareable([*@origins, origin])
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,5 +1,7 @@
1
1
  require "pg"
2
2
  require "securerandom"
3
+ require_relative "errors"
4
+ require_relative "listeners"
3
5
 
4
6
  module Monk
5
7
  module WebSocket
@@ -7,7 +9,7 @@ module Monk
7
9
  # "monk/websocket/pg_fanout" explicitly -- "monk/websocket" alone does
8
10
  # not load this. A second implementation of the exact interface
9
11
  # RedisFanout wraps a Registry behind (#register, #unregister, #count,
10
- # #broadcast, docs/design/live-pg-fanout.md,
12
+ # #broadcast, #listen!, docs/design/live-pg-fanout.md,
11
13
  # docs/history/plan-live-pg-fanout.md), so an app swaps which object it
12
14
  # holds and nothing else changes. Uses Postgres LISTEN/NOTIFY instead
13
15
  # of Redis pub/sub -- for an app that already runs Postgres
@@ -53,15 +55,11 @@ module Monk
53
55
  end
54
56
 
55
57
  def initialize(registry, pg_opts:)
56
- # Checked upfront, on the argument, before anything below opens a
57
- # real Postgres connection -- RedisFanout has no equivalent check
58
- # (freezing self never raises on its own, even when an ivar isn't
59
- # itself shareable), so a bad registry there only surfaces later
60
- # as an opaque Ractor::IsolationError. Checking here first, rather
61
- # than after spawning the subscriber below, means a bad registry
62
- # never leaves a live LISTEN connection running past a failed
63
- # construction. Mirrors Monk::Live.configure's own check of its
64
- # registry argument.
58
+ # Checked upfront, on the argument -- RedisFanout has no equivalent
59
+ # check (freezing self never raises on its own, even when an ivar
60
+ # isn't itself shareable), so a bad registry there only surfaces
61
+ # later as an opaque Ractor::IsolationError. Mirrors
62
+ # Monk::Live.configure's own check of its registry argument.
65
63
  unless Ractor.shareable?(registry)
66
64
  raise ArgumentError,
67
65
  "Monk::WebSocket::PgFanout.new needs a Ractor-shareable registry, got #{registry.class}"
@@ -79,20 +77,43 @@ module Monk
79
77
  @pg_opts = Ractor.make_shareable(pg_opts.dup)
80
78
  # Frozen explicitly, not just a String literal: read from every
81
79
  # calling Ractor's own #broadcast and from the subscriber Ractor
82
- # below, both of which raise Ractor::IsolationError on an
80
+ # #listen! starts, both of which raise Ractor::IsolationError on an
83
81
  # unfrozen value -- same reason RedisFanout freezes its own
84
82
  # @origin.
85
83
  @origin = SecureRandom.uuid.freeze
86
84
 
85
+ freeze
86
+ end
87
+
88
+ # Starts relaying other processes' broadcasts into this process's
89
+ # registry: a subscriber Ractor with its own LISTEN connection. Only
90
+ # the process that holds sockets calls this (bin/websocket_server, at
91
+ # boot, from the main Ractor) -- a publish-only process (bin/server,
92
+ # bin/jobs) never does, so it holds no LISTEN connection at all.
93
+ # Returns once the LISTEN is in effect, so nothing notified after it
94
+ # can be missed; raises ListenError if Postgres can't be reached.
95
+ # Calling it again is a no-op.
96
+ def listen!
97
+ return self if Listeners.listening?(@origin)
98
+
99
+ ready = Ractor::Port.new
87
100
  # Ractor.new's block args must be shareable: registry is
88
101
  # (Registry freezes itself around a Ractor, which is inherently
89
- # shareable), @pg_opts and @origin are already made so above. The
90
- # PG::Connection itself is never passed in -- opened fresh inside
91
- # the block, mirroring RedisFanout's subscriber and
92
- # Monk::Persistence::Pg's per-Ractor connection pattern.
93
- @subscriber = Ractor.new(registry, @pg_opts, @origin) do |registry, pg_opts, own_origin|
94
- conn = PG.connect(**pg_opts)
95
- conn.exec("LISTEN #{Monk::WebSocket::PgFanout::CHANNEL}")
102
+ # shareable), @pg_opts and @origin are already made so, a Port
103
+ # always is. The PG::Connection itself is never passed in --
104
+ # opened fresh inside the block, mirroring RedisFanout's
105
+ # subscriber and Monk::Persistence::Pg's per-Ractor connection
106
+ # pattern.
107
+ Ractor.new(@registry, @pg_opts, @origin, ready) do |registry, pg_opts, own_origin, ready|
108
+ begin
109
+ conn = PG.connect(**pg_opts)
110
+ conn.exec("LISTEN #{Monk::WebSocket::PgFanout::CHANNEL}")
111
+ rescue StandardError => e
112
+ ready.send("#{e.class}: #{e.message}")
113
+ next
114
+ end
115
+ ready.send(:ok)
116
+
96
117
  loop do
97
118
  conn.wait_for_notify do |_channel, _pid, raw|
98
119
  sender, key, payload = Monk::WebSocket::PgFanout.decode_envelope(raw)
@@ -103,10 +124,24 @@ module Monk
103
124
  end
104
125
  end
105
126
 
106
- freeze
127
+ result = ready.receive
128
+ raise ListenError, "Monk::WebSocket::PgFanout#listen! couldn't LISTEN: #{result}" unless result == :ok
129
+
130
+ Listeners.add(@origin)
131
+ self
132
+ end
133
+
134
+ def register(key, port)
135
+ unless Listeners.listening?(@origin)
136
+ raise NotListeningError,
137
+ "Monk::WebSocket::PgFanout#register before #listen!: this socket would never get " \
138
+ "other processes' broadcasts -- call listen! once at boot in the process that holds " \
139
+ "sockets (bin/websocket_server: Monk::Live.listen!)"
140
+ end
141
+
142
+ @registry.register(key, port)
107
143
  end
108
144
 
109
- def register(key, port) = @registry.register(key, port)
110
145
  def unregister(key, port) = @registry.unregister(key, port)
111
146
  def count(key) = @registry.count(key)
112
147
 
@@ -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)