monkrb 0.15.0 → 0.16.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 +42 -0
- data/README.md +7 -5
- data/exe/monk +22 -8
- data/lib/monk/errors.rb +3 -0
- data/lib/monk/scaffold.rb +91 -9
- data/lib/monk/templates/live/config/live_pg.rb +60 -0
- data/lib/monk/version.rb +1 -1
- data/lib/monk/views.rb +12 -1
- data/lib/monk/websocket/errors.rb +7 -0
- data/lib/monk/websocket/pg_fanout.rb +174 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7e6286fdc2af1003d176142f6d7b7410c1f924f78466f28ff1bd4b95709c6230
|
|
4
|
+
data.tar.gz: 1c5030467a67e80f57e4f10ad939c06392d5d80cff0cc732692589f207173a66
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 204709f8e01de1f7d13e3223daff2a875979b1fd93ed5fa51663e5750f1fab6046f3ac9827712204864d2d6f50b309088f2262fa66533715b0ab486e33e330e5
|
|
7
|
+
data.tar.gz: 727e5d900941e860ae491bff2e7042a4d9ac7be13131707435361aca990021cc8fcd4b580421bfe10b0ebbe7c12430f461629449b18f49098437fa17097d72ad
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,48 @@ All notable changes to this project are documented here. Format is loosely
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/); versions are as released
|
|
5
5
|
in `lib/monk/version.rb`.
|
|
6
6
|
|
|
7
|
+
## 0.16.0 - 2026-09-24
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `Monk::WebSocket::PgFanout`: a Postgres `LISTEN`/`NOTIFY`-backed
|
|
12
|
+
alternative to `RedisFanout`, implementing the identical
|
|
13
|
+
`#register`/`#unregister`/`#count`/`#broadcast` interface, for an app
|
|
14
|
+
that already runs Postgres and doesn't need Redis as a second
|
|
15
|
+
dependency. Caps a single broadcast at just under 8000 bytes
|
|
16
|
+
(Postgres's own `NOTIFY` limit) and, unlike `RedisFanout`, has no
|
|
17
|
+
horizontal-scaling story for many WS processes or a hot topic.
|
|
18
|
+
`monk new --live --postgres` (without `--redis`) wires it automatically,
|
|
19
|
+
reusing the app's existing `DB_*` settings — no new configuration, no
|
|
20
|
+
Redis to run. See `docs/design/live-pg-fanout.md` and
|
|
21
|
+
`docs/history/plan-live-pg-fanout.md`.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **`monk new --live` no longer silently defaults to Redis.** It now
|
|
26
|
+
requires `--redis` or `--postgres` explicitly, raising
|
|
27
|
+
`Monk::AmbiguousLiveTransportError` with neither — there's no way to
|
|
28
|
+
tell which cross-process transport an app actually has available, so
|
|
29
|
+
guessing would be as likely to hand back a template that can't connect
|
|
30
|
+
as one that can. A script that ran `monk new my_app --live` needs
|
|
31
|
+
`--redis` added to keep the previous behavior.
|
|
32
|
+
|
|
33
|
+
## 0.15.1 - 2026-09-24
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- ERB templates with a non-ASCII string literal inside Ruby code (e.g.
|
|
38
|
+
`<%= ok ? "✓" : "✗" %>`) no longer render as `ASCII-8BIT` and drag the
|
|
39
|
+
whole page — layout and other locals included — down with them. The
|
|
40
|
+
compiled template source is now retagged with the encoding
|
|
41
|
+
`ERB::Compiler#compile` returns instead of dropping it.
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- `docs/ai_usage_disclaimer.md`, linked from the README.
|
|
46
|
+
- `docs/deployment-options.md`: analysis of hosting options for Postgres,
|
|
47
|
+
Redis, and SMTP.
|
|
48
|
+
|
|
7
49
|
## 0.15.0 - 2026-09-22
|
|
8
50
|
|
|
9
51
|
### Changed
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A light Ruby web framework designed to be fully `Ractor`-safe: every app it produces is a valid Rack 3 app that is also `Ractor.shareable?`, so it can be served in parallel across Ractor worker pools without silently losing that safety property. Named after Thelonious Sphere Monk, great and unique Jazz piano player and composer.
|
|
4
4
|
|
|
5
|
-
**Built in** (loaded by `require "monk"`): routing, context and error handling, boot-time Ractor-shareability checks, `Monk::StateRactor` for shared state, settings, ERB views, static assets and logging. **Opt-in** (each needs its own `require`): Postgres persistence and migrations, passwordless auth and sessions, a WebSocket server with Redis fan-out, and `Monk::Live` server-pushed HTML updates. Details for each are in the [Features](#features) table below.
|
|
5
|
+
**Built in** (loaded by `require "monk"`): routing, context and error handling, boot-time Ractor-shareability checks, `Monk::StateRactor` for shared state, settings, ERB views, static assets and logging. **Opt-in** (each needs its own `require`): Postgres persistence and migrations, passwordless auth and sessions, a WebSocket server with Redis or Postgres fan-out, and `Monk::Live` server-pushed HTML updates. Details for each are in the [Features](#features) table below.
|
|
6
6
|
|
|
7
7
|
Monk is Kino-agnostic — it's built on stdlib `Ractor` primitives only, with no runtime dependency on any particular server. [Kino](https://github.com/yaroslav/kino) is the reference/development server (see `bin/server`), but any Ractor-aware Rack server, or a conventional one, can run a Monk app.
|
|
8
8
|
|
|
@@ -60,8 +60,8 @@ Everything beyond the core is opt-in (`require "monk"` alone loads none of it).
|
|
|
60
60
|
| Persistence | `Monk::Persistence::Pg`: raw `pg`, per-Ractor connections, hash-based `Model` | `require "monk/persistence/pg"` (+ `.../pg/model`); needs the `pg` gem | [`persistence.md`](docs/guides/persistence.md) |
|
|
61
61
|
| Migrations | plain `.sql` up/down pairs, `Migrator` | `require "monk/persistence/pg/migrator"`; needs the `pg` gem | [`migrations.md`](docs/guides/migrations.md) |
|
|
62
62
|
| Auth and sessions | `Monk::Auth`: passwordless tokens, Bearer or cookie + CSRF | `require "monk/auth"`; needs the `pg` gem and a registered Postgres connection | [`auth.md`](docs/guides/auth.md) |
|
|
63
|
-
| WebSocket | `Monk::WebSocket`: RFC 6455 server as its own process, Redis fan-out | `require "monk/websocket"`;
|
|
64
|
-
| Live updates | `Monk::Live`: server-rendered HTML patches pushed to open tabs | `require "monk/live"`; needs the WebSocket server and, across processes, Redis | [`live.md`](docs/guides/live.md) |
|
|
63
|
+
| WebSocket | `Monk::WebSocket`: RFC 6455 server as its own process, Redis or Postgres fan-out | `require "monk/websocket"`; fan-out: `require "monk/websocket/redis_fanout"` (+ the `redis` gem) or `require "monk/websocket/pg_fanout"` (+ the `pg` gem) | [`websocket.md`](docs/guides/websocket.md) |
|
|
64
|
+
| Live updates | `Monk::Live`: server-rendered HTML patches pushed to open tabs | `require "monk/live"`; needs the WebSocket server and, across processes, Redis or Postgres | [`live.md`](docs/guides/live.md) |
|
|
65
65
|
| Scaffolding | `monk new` and its flags; retrofitting Postgres, Auth or Redis | — (the `monk` command; flags `--postgres`, `--auth`, `--redis`, `--live`) | [`scaffolding.md`](docs/guides/scaffolding.md) |
|
|
66
66
|
|
|
67
67
|
## More documentation
|
|
@@ -70,7 +70,7 @@ Everything beyond the core is opt-in (`require "monk"` alone loads none of it).
|
|
|
70
70
|
- [`docs/guides/deploying.md`](docs/guides/deploying.md): worked deployment examples.
|
|
71
71
|
- [`docs/design/ractor.md`](docs/design/ractor.md): how Monk uses Ruby's `Ractor`.
|
|
72
72
|
- [`docs/adr/`](docs/adr) and [`CONTEXT.md`](CONTEXT.md): architectural decisions and domain vocabulary.
|
|
73
|
-
- Design docs and phase-by-phase plans behind each feature: [`docs/design/views.md`](docs/design/views.md), [`docs/design/auth-sessions.md`](docs/design/auth-sessions.md), [`docs/design/websocket.md`](docs/design/websocket.md), [`docs/design/persistence-ractor-connections.md`](docs/design/persistence-ractor-connections.md), and the phase-by-phase plans and archived notes in [`docs/history/`](docs/history).
|
|
73
|
+
- Design docs and phase-by-phase plans behind each feature: [`docs/design/views.md`](docs/design/views.md), [`docs/design/auth-sessions.md`](docs/design/auth-sessions.md), [`docs/design/websocket.md`](docs/design/websocket.md), [`docs/design/persistence-ractor-connections.md`](docs/design/persistence-ractor-connections.md), [`docs/design/live-pg-fanout.md`](docs/design/live-pg-fanout.md), and the phase-by-phase plans and archived notes in [`docs/history/`](docs/history).
|
|
74
74
|
- [`CHANGELOG.md`](CHANGELOG.md).
|
|
75
75
|
|
|
76
76
|
## Working on this repo
|
|
@@ -85,4 +85,6 @@ More (server modes, Docker) in [`docs/development.md`](docs/development.md).
|
|
|
85
85
|
|
|
86
86
|
## Status
|
|
87
87
|
|
|
88
|
-
Monk is **pre-1.0** (see `lib/monk/version.rb` and the [changelog](CHANGELOG.md)): the API may still change between minor versions. The core is described in [`docs/history/core-plan.md`](docs/history/core-plan.md). There's no open roadmap issue at the moment; new work is proposed and tracked as it comes up. Done so far, each with its own design doc and plan: persistence, migrations, HTML templating and static assets, auth and sessions, WebSocket with Redis fan-out, log levels, live updates (`Monk::Live`, as of 2026-09-19), deployment support (Dockerfile scaffolding, `docs/guides/deploying.md`),
|
|
88
|
+
Monk is **pre-1.0** (see `lib/monk/version.rb` and the [changelog](CHANGELOG.md)): the API may still change between minor versions. The core is described in [`docs/history/core-plan.md`](docs/history/core-plan.md). There's no open roadmap issue at the moment; new work is proposed and tracked as it comes up. Done so far, each with its own design doc and plan: persistence, migrations, HTML templating and static assets, auth and sessions, WebSocket with Redis fan-out, log levels, live updates (`Monk::Live`, as of 2026-09-19), deployment support (Dockerfile scaffolding, `docs/guides/deploying.md`), the RubyGems release as `monkrb` (both 2026-09-22), and `Monk::WebSocket::PgFanout`/`monk new --live --postgres` — the same cross-process fan-out over Postgres `LISTEN`/`NOTIFY` instead of Redis, for an app that doesn't want a second piece of infrastructure (`docs/design/live-pg-fanout.md`, 2026-09-24).
|
|
89
|
+
|
|
90
|
+
[Made with Love ❤️, Ruby 💎 and AI 🤖](docs/ai_usage_disclaimer.md)
|
data/exe/monk
CHANGED
|
@@ -53,13 +53,21 @@ HELP = <<~TEXT.freeze
|
|
|
53
53
|
|
|
54
54
|
--live Also scaffold Monk::Live: a demo counter whose open tabs
|
|
55
55
|
update by themselves, pushed as HTML over a WebSocket.
|
|
56
|
-
|
|
57
|
-
are separate processes,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
Requires --redis or --postgres: bin/server and
|
|
57
|
+
bin/websocket_server are always separate processes, so
|
|
58
|
+
one of the two has to carry a publish between them --
|
|
59
|
+
with --redis, the usual Redis wiring; with --postgres
|
|
60
|
+
(and not --redis), Monk::WebSocket::PgFanout over
|
|
61
|
+
LISTEN/NOTIFY instead, reusing --postgres's own DB_*
|
|
62
|
+
settings, so there's no Redis to run. Passing both
|
|
63
|
+
picks --redis; passing neither is an error, since
|
|
64
|
+
there's no way to tell whether Postgres is available.
|
|
65
|
+
Adds
|
|
66
|
+
config/live.rb, views/live/_hits.erb and the browser
|
|
67
|
+
runtime under public/js/monk_live/, replaces config.ru,
|
|
68
|
+
views/index.erb and bin/websocket_server with live
|
|
69
|
+
versions, and adds the runtime's <meta>/<script> tags
|
|
70
|
+
to the layout.
|
|
63
71
|
|
|
64
72
|
Commands:
|
|
65
73
|
new APP_NAME Create a new project (see Options above)
|
|
@@ -110,7 +118,13 @@ when "new"
|
|
|
110
118
|
puts " bundle install"
|
|
111
119
|
puts " see SETUP.md for the full dev-then-test walkthrough"
|
|
112
120
|
puts " change the placeholder AUTH_SECRET in .env/.env.test before relying on it" if auth
|
|
113
|
-
|
|
121
|
+
if live
|
|
122
|
+
if redis
|
|
123
|
+
puts " Monk::Live needs Redis running, bin/server and bin/websocket_server (see SETUP.md)"
|
|
124
|
+
else
|
|
125
|
+
puts " Monk::Live needs bin/server and bin/websocket_server running, sharing Postgres (see SETUP.md)"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
114
128
|
else
|
|
115
129
|
usage_error!
|
|
116
130
|
end
|
data/lib/monk/errors.rb
CHANGED
data/lib/monk/scaffold.rb
CHANGED
|
@@ -58,11 +58,18 @@ module Monk
|
|
|
58
58
|
"views/index.erb" => "live/views/index.erb",
|
|
59
59
|
}.freeze
|
|
60
60
|
|
|
61
|
+
# config/live.rb itself isn't here -- #write_live! picks between
|
|
62
|
+
# LIVE_CONFIG_TEMPLATES[@live_transport] instead, since which one gets
|
|
63
|
+
# written depends on --redis vs --postgres, not a fixed mapping.
|
|
61
64
|
LIVE_FILES = {
|
|
62
|
-
"config/live.rb" => "live/config/live.rb",
|
|
63
65
|
"views/live/_hits.erb" => "live/views/live/_hits.erb",
|
|
64
66
|
}.freeze
|
|
65
67
|
|
|
68
|
+
LIVE_CONFIG_TEMPLATES = {
|
|
69
|
+
redis: "live/config/live.rb",
|
|
70
|
+
postgres: "live/config/live_pg.rb",
|
|
71
|
+
}.freeze
|
|
72
|
+
|
|
66
73
|
# Where the client runtime lands in the app's public root (an app serves
|
|
67
74
|
# it like any other static file; its files import each other by relative
|
|
68
75
|
# path, so they stay together in one directory).
|
|
@@ -87,15 +94,41 @@ module Monk
|
|
|
87
94
|
# and only then requires "redis", so the gem has to already be in the
|
|
88
95
|
# bundle for that to work.
|
|
89
96
|
#
|
|
90
|
-
# live:
|
|
91
|
-
# processes, so
|
|
92
|
-
#
|
|
97
|
+
# live: bin/server and bin/websocket_server are always separate
|
|
98
|
+
# processes, so *something* has to carry a publish between them --
|
|
99
|
+
# --redis (Monk::WebSocket::RedisFanout) or --postgres
|
|
100
|
+
# (Monk::WebSocket::PgFanout, reusing --postgres's own DB_* settings,
|
|
101
|
+
# no Redis to run). --live no longer silently defaults to one: with
|
|
102
|
+
# neither flag, there's no way to tell whether this app has Postgres
|
|
103
|
+
# available at all, so guessing would be as likely to hand back a
|
|
104
|
+
# template that can't connect as one that can. --live --redis wins
|
|
105
|
+
# over --postgres if both are passed (an explicit ask beats an
|
|
106
|
+
# implied default); --live --postgres alone (or via --auth, which
|
|
107
|
+
# implies --postgres) picks PgFanout; --live with neither raises.
|
|
108
|
+
# See docs/design/live-pg-fanout.md / docs/history/plan-live-pg-fanout.md
|
|
109
|
+
# for the design this decision comes from.
|
|
93
110
|
def initialize(dir, postgres: false, auth: false, redis: false, live: false)
|
|
94
111
|
@dir = dir
|
|
95
112
|
@auth = auth
|
|
96
113
|
@postgres = postgres || auth
|
|
97
114
|
@live = live
|
|
98
|
-
|
|
115
|
+
|
|
116
|
+
if @live
|
|
117
|
+
@redis = redis || false
|
|
118
|
+
@live_transport = if redis
|
|
119
|
+
:redis
|
|
120
|
+
elsif @postgres
|
|
121
|
+
:postgres
|
|
122
|
+
else
|
|
123
|
+
raise Monk::AmbiguousLiveTransportError,
|
|
124
|
+
"monk new --live needs --redis or --postgres to pick Monk::Live's cross-process " \
|
|
125
|
+
"transport (bin/server and bin/websocket_server are always separate processes) -- " \
|
|
126
|
+
"pass one explicitly, e.g. `--live --postgres` (docs/guides/live.md's " \
|
|
127
|
+
"\"Without Redis\" section has the tradeoffs)"
|
|
128
|
+
end
|
|
129
|
+
else
|
|
130
|
+
@redis = redis
|
|
131
|
+
end
|
|
99
132
|
end
|
|
100
133
|
|
|
101
134
|
def write!
|
|
@@ -136,6 +169,7 @@ module Monk
|
|
|
136
169
|
private
|
|
137
170
|
|
|
138
171
|
def write_live!
|
|
172
|
+
write_file("config/live.rb", LIVE_CONFIG_TEMPLATES.fetch(@live_transport))
|
|
139
173
|
LIVE_FILES.each { |relative, template| write_file(relative, template) }
|
|
140
174
|
copy_live_client!
|
|
141
175
|
add_live_to_layout!
|
|
@@ -253,12 +287,17 @@ module Monk
|
|
|
253
287
|
end
|
|
254
288
|
|
|
255
289
|
def live_setup_md_content
|
|
290
|
+
@live_transport == :postgres ? live_setup_md_content_postgres : live_setup_md_content_redis
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def live_setup_md_content_redis
|
|
256
294
|
<<~MARKDOWN
|
|
257
295
|
|
|
258
296
|
## Live updates (Monk::Live)
|
|
259
297
|
|
|
260
|
-
`monk new --live` wired a demo: a counter whose open tabs
|
|
261
|
-
themselves. It needs three things running, each in its own
|
|
298
|
+
`monk new --live --redis` wired a demo: a counter whose open tabs
|
|
299
|
+
update by themselves. It needs three things running, each in its own
|
|
300
|
+
terminal:
|
|
262
301
|
|
|
263
302
|
```bash
|
|
264
303
|
docker run --rm -d -p 6379:6379 --name #{File.basename(@dir)}_redis redis:7 # skip if one is already up
|
|
@@ -272,8 +311,45 @@ module Monk
|
|
|
272
311
|
(`REDIS_URL`, already in `.env`) is what carries an update from one to
|
|
273
312
|
the other. Where things are:
|
|
274
313
|
|
|
275
|
-
- `config/live.rb` -- the Redis wiring
|
|
276
|
-
is allowed unless a rule says so).
|
|
314
|
+
- `config/live.rb` -- the Redis wiring (`Monk::WebSocket::RedisFanout`)
|
|
315
|
+
and the subscribe rules (nothing is allowed unless a rule says so).
|
|
316
|
+
- `config.ru` -- `POST /hit` changes state and calls `Monk::Live.patch`.
|
|
317
|
+
- `views/index.erb` -- `live_topic "hits"` marks what to subscribe to.
|
|
318
|
+
- `views/live/_hits.erb` -- the fragment that gets pushed. Partials
|
|
319
|
+
used this way see only their locals (`locals[:hits]`), never
|
|
320
|
+
`params` or the session.
|
|
321
|
+
- `public/js/monk_live/` -- the browser runtime, copied from the gem.
|
|
322
|
+
The layout points at it and at `LIVE_WS_URL` (default
|
|
323
|
+
`ws://localhost:9293`; use `wss://` in production).
|
|
324
|
+
|
|
325
|
+
`WS_ALLOWED_ORIGINS` (default `http://localhost:9292`) must list the
|
|
326
|
+
origin your pages are served from, or the browser's socket is refused.
|
|
327
|
+
MARKDOWN
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def live_setup_md_content_postgres
|
|
331
|
+
<<~MARKDOWN
|
|
332
|
+
|
|
333
|
+
## Live updates (Monk::Live)
|
|
334
|
+
|
|
335
|
+
`monk new --live --postgres` wired a demo: a counter whose open tabs
|
|
336
|
+
update by themselves. It needs two things running, each in its own
|
|
337
|
+
terminal -- no Redis, since Postgres `LISTEN`/`NOTIFY` carries the
|
|
338
|
+
update instead:
|
|
339
|
+
|
|
340
|
+
```bash
|
|
341
|
+
bin/server # the HTTP app on :9292, which publishes updates
|
|
342
|
+
bin/websocket_server # the sockets on :9293, which deliver them
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
Open http://localhost:9292 in two tabs and press the button in one.
|
|
346
|
+
|
|
347
|
+
`bin/server` and `bin/websocket_server` are separate processes, so
|
|
348
|
+
Postgres `LISTEN`/`NOTIFY` (the same `DB_*` settings already in `.env`)
|
|
349
|
+
is what carries an update from one to the other. Where things are:
|
|
350
|
+
|
|
351
|
+
- `config/live.rb` -- the Postgres wiring (`Monk::WebSocket::PgFanout`)
|
|
352
|
+
and the subscribe rules (nothing is allowed unless a rule says so).
|
|
277
353
|
- `config.ru` -- `POST /hit` changes state and calls `Monk::Live.patch`.
|
|
278
354
|
- `views/index.erb` -- `live_topic "hits"` marks what to subscribe to.
|
|
279
355
|
- `views/live/_hits.erb` -- the fragment that gets pushed. Partials
|
|
@@ -285,6 +361,12 @@ module Monk
|
|
|
285
361
|
|
|
286
362
|
`WS_ALLOWED_ORIGINS` (default `http://localhost:9292`) must list the
|
|
287
363
|
origin your pages are served from, or the browser's socket is refused.
|
|
364
|
+
|
|
365
|
+
A single broadcast is capped at just under 8000 bytes
|
|
366
|
+
(`Monk::WebSocket::PgFanout::MAX_NOTIFY_PAYLOAD_BYTES`, Postgres's own
|
|
367
|
+
`NOTIFY` limit) -- far more than this demo ever sends, but worth
|
|
368
|
+
knowing before pushing much larger partials this way. Regenerate with
|
|
369
|
+
`--redis` instead of `--postgres` if that becomes a real constraint.
|
|
288
370
|
MARKDOWN
|
|
289
371
|
end
|
|
290
372
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Monk::Live wiring, required by both processes: config.ru (this app
|
|
2
|
+
# publishes updates, e.g. from a route) and bin/websocket_server (the browsers'
|
|
3
|
+
# sockets terminate there and are handed the updates).
|
|
4
|
+
#
|
|
5
|
+
# They are separate processes, so *something* has to carry a publish from
|
|
6
|
+
# one to the other. `monk new --live --postgres` (and not --redis) wired
|
|
7
|
+
# this file instead of the Redis-based default -- Postgres LISTEN/NOTIFY
|
|
8
|
+
# carries the publish instead, reusing the exact DB_* env vars
|
|
9
|
+
# config/persistence.rb already reads, so there's no Redis to run. The
|
|
10
|
+
# tradeoff: a single broadcast is capped at just under 8000 bytes
|
|
11
|
+
# (Monk::WebSocket::PgFanout::MAX_NOTIFY_PAYLOAD_BYTES, Postgres's own
|
|
12
|
+
# NOTIFY limit) -- regenerate with --redis instead if that becomes a real
|
|
13
|
+
# constraint. Full design in the monk gem's own docs/design/live-pg-fanout.md.
|
|
14
|
+
require_relative "settings"
|
|
15
|
+
require "monk/live"
|
|
16
|
+
require "monk/websocket/pg_fanout"
|
|
17
|
+
|
|
18
|
+
# Where the browser opens its WebSocket (read by the layout). In
|
|
19
|
+
# development there's no reverse proxy in front, so this is the direct WS
|
|
20
|
+
# port; outside development it defaults to a /ws path under public_url
|
|
21
|
+
# (config/settings.rb) -- matching the path-based proxy routing
|
|
22
|
+
# docs/guides/deploying.md's "Adding Monk::WebSocket" section sets up, so
|
|
23
|
+
# setting PUBLIC_URL alone keeps this in sync instead of two env vars
|
|
24
|
+
# drifting apart. Override with LIVE_WS_URL directly if your setup doesn't
|
|
25
|
+
# fit that shape.
|
|
26
|
+
default_live_ws_url =
|
|
27
|
+
if Monk.env.development?
|
|
28
|
+
"ws://localhost:9293"
|
|
29
|
+
else
|
|
30
|
+
Monk::Settings[:public_url].sub(/\Ahttps:\/\//, "wss://").sub(/\Ahttp:\/\//, "ws://") + "/ws"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Monk::Settings.configure do
|
|
34
|
+
optional :live_ws_url, default: default_live_ws_url
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Monk::Live.configure(
|
|
38
|
+
registry: Monk::WebSocket::PgFanout.new(
|
|
39
|
+
Monk::WebSocket::Registry.new,
|
|
40
|
+
pg_opts: {
|
|
41
|
+
host: ENV.fetch("DB_HOST", "127.0.0.1"),
|
|
42
|
+
port: ENV.fetch("DB_PORT", "5432").to_i,
|
|
43
|
+
user: ENV.fetch("DB_USER", "postgres"),
|
|
44
|
+
password: ENV.fetch("DB_PASSWORD", "postgres"),
|
|
45
|
+
dbname: ENV.fetch("DB_NAME", "app_development"),
|
|
46
|
+
},
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Who may subscribe to what. Nothing is allowed unless a rule says so, and an
|
|
51
|
+
# anonymous connection is denied unless the rule opts in with `anonymous: true`.
|
|
52
|
+
# The rule blocks live in a module because they have to be Ractor-shareable
|
|
53
|
+
# (self at the top of this file is not).
|
|
54
|
+
module AppLive
|
|
55
|
+
# The demo counter is public. A real rule looks at who is asking:
|
|
56
|
+
# proc { |subject, topic| topic == "contacts:#{subject}" }
|
|
57
|
+
PUBLIC = proc { |_subject, _topic| true }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
Monk::Live.authorize("hits", anonymous: true, &AppLive::PUBLIC)
|
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.16.0".freeze
|
|
9
9
|
end
|
data/lib/monk/views.rb
CHANGED
|
@@ -151,7 +151,18 @@ module Monk
|
|
|
151
151
|
compiler.post_cmd = ["Monk::Views::Raw.new(_erbout)"]
|
|
152
152
|
compiler.put_cmd = "_erbout.<<"
|
|
153
153
|
compiler.insert_cmd = "_erbout.<< Monk::Views.h"
|
|
154
|
-
compiler.compile(source)
|
|
154
|
+
script, encoding, = compiler.compile(source)
|
|
155
|
+
# ERB::Compiler#compile scans the source as ASCII-8BIT (see its
|
|
156
|
+
# own String#b) and hands that tag straight through to any code
|
|
157
|
+
# inside <%= %>/<% %> -- template TEXT is safe (put_cmd dumps it
|
|
158
|
+
# to ASCII escapes), but a non-ASCII literal written in Ruby code,
|
|
159
|
+
# e.g. <%= ok ? "✓" : "✗" %>, comes back mistagged
|
|
160
|
+
# ASCII-8BIT even though the bytes are valid UTF-8. #compile's
|
|
161
|
+
# second return value is the encoding to retag it with; dropping
|
|
162
|
+
# that (as this used to) is what broke non-ASCII Ruby literals --
|
|
163
|
+
# the mistagged string then downgrades whatever UTF-8 content it's
|
|
164
|
+
# concatenated with (layouts, other locals) to ASCII-8BIT too.
|
|
165
|
+
encoding ? script.dup.force_encoding(encoding) : script
|
|
155
166
|
end
|
|
156
167
|
|
|
157
168
|
def method_name_for(name)
|
|
@@ -5,5 +5,12 @@ module Monk
|
|
|
5
5
|
|
|
6
6
|
class ProtocolError < StandardError
|
|
7
7
|
end
|
|
8
|
+
|
|
9
|
+
# Raised by PgFanout#broadcast before ever calling pg_notify, rather
|
|
10
|
+
# than letting a raw PG::Error surface from inside it or silently
|
|
11
|
+
# truncating -- see docs/design/live-pg-fanout.md's payload-cap
|
|
12
|
+
# tradeoff.
|
|
13
|
+
class PayloadTooLargeError < StandardError
|
|
14
|
+
end
|
|
8
15
|
end
|
|
9
16
|
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
require "pg"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
module Monk
|
|
5
|
+
module WebSocket
|
|
6
|
+
# Cross-process fan-out for Registry, opt-in: require
|
|
7
|
+
# "monk/websocket/pg_fanout" explicitly -- "monk/websocket" alone does
|
|
8
|
+
# not load this. A second implementation of the exact interface
|
|
9
|
+
# RedisFanout wraps a Registry behind (#register, #unregister, #count,
|
|
10
|
+
# #broadcast, docs/design/live-pg-fanout.md,
|
|
11
|
+
# docs/history/plan-live-pg-fanout.md), so an app swaps which object it
|
|
12
|
+
# holds and nothing else changes. Uses Postgres LISTEN/NOTIFY instead
|
|
13
|
+
# of Redis pub/sub -- for an app that already runs Postgres
|
|
14
|
+
# (persistence, Monk::Auth) and doesn't need Redis's higher throughput
|
|
15
|
+
# or its lack of a payload cap (see the design doc's "Where this is
|
|
16
|
+
# worse than Redis").
|
|
17
|
+
class PgFanout
|
|
18
|
+
# Postgres has no pattern LISTEN (unlike Redis's own
|
|
19
|
+
# psubscribe("monk:ws:*")), so every key shares one fixed channel;
|
|
20
|
+
# the key rides inside the notification payload instead of the
|
|
21
|
+
# channel name -- see the subscriber Ractor in #initialize below
|
|
22
|
+
# for the other half of this.
|
|
23
|
+
CHANNEL = "monk_live".freeze
|
|
24
|
+
|
|
25
|
+
# Postgres's own hard limit on a single NOTIFY payload -- verified
|
|
26
|
+
# directly against a real server (not assumed from documentation):
|
|
27
|
+
# 7999 bytes succeeds, exactly 8000 fails with "payload string too
|
|
28
|
+
# long". #broadcast checks the full envelope (origin + key + app
|
|
29
|
+
# payload) against this *exclusive* bound before ever calling
|
|
30
|
+
# pg_notify, so an oversized broadcast fails with a clear
|
|
31
|
+
# PayloadTooLargeError instead of a raw PG::Error surfacing from
|
|
32
|
+
# inside the query.
|
|
33
|
+
MAX_NOTIFY_PAYLOAD_BYTES = 8000
|
|
34
|
+
|
|
35
|
+
# Inverse of #envelope_for. A class method, not a private instance
|
|
36
|
+
# method like its encode-side counterpart: the subscriber Ractor
|
|
37
|
+
# below has no `self` bound to a PgFanout instance to call an
|
|
38
|
+
# ordinary method on, only whatever it can reach off the class
|
|
39
|
+
# constant (mirrors Monk::WebSocket::Server.serve being a class
|
|
40
|
+
# method for the equivalent reason -- the Server instance itself
|
|
41
|
+
# never crosses into a connection's Ractor either).
|
|
42
|
+
def self.decode_envelope(raw)
|
|
43
|
+
bytes = raw.b
|
|
44
|
+
origin_len_str, rest = bytes.split(":", 2)
|
|
45
|
+
origin_len = origin_len_str.to_i
|
|
46
|
+
origin = rest[0, origin_len]
|
|
47
|
+
rest = rest[origin_len..]
|
|
48
|
+
key_len_str, rest = rest.split(":", 2)
|
|
49
|
+
key_len = key_len_str.to_i
|
|
50
|
+
key = rest[0, key_len]
|
|
51
|
+
payload = rest[key_len..]
|
|
52
|
+
[origin, key, payload]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
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.
|
|
65
|
+
unless Ractor.shareable?(registry)
|
|
66
|
+
raise ArgumentError,
|
|
67
|
+
"Monk::WebSocket::PgFanout.new needs a Ractor-shareable registry, got #{registry.class}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
@registry = registry
|
|
71
|
+
# Ractor.make_shareable, not a plain #freeze: pg_opts's values
|
|
72
|
+
# (host/user/password Strings from ENV.fetch, typically) aren't
|
|
73
|
+
# frozen individually just because the Hash itself is -- #freeze
|
|
74
|
+
# only locks the Hash against further key/value changes, it
|
|
75
|
+
# doesn't recurse. Without this, the shareability check below
|
|
76
|
+
# fails on this Hash's contents, not on the registry argument it's
|
|
77
|
+
# meant to guard, the same trap Server#initialize's
|
|
78
|
+
# allowed_origins already avoids the same way.
|
|
79
|
+
@pg_opts = Ractor.make_shareable(pg_opts.dup)
|
|
80
|
+
# Frozen explicitly, not just a String literal: read from every
|
|
81
|
+
# calling Ractor's own #broadcast and from the subscriber Ractor
|
|
82
|
+
# below, both of which raise Ractor::IsolationError on an
|
|
83
|
+
# unfrozen value -- same reason RedisFanout freezes its own
|
|
84
|
+
# @origin.
|
|
85
|
+
@origin = SecureRandom.uuid.freeze
|
|
86
|
+
|
|
87
|
+
# Ractor.new's block args must be shareable: registry is
|
|
88
|
+
# (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}")
|
|
96
|
+
loop do
|
|
97
|
+
conn.wait_for_notify do |_channel, _pid, raw|
|
|
98
|
+
sender, key, payload = Monk::WebSocket::PgFanout.decode_envelope(raw)
|
|
99
|
+
next if sender == own_origin
|
|
100
|
+
|
|
101
|
+
registry.broadcast(key.to_sym, payload)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
freeze
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def register(key, port) = @registry.register(key, port)
|
|
110
|
+
def unregister(key, port) = @registry.unregister(key, port)
|
|
111
|
+
def count(key) = @registry.count(key)
|
|
112
|
+
|
|
113
|
+
def broadcast(key, payload)
|
|
114
|
+
# Delivers locally first, regardless of what happens next -- same
|
|
115
|
+
# latency and reliability as a plain Registry if Postgres is
|
|
116
|
+
# briefly unavailable or this payload is over the wire cap,
|
|
117
|
+
# mirroring RedisFanout#broadcast's own ordering.
|
|
118
|
+
@registry.broadcast(key, payload)
|
|
119
|
+
|
|
120
|
+
envelope = envelope_for(key, payload)
|
|
121
|
+
if envelope.bytesize >= MAX_NOTIFY_PAYLOAD_BYTES
|
|
122
|
+
raise Monk::WebSocket::PayloadTooLargeError,
|
|
123
|
+
"Monk::WebSocket::PgFanout#broadcast payload plus origin/key framing is " \
|
|
124
|
+
"#{envelope.bytesize} bytes; Postgres NOTIFY accepts a payload of at most " \
|
|
125
|
+
"#{MAX_NOTIFY_PAYLOAD_BYTES - 1} bytes -- shrink the fragment, or use " \
|
|
126
|
+
"Monk::WebSocket::RedisFanout instead, which has no such cap"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# pg_notify(channel, payload), not a string-built
|
|
130
|
+
# "NOTIFY channel, 'payload'" -- passing the envelope as a bound
|
|
131
|
+
# parameter sidesteps any quoting/injection concern for its bytes
|
|
132
|
+
# entirely, unlike Redis's PUBLISH, which never had that concern
|
|
133
|
+
# in the first place.
|
|
134
|
+
publisher.exec_params("SELECT pg_notify($1, $2)", [CHANNEL, envelope])
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# Length-prefixes the origin and key (Netstring-style: "<bytesize>:"
|
|
140
|
+
# then that many bytes) instead of RedisFanout's NUL-separated
|
|
141
|
+
# "origin\0payload" -- discovered while building this that a NUL
|
|
142
|
+
# byte can't ride in a Postgres NOTIFY payload at all (a bound text
|
|
143
|
+
# parameter containing one raises "string contains null byte" from
|
|
144
|
+
# inside exec_params; text NOTIFY payloads simply don't carry one).
|
|
145
|
+
# A digit run followed by ':' can never be confused with content
|
|
146
|
+
# coming later: the header is always the *first* ':' in the whole
|
|
147
|
+
# string, no matter what colons, NULs or anything else an app's key
|
|
148
|
+
# or payload contains.
|
|
149
|
+
#
|
|
150
|
+
# Forces binary (.b) throughout so slicing by the recorded byte
|
|
151
|
+
# lengths lines up exactly -- the same fix Monk::WebSocket::Frame.encode
|
|
152
|
+
# needed for non-ASCII payloads (docs/history/plan-live.md Phase 7);
|
|
153
|
+
# a String#[] by character count on a UTF-8 string would misalign
|
|
154
|
+
# the moment origin/key/payload aren't pure ASCII.
|
|
155
|
+
def envelope_for(key, payload)
|
|
156
|
+
origin_bytes = @origin.b
|
|
157
|
+
key_bytes = key.to_s.b
|
|
158
|
+
"#{origin_bytes.bytesize}:".b + origin_bytes + "#{key_bytes.bytesize}:".b + key_bytes + payload.to_s.b
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# One PG::Connection per calling Ractor, opened on first use and
|
|
162
|
+
# cached there -- a single client called concurrently from every
|
|
163
|
+
# connection Ractor's #broadcast wouldn't be safe, mirrors
|
|
164
|
+
# RedisFanout#publisher and Monk::Persistence::Pg's own per-Ractor
|
|
165
|
+
# pattern exactly. Never the connection Monk::Persistence::Pg hands
|
|
166
|
+
# out for app queries: NOTIFY issued on a connection mid-transaction
|
|
167
|
+
# wouldn't fire until that transaction commits, so this stays a
|
|
168
|
+
# connection of its own, used for nothing but one-off notifies.
|
|
169
|
+
def publisher
|
|
170
|
+
Ractor.current[:monk_pg_fanout_publisher] ||= PG.connect(**@pg_opts)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
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.
|
|
4
|
+
version: 0.16.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Matteo Folin
|
|
@@ -204,6 +204,7 @@ files:
|
|
|
204
204
|
- lib/monk/templates/live/bin/websocket_server
|
|
205
205
|
- lib/monk/templates/live/config.ru
|
|
206
206
|
- lib/monk/templates/live/config/live.rb
|
|
207
|
+
- lib/monk/templates/live/config/live_pg.rb
|
|
207
208
|
- lib/monk/templates/live/views/index.erb
|
|
208
209
|
- lib/monk/templates/live/views/live/_hits.erb
|
|
209
210
|
- lib/monk/templates/postgres/Dockerfile
|
|
@@ -220,6 +221,7 @@ files:
|
|
|
220
221
|
- lib/monk/websocket/errors.rb
|
|
221
222
|
- lib/monk/websocket/frame.rb
|
|
222
223
|
- lib/monk/websocket/handshake.rb
|
|
224
|
+
- lib/monk/websocket/pg_fanout.rb
|
|
223
225
|
- lib/monk/websocket/redis_fanout.rb
|
|
224
226
|
- lib/monk/websocket/registry.rb
|
|
225
227
|
- lib/monk/websocket/server.rb
|