monkrb 0.19.0 → 0.21.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +166 -0
  3. data/README.md +7 -6
  4. data/exe/monk +3 -181
  5. data/lib/monk/assets.rb +39 -9
  6. data/lib/monk/auth/errors.rb +3 -0
  7. data/lib/monk/auth/helpers.rb +11 -0
  8. data/lib/monk/auth/send_login_link.rb +36 -0
  9. data/lib/monk/auth.rb +34 -5
  10. data/lib/monk/base.rb +20 -0
  11. data/lib/monk/bin.rb +107 -0
  12. data/lib/monk/cli.rb +277 -0
  13. data/lib/monk/context.rb +17 -0
  14. data/lib/monk/errors.rb +0 -6
  15. data/lib/monk/generator/definition.rb +153 -0
  16. data/lib/monk/generator/result.rb +279 -0
  17. data/lib/monk/generator/run.rb +449 -0
  18. data/lib/monk/generator.rb +48 -0
  19. data/lib/monk/generators/auth.rb +29 -0
  20. data/lib/monk/generators/base.rb +19 -0
  21. data/lib/monk/generators/jobs.rb +21 -0
  22. data/lib/monk/generators/live.rb +26 -0
  23. data/lib/monk/generators/mail.rb +24 -0
  24. data/lib/monk/generators/postgres.rb +23 -0
  25. data/lib/monk/generators/redis.rb +20 -0
  26. data/lib/monk/generators/websocket.rb +24 -0
  27. data/lib/monk/generators.rb +7 -0
  28. data/lib/monk/jobs/adapters/pg.rb +3 -1
  29. data/lib/monk/jobs.rb +7 -0
  30. data/lib/monk/live/helpers.rb +13 -0
  31. data/lib/monk/live/policy.rb +39 -4
  32. data/lib/monk/live/session.rb +7 -0
  33. data/lib/monk/live.rb +17 -4
  34. data/lib/monk/log.rb +10 -1
  35. data/lib/monk/mail/deliver_later.rb +54 -0
  36. data/lib/monk/mail/later.rb +3 -51
  37. data/lib/monk/mail.rb +4 -0
  38. data/lib/monk/persistence/errors.rb +32 -0
  39. data/lib/monk/persistence/pg.rb +92 -1
  40. data/lib/monk/persistence/pool.rb +513 -0
  41. data/lib/monk/persistence.rb +196 -7
  42. data/lib/monk/templates/auth/agents.md +29 -0
  43. data/lib/monk/templates/auth/app/mailers/{app_mailer.rb → auth_mailer.rb} +5 -5
  44. data/lib/monk/templates/auth/app/routes/auth.rb +48 -0
  45. data/lib/monk/templates/auth/app/views/auth/login.erb +25 -0
  46. data/lib/monk/templates/auth/config/auth.rb +14 -8
  47. data/lib/monk/templates/auth/setup.md +37 -0
  48. data/lib/monk/templates/auth/test/auth_test.rb +17 -0
  49. data/lib/monk/templates/base/CLAUDE.md +1 -0
  50. data/lib/monk/templates/base/Dockerfile +4 -3
  51. data/lib/monk/templates/base/Gemfile +7 -2
  52. data/lib/monk/templates/base/Rakefile +8 -0
  53. data/lib/monk/templates/base/agents.md +41 -0
  54. data/lib/monk/templates/base/app/app.rb +5 -0
  55. data/lib/monk/templates/base/app/routes/.keep +0 -0
  56. data/lib/monk/templates/base/app/views/index.erb +1 -1
  57. data/lib/monk/templates/base/app/views/layouts/app.erb +2 -0
  58. data/lib/monk/templates/base/config/load.rb +8 -0
  59. data/lib/monk/templates/base/config/settings.rb +4 -5
  60. data/lib/monk/templates/base/setup.md +22 -0
  61. data/lib/monk/templates/base/test/app_test.rb +12 -0
  62. data/lib/monk/templates/base/test/test_helper.rb +18 -0
  63. data/lib/monk/templates/jobs/agents.md +25 -0
  64. data/lib/monk/templates/jobs/app/routes/jobs.rb +9 -0
  65. data/lib/monk/templates/jobs/bin/jobs +19 -13
  66. data/lib/monk/templates/jobs/setup.md +30 -0
  67. data/lib/monk/templates/jobs/test/jobs_test.rb +16 -0
  68. data/lib/monk/templates/live/agents.md +25 -0
  69. data/lib/monk/templates/live/app/broadcasts/greeting.rb +13 -0
  70. data/lib/monk/templates/live/app/routes/demo_live.rb +21 -0
  71. data/lib/monk/templates/live/app/routes/live.rb +10 -0
  72. data/lib/monk/templates/live/app/views/demo/live.erb +18 -0
  73. data/lib/monk/templates/live/app/views/live/_greeting.erb +1 -0
  74. data/lib/monk/templates/live/config/live.rb +30 -32
  75. data/lib/monk/templates/live/config/live_without_demo.rb +38 -0
  76. data/lib/monk/templates/live/setup.md +39 -0
  77. data/lib/monk/templates/live/test/live_test.rb +22 -0
  78. data/lib/monk/templates/mail/agents.md +22 -0
  79. data/lib/monk/templates/mail/app/mailers/app_mailer.rb +14 -0
  80. data/lib/monk/templates/mail/app/routes/mail.rb +12 -0
  81. data/lib/monk/templates/mail/app/views/mail/welcome.erb +1 -0
  82. data/lib/monk/templates/mail/config/mail.rb +4 -4
  83. data/lib/monk/templates/mail/setup.md +32 -0
  84. data/lib/monk/templates/mail/test/mail_test.rb +13 -0
  85. data/lib/monk/templates/postgres/agents.md +25 -0
  86. data/lib/monk/templates/postgres/app/routes/postgres.rb +22 -0
  87. data/lib/monk/templates/postgres/bin/console +3 -1
  88. data/lib/monk/templates/postgres/bin/migrate +23 -17
  89. data/lib/monk/templates/postgres/bin/setup_db +11 -5
  90. data/lib/monk/templates/postgres/config/persistence.rb +1 -0
  91. data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
  92. data/lib/monk/templates/postgres/setup.md +38 -0
  93. data/lib/monk/templates/postgres/test/persistence_test.rb +11 -0
  94. data/lib/monk/templates/redis/agents.md +18 -0
  95. data/lib/monk/templates/redis/app/routes/redis.rb +15 -0
  96. data/lib/monk/templates/redis/config/redis.rb +13 -0
  97. data/lib/monk/templates/redis/setup.md +20 -0
  98. data/lib/monk/templates/redis/test/redis_test.rb +12 -0
  99. data/lib/monk/templates/websocket/agents.md +25 -0
  100. data/lib/monk/templates/websocket/app/sockets/chat.rb +18 -0
  101. data/lib/monk/templates/websocket/bin/websocket_server +58 -0
  102. data/lib/monk/templates/websocket/config/websocket_postgres.rb +24 -0
  103. data/lib/monk/templates/websocket/config/websocket_redis.rb +21 -0
  104. data/lib/monk/templates/websocket/setup.md +51 -0
  105. data/lib/monk/templates/websocket/test/websocket_test.rb +21 -0
  106. data/lib/monk/version.rb +1 -1
  107. data/lib/monk/websocket/connection.rb +25 -6
  108. data/lib/monk/websocket/listeners.rb +24 -0
  109. data/lib/monk/websocket/pg_fanout.rb +143 -20
  110. data/lib/monk/websocket/redis_fanout.rb +78 -29
  111. data/lib/monk/websocket/registry.rb +24 -0
  112. data/lib/monk/websocket/server.rb +57 -16
  113. data/lib/monk/websocket.rb +2 -0
  114. data/lib/monk.rb +10 -0
  115. metadata +83 -14
  116. data/lib/monk/scaffold.rb +0 -1061
  117. data/lib/monk/templates/base/bin/websocket_server +0 -68
  118. data/lib/monk/templates/jobs/app/jobs/send_login_link.rb +0 -28
  119. data/lib/monk/templates/live/app/app.rb +0 -22
  120. data/lib/monk/templates/live/app/views/index.erb +0 -18
  121. data/lib/monk/templates/live/bin/websocket_server +0 -42
  122. data/lib/monk/templates/live/config/live_pg.rb +0 -60
  123. data/lib/monk/templates/mail/Gemfile.extra +0 -1
  124. data/lib/monk/templates/postgres/Dockerfile +0 -32
  125. data/lib/monk/templates/postgres/Gemfile.extra +0 -2
  126. data/lib/monk/templates/redis/Gemfile.extra +0 -1
  127. /data/lib/monk/templates/live/app/views/{live → demo}/_hits.erb +0 -0
