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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +166 -0
- data/README.md +7 -6
- data/exe/monk +3 -181
- data/lib/monk/assets.rb +39 -9
- data/lib/monk/auth/errors.rb +3 -0
- data/lib/monk/auth/helpers.rb +11 -0
- data/lib/monk/auth/send_login_link.rb +36 -0
- data/lib/monk/auth.rb +34 -5
- data/lib/monk/base.rb +20 -0
- data/lib/monk/bin.rb +107 -0
- data/lib/monk/cli.rb +277 -0
- data/lib/monk/context.rb +17 -0
- data/lib/monk/errors.rb +0 -6
- data/lib/monk/generator/definition.rb +153 -0
- data/lib/monk/generator/result.rb +279 -0
- data/lib/monk/generator/run.rb +449 -0
- data/lib/monk/generator.rb +48 -0
- data/lib/monk/generators/auth.rb +29 -0
- data/lib/monk/generators/base.rb +19 -0
- data/lib/monk/generators/jobs.rb +21 -0
- data/lib/monk/generators/live.rb +26 -0
- data/lib/monk/generators/mail.rb +24 -0
- data/lib/monk/generators/postgres.rb +23 -0
- data/lib/monk/generators/redis.rb +20 -0
- data/lib/monk/generators/websocket.rb +24 -0
- data/lib/monk/generators.rb +7 -0
- data/lib/monk/jobs/adapters/pg.rb +3 -1
- data/lib/monk/jobs.rb +7 -0
- data/lib/monk/live/helpers.rb +13 -0
- data/lib/monk/live/policy.rb +39 -4
- data/lib/monk/live/session.rb +7 -0
- data/lib/monk/live.rb +17 -4
- data/lib/monk/log.rb +10 -1
- data/lib/monk/mail/deliver_later.rb +54 -0
- data/lib/monk/mail/later.rb +3 -51
- data/lib/monk/mail.rb +4 -0
- data/lib/monk/persistence/errors.rb +32 -0
- data/lib/monk/persistence/pg.rb +92 -1
- data/lib/monk/persistence/pool.rb +513 -0
- data/lib/monk/persistence.rb +196 -7
- data/lib/monk/templates/auth/agents.md +29 -0
- data/lib/monk/templates/auth/app/mailers/{app_mailer.rb → auth_mailer.rb} +5 -5
- data/lib/monk/templates/auth/app/routes/auth.rb +48 -0
- data/lib/monk/templates/auth/app/views/auth/login.erb +25 -0
- data/lib/monk/templates/auth/config/auth.rb +14 -8
- data/lib/monk/templates/auth/setup.md +37 -0
- data/lib/monk/templates/auth/test/auth_test.rb +17 -0
- data/lib/monk/templates/base/CLAUDE.md +1 -0
- data/lib/monk/templates/base/Dockerfile +4 -3
- data/lib/monk/templates/base/Gemfile +7 -2
- data/lib/monk/templates/base/Rakefile +8 -0
- data/lib/monk/templates/base/agents.md +41 -0
- data/lib/monk/templates/base/app/app.rb +5 -0
- data/lib/monk/templates/base/app/routes/.keep +0 -0
- data/lib/monk/templates/base/app/views/index.erb +1 -1
- data/lib/monk/templates/base/app/views/layouts/app.erb +2 -0
- data/lib/monk/templates/base/config/load.rb +8 -0
- data/lib/monk/templates/base/config/settings.rb +4 -5
- data/lib/monk/templates/base/setup.md +22 -0
- data/lib/monk/templates/base/test/app_test.rb +12 -0
- data/lib/monk/templates/base/test/test_helper.rb +18 -0
- data/lib/monk/templates/jobs/agents.md +25 -0
- data/lib/monk/templates/jobs/app/routes/jobs.rb +9 -0
- data/lib/monk/templates/jobs/bin/jobs +19 -13
- data/lib/monk/templates/jobs/setup.md +30 -0
- data/lib/monk/templates/jobs/test/jobs_test.rb +16 -0
- data/lib/monk/templates/live/agents.md +25 -0
- data/lib/monk/templates/live/app/broadcasts/greeting.rb +13 -0
- data/lib/monk/templates/live/app/routes/demo_live.rb +21 -0
- data/lib/monk/templates/live/app/routes/live.rb +10 -0
- data/lib/monk/templates/live/app/views/demo/live.erb +18 -0
- data/lib/monk/templates/live/app/views/live/_greeting.erb +1 -0
- data/lib/monk/templates/live/config/live.rb +30 -32
- data/lib/monk/templates/live/config/live_without_demo.rb +38 -0
- data/lib/monk/templates/live/setup.md +39 -0
- data/lib/monk/templates/live/test/live_test.rb +22 -0
- data/lib/monk/templates/mail/agents.md +22 -0
- data/lib/monk/templates/mail/app/mailers/app_mailer.rb +14 -0
- data/lib/monk/templates/mail/app/routes/mail.rb +12 -0
- data/lib/monk/templates/mail/app/views/mail/welcome.erb +1 -0
- data/lib/monk/templates/mail/config/mail.rb +4 -4
- data/lib/monk/templates/mail/setup.md +32 -0
- data/lib/monk/templates/mail/test/mail_test.rb +13 -0
- data/lib/monk/templates/postgres/agents.md +25 -0
- data/lib/monk/templates/postgres/app/routes/postgres.rb +22 -0
- data/lib/monk/templates/postgres/bin/console +3 -1
- data/lib/monk/templates/postgres/bin/migrate +23 -17
- data/lib/monk/templates/postgres/bin/setup_db +11 -5
- data/lib/monk/templates/postgres/config/persistence.rb +1 -0
- data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
- data/lib/monk/templates/postgres/setup.md +38 -0
- data/lib/monk/templates/postgres/test/persistence_test.rb +11 -0
- data/lib/monk/templates/redis/agents.md +18 -0
- data/lib/monk/templates/redis/app/routes/redis.rb +15 -0
- data/lib/monk/templates/redis/config/redis.rb +13 -0
- data/lib/monk/templates/redis/setup.md +20 -0
- data/lib/monk/templates/redis/test/redis_test.rb +12 -0
- data/lib/monk/templates/websocket/agents.md +25 -0
- data/lib/monk/templates/websocket/app/sockets/chat.rb +18 -0
- data/lib/monk/templates/websocket/bin/websocket_server +58 -0
- data/lib/monk/templates/websocket/config/websocket_postgres.rb +24 -0
- data/lib/monk/templates/websocket/config/websocket_redis.rb +21 -0
- data/lib/monk/templates/websocket/setup.md +51 -0
- data/lib/monk/templates/websocket/test/websocket_test.rb +21 -0
- data/lib/monk/version.rb +1 -1
- data/lib/monk/websocket/connection.rb +25 -6
- data/lib/monk/websocket/listeners.rb +24 -0
- data/lib/monk/websocket/pg_fanout.rb +143 -20
- data/lib/monk/websocket/redis_fanout.rb +78 -29
- data/lib/monk/websocket/registry.rb +24 -0
- data/lib/monk/websocket/server.rb +57 -16
- data/lib/monk/websocket.rb +2 -0
- data/lib/monk.rb +10 -0
- metadata +83 -14
- data/lib/monk/scaffold.rb +0 -1061
- data/lib/monk/templates/base/bin/websocket_server +0 -68
- data/lib/monk/templates/jobs/app/jobs/send_login_link.rb +0 -28
- data/lib/monk/templates/live/app/app.rb +0 -22
- data/lib/monk/templates/live/app/views/index.erb +0 -18
- data/lib/monk/templates/live/bin/websocket_server +0 -42
- data/lib/monk/templates/live/config/live_pg.rb +0 -60
- data/lib/monk/templates/mail/Gemfile.extra +0 -1
- data/lib/monk/templates/postgres/Dockerfile +0 -32
- data/lib/monk/templates/postgres/Gemfile.extra +0 -2
- data/lib/monk/templates/redis/Gemfile.extra +0 -1
- /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
|
-
|
|
4
|
-
|
|
5
|
-
require "monk/
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
require "monk/
|
|
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::
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
#
|
|
89
|
-
#
|
|
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.
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
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.
|