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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7fc84b47997c81eb2748a3326ae5adba796ae90eb143d3c537b2f718d41b4a33
4
- data.tar.gz: 7f2072700db0787f7e9ee8bb0c28bf03e09b6194d2ad3c8aedcb89490c179ad8
3
+ metadata.gz: 7e6286fdc2af1003d176142f6d7b7410c1f924f78466f28ff1bd4b95709c6230
4
+ data.tar.gz: 1c5030467a67e80f57e4f10ad939c06392d5d80cff0cc732692589f207173a66
5
5
  SHA512:
6
- metadata.gz: f55f23d4954fec6400c1976a00f203bbd4b17df3ab1c0b0150051fd64a85db5e8e5f43229593a4d23333e56a4051fb39a45e2e9a9ce20dd0f3780101e918427a
7
- data.tar.gz: 94aad3a80714da837196a0c81d1191dd5c9e5490ebb7ed5094b2fb737dcb55615d4f187d13876acd125830ce14bf46ab9f6718654d7ff2b0435bfc108bd214ea
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"`; Redis fan-out: `require "monk/websocket/redis_fanout"` and the `redis` 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 | [`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`), and the RubyGems release as `monkrb` (both 2026-09-22).
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
- Implies --redis (bin/server and bin/websocket_server
57
- are separate processes, and Redis carries an update
58
- from one to the other). Adds config/live.rb,
59
- views/live/_hits.erb and the browser runtime under
60
- public/js/monk_live/, replaces config.ru, views/index.erb
61
- and bin/websocket_server with live versions, and adds
62
- the runtime's <meta>/<script> tags to the layout.
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
- puts " Monk::Live needs Redis running, bin/server and bin/websocket_server (see SETUP.md)" if live
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
@@ -34,4 +34,7 @@ module Monk
34
34
 
35
35
  class ScaffoldExistsError < StandardError
36
36
  end
37
+
38
+ class AmbiguousLiveTransportError < StandardError
39
+ end
37
40
  end
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: implies redis: -- bin/server and bin/websocket_server are separate
91
- # processes, so an update published by one only reaches a socket held by
92
- # the other through Redis.
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
- @redis = redis || live
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 update by
261
- themselves. It needs three things running, each in its own terminal:
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 and the subscribe rules (nothing
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.15.0".freeze
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).first
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.15.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