@@ -1,22 +1,28 @@
1
1
  #!/usr/bin/env ruby
2
2
  require "bundler/setup"
3
- require_relative "../config/settings"
4
- require_relative "../config/persistence"
5
- require "monk/persistence/pg/migrator"
3
+ # A service that's down, a missing database or setting ends this script
4
+ # with what to fix (SETUP.md's commands), not a backtrace.
5
+ require "monk/bin"
6
6
 
7
- migrator = Monk::Persistence::Pg::Migrator.new(db_name: :primary, dir: File.expand_path("../db/migrate", __dir__))
7
+ Monk::Bin.run(__FILE__) do
8
+ require_relative "../config/settings"
9
+ require_relative "../config/persistence"
10
+ require "monk/persistence/pg/migrator"
8
11
 
9
- case ARGV[0]
10
- when nil, "migrate"
11
- applied = migrator.migrate!
12
- puts applied.empty? ? "Nothing to migrate." : "Applied: #{applied.join(", ")}"
13
- when "rollback"
14
- reverted = migrator.rollback!(steps: (ARGV[1] || 1).to_i)
15
- puts reverted.empty? ? "Nothing to roll back." : "Reverted: #{reverted.join(", ")}"
16
- when "status"
17
- puts "Applied: #{migrator.applied.join(", ")}"
18
- puts "Pending: #{migrator.pending.join(", ")}"
19
- else
20
- warn "Usage: bin/migrate [migrate|rollback [N]|status]"
21
- exit 1
12
+ migrator = Monk::Persistence::Pg::Migrator.new(db_name: :primary, dir: File.expand_path("../db/migrate", __dir__))
13
+
14
+ case ARGV[0]
15
+ when nil, "migrate"
16
+ applied = migrator.migrate!
17
+ puts applied.empty? ? "Nothing to migrate." : "Applied: #{applied.join(", ")}"
18
+ when "rollback"
19
+ reverted = migrator.rollback!(steps: (ARGV[1] || 1).to_i)
20
+ puts reverted.empty? ? "Nothing to roll back." : "Reverted: #{reverted.join(", ")}"
21
+ when "status"
22
+ puts "Applied: #{migrator.applied.join(", ")}"
23
+ puts "Pending: #{migrator.pending.join(", ")}"
24
+ else
25
+ warn "Usage: bin/migrate [migrate|rollback [N]|status]"
26
+ exit 1
27
+ end
22
28
  end
@@ -1,9 +1,15 @@
1
1
  #!/usr/bin/env ruby
2
2
  require "bundler/setup"
3
- require_relative "../config/settings"
4
- require_relative "../config/persistence"
5
- require "monk/persistence/pg/migrator"
3
+ # A service that's down, a missing database or setting ends this script
4
+ # with what to fix (SETUP.md's commands), not a backtrace.
5
+ require "monk/bin"
6
6
 
7
- Monk::Persistence::Pg::Migrator.new(db_name: :primary, dir: File.expand_path("../db/migrate", __dir__)).migrate!
7
+ Monk::Bin.run(__FILE__) do
8
+ require_relative "../config/settings"
9
+ require_relative "../config/persistence"
10
+ require "monk/persistence/pg/migrator"
8
11
 
9
- puts "Migrated #{ENV.fetch("DB_NAME", "app_development")}."
12
+ Monk::Persistence::Pg::Migrator.new(db_name: :primary, dir: File.expand_path("../db/migrate", __dir__)).migrate!
13
+
14
+ puts "Migrated #{ENV.fetch("DB_NAME", "app_development")}."
15
+ end
@@ -1,5 +1,6 @@
1
1
  require "monk"
2
2
  require "monk/persistence/pg"
3
+ require "monk/persistence/pg/model" # Monk::Persistence::Pg::Model, for app/models/
3
4
 
4
5
  Monk::Persistence::Pg.register(:primary,
5
6
  host: ENV.fetch("DB_HOST", "127.0.0.1"),
File without changes
@@ -0,0 +1,38 @@
1
+ ## postgres
2
+
3
+ `.env` and `.env.test` point at a local Postgres (`DB_*`), with database
4
+ names taken from this app's directory: `{{app}}_development` and
5
+ `{{app}}_test`. `MONK_ENV=test` doesn't change the database: only `DB_NAME`
6
+ does, and `test/test_helper.rb` takes it from `.env.test`.
7
+
8
+ ### Start Postgres
9
+
10
+ Check first whether one is already running (`docker ps`): a second
11
+ container on the same port fails with "port is already allocated". Either
12
+ point `DB_HOST`/`DB_PORT`/`DB_USER`/`DB_PASSWORD` in `.env` and `.env.test`
13
+ at the running one, or start one:
14
+
15
+ ```bash
16
+ docker run --rm -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres --name {{app}}_pg postgres:16
17
+ ```
18
+
19
+ ### Create the databases and migrate them
20
+
21
+ ```bash
22
+ PGPASSWORD=postgres createdb -h 127.0.0.1 -p 5432 -U postgres {{app}}_development
23
+ PGPASSWORD=postgres createdb -h 127.0.0.1 -p 5432 -U postgres {{app}}_test
24
+ bin/setup_db
25
+ DB_NAME={{app}}_test bin/setup_db
26
+ ```
27
+
28
+ `createdb` without `-h` uses a Unix socket, which a container doesn't
29
+ provide. Run both `bin/setup_db` lines again after adding a migration.
30
+
31
+ ### Check it works
32
+
33
+ 1. `bundle exec rake test` runs `test/persistence_test.rb`, which connects
34
+ to the test database.
35
+ 2. By hand: `bin/console` opens IRB with the app's configs and models
36
+ loaded; `Monk::Persistence::Pg.checkout(:primary) { |conn| conn.exec("SELECT 1").values }`
37
+ answers `[[1]]`. `bin/migrate status` lists applied and pending
38
+ migrations.
@@ -0,0 +1,11 @@
1
+ require_relative "test_helper"
2
+
3
+ # The test database is reachable with .env.test's settings, and migrated
4
+ # (SETUP.md, postgres).
5
+ class PersistenceTest < Minitest::Test
6
+ def test_connects_to_the_test_database
7
+ Monk::Persistence::Pg.checkout(:primary) do |conn|
8
+ assert_equal 1, conn.exec("SELECT 1").getvalue(0, 0)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,18 @@
1
+ ## redis
2
+
3
+ **Where:** `config/redis.rb` declares the `redis_url` setting (from
4
+ `REDIS_URL`) and loads the `redis` gem.
5
+
6
+ **Main calls:** `Redis.new(url: settings[:redis_url])` in a route (or
7
+ `Monk::Settings[:redis_url]` elsewhere), then the redis gem's commands:
8
+ `get`, `set(key, value, ex: seconds)`, `incr`, `del`. Close it when done.
9
+
10
+ **Test:** `test/redis_test.rb` pings Redis at `.env.test`'s `REDIS_URL`.
11
+
12
+ **Pitfalls:**
13
+ - A Redis client isn't Ractor-shareable: never keep one in a constant or a
14
+ module variable. Open it in the route (or job) that uses it.
15
+ - Monk has no Redis pool: each `Redis.new` is a connection. Close it.
16
+ - `REDIS_URL` is required outside development and test.
17
+
18
+ **Examples:** `redis-cache` in `app/routes/redis.rb`.
@@ -0,0 +1,15 @@
1
+ # Routes that use the app's Redis (monk add redis).
2
+ #
3
+ # monk:example redis-cache -- a value computed once a minute, cached in Redis.
4
+ # # A Redis client can't be shared between Ractors, and routes run in them,
5
+ # # so each request opens its own; Monk has no Redis pool yet.
6
+ # class App
7
+ # get("/cached/now") do
8
+ # redis = Redis.new(url: settings[:redis_url])
9
+ # now = redis.get("cached:now") || Time.now.to_i.to_s.tap { |value| redis.set("cached:now", value, ex: 60) }
10
+ # json(now: now)
11
+ # ensure
12
+ # redis&.close
13
+ # end
14
+ # end
15
+ # monk:end
@@ -0,0 +1,13 @@
1
+ require "monk"
2
+ require "redis"
3
+
4
+ # Where this app's Redis is (monk add redis): for a cache, rate limits,
5
+ # locks, or as WebSocket's transport (config/websocket.rb). A local one by
6
+ # default in development and test; anywhere else REDIS_URL must be set.
7
+ Monk::Settings.configure do
8
+ if Monk.env.development? || Monk.env.test?
9
+ optional :redis_url, default: "redis://localhost:6379/0"
10
+ else
11
+ required :redis_url
12
+ end
13
+ end
@@ -0,0 +1,20 @@
1
+ ## redis
2
+
3
+ `REDIS_URL` in `.env` and `.env.test` points at a local Redis
4
+ (`redis://localhost:6379/0`). Outside development and test it has no
5
+ default: set it, or the app fails at boot.
6
+
7
+ ### Start Redis
8
+
9
+ Check first whether one is already running (`docker ps`). Either point
10
+ `REDIS_URL` at it, or start one:
11
+
12
+ ```bash
13
+ docker run --rm -d -p 6379:6379 --name {{app}}_redis redis:7
14
+ ```
15
+
16
+ ### Check it works
17
+
18
+ 1. `bundle exec rake test` runs `test/redis_test.rb`, which pings it.
19
+ 2. By hand, in `bin/console`: `Redis.new(url: Monk::Settings[:redis_url]).ping`
20
+ answers `"PONG"`.
@@ -0,0 +1,12 @@
1
+ require_relative "test_helper"
2
+
3
+ # Redis is reachable at REDIS_URL (.env.test): SETUP.md, redis.
4
+ class RedisTest < Minitest::Test
5
+ def test_redis_answers
6
+ redis = Redis.new(url: Monk::Settings[:redis_url])
7
+
8
+ assert_equal "PONG", redis.ping
9
+ ensure
10
+ redis&.close
11
+ end
12
+ end
@@ -0,0 +1,25 @@
1
+ ## websocket
2
+
3
+ **Where:** `config/websocket.rb` (`AppWebSocket::REGISTRY`, over the app's
4
+ transport: `AppWebSocket::TRANSPORT`), `bin/websocket_server` (the socket
5
+ process), `app/sockets/` (handlers, when the app doesn't use live).
6
+
7
+ **Main calls:** `AppWebSocket::REGISTRY.broadcast(topic, payload)` from any
8
+ process. In a handler, `proc { |connection| ... }`: `connection.read`,
9
+ `connection.subscribe(AppWebSocket::REGISTRY, topic)`,
10
+ `connection.subject` (the user, with auth). The server runs
11
+ `AppSockets::HANDLER`, or live's.
12
+
13
+ **Test:** `test/websocket_test.rb` sends a broadcast across two registries
14
+ over the transport.
15
+
16
+ **Pitfalls:**
17
+ - Handlers run in each connection's own Ractor: build them in a module
18
+ body, never at a file's top level.
19
+ - Only `bin/websocket_server` calls `listen!`; every other process only
20
+ publishes.
21
+ - With Postgres, a broadcast is capped at just under 8000 bytes (NOTIFY).
22
+ - `WS_ALLOWED_ORIGINS` must list your pages' origins.
23
+ - Switching transports is three steps in SETUP.md, websocket.
24
+
25
+ **Examples:** `websocket-chat` in `app/sockets/chat.rb`.
@@ -0,0 +1,18 @@
1
+ # Socket handlers for bin/websocket_server, in an app without Monk::Live
2
+ # (with live, the server runs live's handler instead).
3
+ #
4
+ # monk:example websocket-chat -- one chat room: what a socket sends goes to every socket.
5
+ # # The handler runs in each connection's own Ractor, so it's built in a
6
+ # # module body, where self is shareable.
7
+ # module AppSockets
8
+ # HANDLER = proc do |connection|
9
+ # connection.subscribe(AppWebSocket::REGISTRY, :chat)
10
+ # loop do
11
+ # message = connection.read
12
+ # break unless message
13
+ #
14
+ # AppWebSocket::REGISTRY.broadcast(:chat, "#{connection.subject || "guest"}: #{message}")
15
+ # end
16
+ # end
17
+ # end
18
+ # monk:end
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env ruby
2
+ require "bundler/setup"
3
+ # A service that's down, a missing database or setting ends this script
4
+ # with what to fix (SETUP.md's commands), not a backtrace.
5
+ require "monk/bin"
6
+
7
+ Monk::Bin.run(__FILE__) do
8
+ require_relative "../config/settings"
9
+ require_relative "../config/websocket"
10
+
11
+ # Each only if this app has it: auth says whose a socket is, live what it
12
+ # may subscribe to.
13
+ %w[auth live].each do |name|
14
+ path = File.expand_path("../config/#{name}.rb", __dir__)
15
+ require path if File.exist?(path)
16
+ end
17
+ live = defined?(Monk::Live) && Monk::Live.registry
18
+
19
+ # Without live, the app's own handler: AppSockets::HANDLER, in app/sockets/.
20
+ Dir[File.expand_path("../app/sockets/*.rb", __dir__)].sort.each { |file| require file }
21
+ handler = live ? Monk::Live::HANDLER : (AppSockets::HANDLER if defined?(AppSockets::HANDLER))
22
+ unless handler
23
+ $stderr.puts "bin/websocket_server: nothing to serve. Add live (monk add live), or define " \
24
+ "AppSockets::HANDLER in app/sockets/ -- the websocket-chat example in app/sockets/chat.rb is one."
25
+ exit 1
26
+ end
27
+
28
+ # This process holds the sockets, so it's the only one that listens for
29
+ # what the others publish: the transport's subscriber connection opens
30
+ # here, and an unreachable Postgres or Redis fails right here.
31
+ AppWebSocket::REGISTRY.listen!
32
+
33
+ # With auth, a socket carries its user's session. With live, a visitor's
34
+ # socket comes in anonymous instead of refused, and live's rules
35
+ # (config/live.rb) decide what it may subscribe to.
36
+ authenticate = if defined?(Monk::Auth) && Monk::Auth.config
37
+ live ? :optional : true
38
+ else
39
+ false
40
+ end
41
+ # Sessions are checked through config/auth.rb's :auth pool, not in each
42
+ # socket's own Ractor, which would hold a Postgres connection per open
43
+ # page. This is the only process that uses the pool, so it starts it.
44
+ db_pool = (:auth if authenticate)
45
+ Monk::Persistence::Pg.start_pools!(db_pool) if db_pool
46
+
47
+ port = ENV.fetch("WS_PORT", "9293").to_i
48
+ # Defaults to public_url (config/settings.rb): one PUBLIC_URL keeps both
49
+ # in sync. More than one origin: comma-separated.
50
+ allowed_origins = ENV.fetch("WS_ALLOWED_ORIGINS", Monk::Settings[:public_url]).split(",")
51
+
52
+ server = Monk::WebSocket::Server.new(
53
+ port: port, authenticate: authenticate, allowed_origins: allowed_origins, db_pool: db_pool,
54
+ )
55
+ puts "Monk::WebSocket::Server listening on ws://0.0.0.0:#{port} " \
56
+ "(#{live ? "live" : "app/sockets"}, transport: #{AppWebSocket::TRANSPORT}, authenticate: #{authenticate})"
57
+ server.run(&handler)
58
+ end
@@ -0,0 +1,24 @@
1
+ require "monk"
2
+ require "monk/websocket"
3
+ require "monk/websocket/pg_fanout"
4
+ require_relative "persistence"
5
+
6
+ # The WebSocket registry (monk add websocket): which sockets are subscribed
7
+ # to which topics, and how a broadcast reaches them. bin/server, bin/jobs
8
+ # and bin/websocket_server are separate processes, so Postgres
9
+ # LISTEN/NOTIFY carries a broadcast from one to the others, over the
10
+ # :primary connection's settings (config/persistence.rb). A broadcast is
11
+ # capped at just under 8000 bytes, Postgres's NOTIFY limit. To switch to
12
+ # Redis: SETUP.md, websocket.
13
+ module AppWebSocket
14
+ TRANSPORT = "postgres".freeze
15
+
16
+ # A registry over this app's transport. bin/websocket_server's (REGISTRY)
17
+ # also listens; every other process only publishes.
18
+ def self.build_registry
19
+ Monk::WebSocket::PgFanout.new(Monk::WebSocket::Registry.new,
20
+ pg_opts: Monk::Persistence::Pg.connection_options(:primary),)
21
+ end
22
+
23
+ REGISTRY = build_registry
24
+ end
@@ -0,0 +1,21 @@
1
+ require "monk"
2
+ require "monk/websocket"
3
+ require "monk/websocket/redis_fanout"
4
+ require_relative "redis"
5
+
6
+ # The WebSocket registry (monk add websocket): which sockets are subscribed
7
+ # to which topics, and how a broadcast reaches them. bin/server, bin/jobs
8
+ # and bin/websocket_server are separate processes, so Redis carries a
9
+ # broadcast from one to the others, at REDIS_URL (config/redis.rb). To
10
+ # switch to Postgres: SETUP.md, websocket.
11
+ module AppWebSocket
12
+ TRANSPORT = "redis".freeze
13
+
14
+ # A registry over this app's transport. bin/websocket_server's (REGISTRY)
15
+ # also listens; every other process only publishes.
16
+ def self.build_registry
17
+ Monk::WebSocket::RedisFanout.new(Monk::WebSocket::Registry.new, redis_url: Monk::Settings[:redis_url])
18
+ end
19
+
20
+ REGISTRY = build_registry
21
+ end
@@ -0,0 +1,51 @@
1
+ ## websocket
2
+
3
+ `bin/websocket_server` holds the browsers' WebSocket connections, on :9293
4
+ (`WS_PORT`), next to `bin/server`. They're separate processes, so a
5
+ broadcast travels between them over this app's transport, chosen when the
6
+ module was added: `config/websocket.rb` says which.
7
+
8
+ ### Run it
9
+
10
+ ```bash
11
+ bin/websocket_server
12
+ ```
13
+
14
+ It serves Monk::Live (`monk add live`) or, without it, the handler in
15
+ `app/sockets/` (the `websocket-chat` example). With neither, it says so and
16
+ exits. In production it's one more process from the same image, with
17
+ `bin/websocket_server` as its command.
18
+
19
+ `WS_ALLOWED_ORIGINS` (default: `PUBLIC_URL`) must list every origin your
20
+ pages are served from, or browsers' sockets are refused. Behind TLS, pages
21
+ connect with `wss://`.
22
+
23
+ ### Switching transports
24
+
25
+ 1. If the other one isn't installed, add it: `monk add redis` or
26
+ `monk add postgres`.
27
+ 2. In `config/websocket.rb`, swap the fanout: `Monk::WebSocket::RedisFanout`
28
+ with `redis_url: Monk::Settings[:redis_url]` (and `require_relative
29
+ "redis"`), or `Monk::WebSocket::PgFanout` with
30
+ `pg_opts: Monk::Persistence::Pg.connection_options(:primary)` (and
31
+ `require_relative "persistence"`).
32
+ 3. Restart `bin/server`, `bin/websocket_server` and `bin/jobs`.
33
+
34
+ ### Check it works
35
+
36
+ 1. `bundle exec rake test` runs `test/websocket_test.rb`: a broadcast
37
+ crosses from one registry to another over the transport.
38
+ 2. By hand, without live: uncomment the `websocket-chat` block in
39
+ `app/sockets/chat.rb` and start `bin/websocket_server` next to
40
+ `bin/server`. Open http://localhost:9292 in two tabs, and in each
41
+ browser console:
42
+
43
+ ```js
44
+ ws = new WebSocket("ws://localhost:9293")
45
+ ws.onmessage = (event) => console.log(event.data)
46
+ ```
47
+
48
+ `ws.send("hi")` in one tab logs `guest: hi` in both. With auth, the
49
+ server lets only logged-in sockets in: log in first (auth, above), and
50
+ the message carries your email. With live, `bin/websocket_server` runs
51
+ live's handler instead: live, below.
@@ -0,0 +1,21 @@
1
+ require_relative "test_helper"
2
+ require "timeout"
3
+
4
+ # A broadcast crosses processes over this app's transport
5
+ # (config/websocket.rb): a second registry, built the same way, publishes
6
+ # as bin/server would, and AppWebSocket::REGISTRY, listening as
7
+ # bin/websocket_server does, delivers it. SETUP.md, websocket.
8
+ class WebSocketTest < Minitest::Test
9
+ def test_a_broadcast_reaches_a_socket_through_the_transport
10
+ AppWebSocket::REGISTRY.listen!
11
+ port = Ractor::Port.new
12
+ AppWebSocket::REGISTRY.register(:websocket_check, port)
13
+
14
+ AppWebSocket.build_registry.broadcast(:websocket_check, "hello")
15
+
16
+ assert_equal "hello", Timeout.timeout(5) { port.receive }
17
+ ensure
18
+ AppWebSocket::REGISTRY.unregister(:websocket_check, port) if port
19
+ port&.close
20
+ end
21
+ end
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.19.0".freeze
8
+ VERSION = "0.21.0".freeze
9
9
  end
@@ -85,11 +85,22 @@ module Monk
85
85
 
86
86
  # The server-initiated close path (step 14): send a close frame
87
87
  # carrying the 2-byte status code + reason RFC 6455 expects, then
88
- # close the socket outright -- no wait for the client's own close
89
- # frame in reply.
88
+ # end the connection -- no wait for the client's own close frame in
89
+ # reply.
90
+ #
91
+ # Shut down, not closed: this runs from other threads too (a relay
92
+ # on Registry#close_all, the reverify thread) while the handler's
93
+ # thread is blocked reading. IO#close would wait for that reader,
94
+ # and the reader's cleanup kills the closing thread mid-close,
95
+ # leaving the descriptor open: the client never sees the close.
96
+ # shutdown tells the client at once and ends the blocked read (it
97
+ # returns nil, as on any disconnect); Server.serve's ensure closes
98
+ # the socket.
90
99
  def close(code: 1000, reason: "")
91
100
  write_frame([code].pack("n") + reason.to_s, 0x8)
92
- @socket.close
101
+ @socket.shutdown(:RDWR)
102
+ rescue Errno::ENOTCONN
103
+ # The client went away first: nothing left to shut down.
93
104
  end
94
105
 
95
106
  # Registers this connection's own Ractor::Port -- the "connection
@@ -154,9 +165,17 @@ module Monk
154
165
  private
155
166
 
156
167
  def relay_broadcasts
157
- loop { write(@port.receive) }
158
- rescue Ractor::ClosedError
159
- # #unsubscribe! closed the port out from under a pending #receive.
168
+ loop do
169
+ message = @port.receive
170
+ next write(message) unless message.is_a?(CloseRequest)
171
+
172
+ # Registry#close_all: the read loop then sees the socket closed
173
+ # and Server.serve's cleanup runs as on any other close.
174
+ return close(code: message.code, reason: message.reason)
175
+ end
176
+ rescue Ractor::ClosedError, IOError, SystemCallError
177
+ # #unsubscribe! closed the port out from under a pending #receive,
178
+ # or the socket went away first.
160
179
  end
161
180
 
162
181
  def write_frame(payload, opcode)
@@ -5,12 +5,36 @@ module Monk
5
5
  # module constant), so it can't record this on itself; a frozen list
6
6
  # in a module ivar is written from the main Ractor, where #listen! runs
7
7
  # at boot, and read from any Ractor, where #register runs.
8
+ #
9
+ # Also what both fanouts' subscribers share for surviving a dropped
10
+ # connection: the reconnect backoff and a log that can't end them.
8
11
  module Listeners
12
+ # 500 ms doubling to 30 s, the same curve as monk_live.js's reconnect.
13
+ RETRY_INITIAL = 0.5
14
+ RETRY_MAX = 30
15
+
16
+ # What a subscriber closes every open socket with after reconnecting:
17
+ # what was published while it was down is lost, and no client can
18
+ # tell (its seq counts what this process sent it), so each must
19
+ # reconnect and resync (ADR 0011).
20
+ MISSED_CODE = 1011
21
+ MISSED_REASON = "missed broadcasts".freeze
22
+
9
23
  @origins = [].freeze
10
24
 
11
25
  class << self
12
26
  def listening?(origin) = @origins.include?(origin)
13
27
 
28
+ def next_delay(delay) = [delay * 2, RETRY_MAX].min
29
+
30
+ # Monk::Log writes to log/<env>.log once Monk has booted. Logging
31
+ # must never end a subscriber, whatever state Monk::Log is in.
32
+ def log(source, level, message)
33
+ Monk::Log.public_send(level, "#{source}: #{message}")
34
+ rescue StandardError
35
+ nil
36
+ end
37
+
14
38
  # Main Ractor only -- a module ivar write from another Ractor raises
15
39
  # Ractor::IsolationError, which is the right answer for a boot-time
16
40
  # call made from the wrong place.