monkrb 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/README.md +2 -2
  4. data/lib/monk/live/client/monk_live.js +2 -2
  5. data/lib/monk/live/client/protocol.js +5 -0
  6. data/lib/monk/live.rb +10 -0
  7. data/lib/monk/scaffold.rb +152 -77
  8. data/lib/monk/templates/auth/app/mailers/app_mailer.rb +22 -0
  9. data/lib/monk/templates/auth/config/auth.rb +7 -20
  10. data/lib/monk/templates/base/app/app.rb +9 -0
  11. data/lib/monk/templates/base/app/broadcasts/.keep +0 -0
  12. data/lib/monk/templates/base/app/helpers/.keep +0 -0
  13. data/lib/monk/templates/base/app/jobs/.keep +0 -0
  14. data/lib/monk/templates/base/app/mailers/.keep +0 -0
  15. data/lib/monk/templates/base/app/models/.keep +0 -0
  16. data/lib/monk/templates/base/app/presenters/.keep +0 -0
  17. data/lib/monk/templates/base/bin/websocket_server +6 -0
  18. data/lib/monk/templates/base/config/load.rb +18 -0
  19. data/lib/monk/templates/base/config.ru +3 -12
  20. data/lib/monk/templates/base/public/js/app.js +1 -1
  21. data/lib/monk/templates/jobs/{jobs → app/jobs}/send_login_link.rb +2 -1
  22. data/lib/monk/templates/jobs/bin/jobs +5 -13
  23. data/lib/monk/templates/jobs/config/jobs.rb +2 -4
  24. data/lib/monk/templates/live/{config.ru → app/app.rb} +1 -6
  25. data/lib/monk/templates/live/{views → app/views}/index.erb +2 -2
  26. data/lib/monk/templates/live/bin/websocket_server +14 -2
  27. data/lib/monk/templates/live/config/live.rb +1 -1
  28. data/lib/monk/templates/live/config/live_pg.rb +1 -1
  29. data/lib/monk/templates/mail/config/mail.rb +2 -2
  30. data/lib/monk/templates/postgres/bin/console +4 -2
  31. data/lib/monk/version.rb +1 -1
  32. data/lib/monk/websocket/errors.rb +11 -0
  33. data/lib/monk/websocket/listeners.rb +23 -0
  34. data/lib/monk/websocket/pg_fanout.rb +55 -20
  35. data/lib/monk/websocket/redis_fanout.rb +57 -14
  36. data/lib/monk/websocket/registry.rb +6 -0
  37. data/lib/monk/websocket/server.rb +77 -17
  38. metadata +19 -9
  39. /data/lib/monk/templates/auth/{views → app/views}/mail/magic_link.erb +0 -0
  40. /data/lib/monk/templates/base/{views → app/views}/index.erb +0 -0
  41. /data/lib/monk/templates/base/{views → app/views}/layouts/app.erb +0 -0
  42. /data/lib/monk/templates/jobs/{jobs → app/jobs}/hello_job.rb +0 -0
  43. /data/lib/monk/templates/live/{views → app/views}/live/_hits.erb +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e8e07694f006907b83202dbafa0dcbfd61d31e1c1f90b1012597dada8eb24c3e
4
- data.tar.gz: cbadf97dbb1fb1063332c7046afcc96fd526e7c483c0bcc8a5873f1b94bb2636
3
+ metadata.gz: 5745b05b44d3f2953a037262d97c7a211ebea46b72f9872e6d626c58886338bf
4
+ data.tar.gz: f6a970fb8f3e724e992238c7c89f230e215cba4b9d36aadc6122bab452b36ea5
5
5
  SHA512:
6
- metadata.gz: f44edaf6f9f80765fa51841d068b920f880fc6855fd702cbff242a51423ca4f7206452b9f4899a6ca5340b2ac228fd8604c287e1dad82d9f122ea6ac26304b2d
7
- data.tar.gz: 1dc1579210fbbd2c211a590eb50ccabf9c385b7e2d8362b723c0cbe1edd55ad64bfd886d8948725d4514b84afee274144fd25790dbf554b82a0372072319e33d
6
+ metadata.gz: 9f16caa7f5d287d95cf1adaf65f72a1da511f9a02671ab06ac51634c4f2a44af2ae76c232b7931ec6118f461116f9fc0ce89d08a56bd1e23eeb8f076f4ff834e
7
+ data.tar.gz: 8083b153954fee1475e08b778772bdb8592ff597feae9367bf6d589bf22c5859792ea3bf9bc74e5c1baef0567568ee5302b87d1e82addc65a5703081988b7ec7
data/CHANGELOG.md CHANGED
@@ -4,6 +4,83 @@ 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.19.0 - 2026-10-01
8
+
9
+ ### Fixed
10
+
11
+ - `Monk::WebSocket::Server#run` stops on `TERM` as it does on Ctrl-C: it
12
+ stops accepting and returns, so `bin/websocket_server` exits 0 on
13
+ `docker stop` and deploys instead of being ended by the signal (exit
14
+ 143). Open sockets are still dropped without a close frame; see
15
+ `docs/guides/websocket.md`, "Stopping and restarting", which also warns
16
+ what other clients have to handle.
17
+ - `monk_live.js` jitters its reconnect delay (50%–150% of the backoff), so
18
+ the pages that lose their socket together on a WebSocket restart don't
19
+ all reconnect and refetch at once. An existing app has a copy of the
20
+ client in `public/js/monk_live/`: copy `lib/monk/live/client/` over it
21
+ again to get this.
22
+
23
+ ### Added
24
+
25
+ - `Monk::WebSocket::Server.new(authenticate: :optional)`: a connection
26
+ with a valid session gets its subject, and one without (none, revoked or
27
+ expired) comes in anonymous instead of getting a `401`. The handler
28
+ decides what an anonymous connection may do; under `Monk::Live`, the
29
+ topic rules (`anonymous: true`). The `Origin` check covers cookies, as
30
+ under `true`, and anonymous connections that send an `Origin`.
31
+ `reverify_interval:` works with it, for connections that have a session.
32
+ `monk new --auth --live`'s `bin/websocket_server` uses it, so the demo
33
+ updates for visitors too, and SETUP.md explains who gets which topics.
34
+ The plain chat `bin/websocket_server` keeps `true`. See
35
+ `docs/design/websocket.md`, "Anonymous connections".
36
+
37
+ ### Changed
38
+
39
+ - `monk new` lays the app out under `app/`, by role (ADR 0015):
40
+ `app/app.rb` holds `class App` and its routes, templates are in
41
+ `app/views/`, and every role directory is created with a `.keep`:
42
+ `app/models`, `presenters`, `helpers`, `mailers`, `broadcasts`, `jobs`.
43
+ `config.ru` is the same three lines for every flag set, and the new
44
+ `config/load.rb` requires the settings and each enabled module's config,
45
+ then loads `app/` by role. It never boots the app. `bin/jobs`,
46
+ `bin/console` and the generated test helper require it too. `--auth`'s
47
+ magic-link sender moves from `config/auth.rb` to
48
+ `app/mailers/app_mailer.rb` and is renamed `AppMailer::MAGIC_LINK`;
49
+ `config/auth.rb` requires that file itself. `config/jobs.rb` no longer
50
+ loads the job classes. The generated SETUP.md's test helper loads and
51
+ boots the app, with a sample request test. `docs/guides/scaffolding.md`,
52
+ "Where code goes", says what goes in each directory.
53
+ **An 0.18 app keeps working as it is.** To move it to the new layout:
54
+ 1. `git mv views app/views` and `git mv jobs app/jobs`; create the other
55
+ role directories you want.
56
+ 2. Move `class App` and its routes from `config.ru` into `app/app.rb`,
57
+ and change `views "views"` to `views "app/views"`.
58
+ 3. Create `config/load.rb`: the `require_relative` lines `config.ru` had
59
+ for `config/*` (without the `config/` prefix), then the loop over
60
+ `app/` roles from a freshly generated one. Make `config.ru`
61
+ `require_relative "config/load"`, `require_relative "app/app"`,
62
+ `run Monk.boot(App)`.
63
+ 4. Remove the `Dir[...]` loop from `config/jobs.rb`. In `bin/jobs`,
64
+ replace its config requires with `require_relative "../config/load"`
65
+ and point `Monk::Views.root` at `../app/views`. Do the same in
66
+ `bin/console`.
67
+ 5. Optional: move `AppMailer` from `config/auth.rb` into
68
+ `app/mailers/app_mailer.rb` and `require_relative` it from
69
+ `config/auth.rb`.
70
+ - **Breaking:** `Monk::WebSocket::RedisFanout` and `PgFanout` no longer
71
+ subscribe when built. The process that holds sockets calls `#listen!`
72
+ once at boot: `Monk::Live.listen!`, or `REGISTRY.listen!` for plain
73
+ `Monk::WebSocket`. Publish-only processes (`bin/server`, `bin/jobs`,
74
+ `bin/console`) then hold no idle subscriber connection. `#listen!`
75
+ returns once the subscription is in effect and raises
76
+ `Monk::WebSocket::ListenError` if Redis or Postgres can't be reached.
77
+ `#register` before `#listen!` raises
78
+ `Monk::WebSocket::NotListeningError`. `Registry#listen!` is a no-op, so
79
+ the same line works with either. The scaffolded `bin/websocket_server`
80
+ calls it. **To upgrade**, add `Monk::Live.listen!` (or
81
+ `REGISTRY.listen!`) to your `bin/websocket_server` before
82
+ `server.run`. See `docs/design/websocket.md`, "Who listens".
83
+
7
84
  ## 0.18.0 - 2026-09-29
8
85
 
9
86
  ### Added
data/README.md CHANGED
@@ -22,7 +22,7 @@ bin/server # -> http://localhost:9292/hello
22
22
 
23
23
  `monk new` scaffolds the app's own `Gemfile` with `gem "monkrb", require: "monk"`.
24
24
 
25
- `monk new` writes a working skeleton (an HTML home page, a `/hello` route, a `/api/hello` JSON route, `views/`, `public/`, a `SETUP.md`). Flags add Postgres, auth, email, background jobs, Redis and live updates: see [`docs/guides/scaffolding.md`](docs/guides/scaffolding.md).
25
+ `monk new` writes a working skeleton (an HTML home page, a `/hello` route, a `/api/hello` JSON route, `public/`, a `SETUP.md`), with the app's own code under `app/`: `app/app.rb` for routes, `app/views/`, and one directory per role (`models/`, `presenters/`, `helpers/`, `mailers/`, `broadcasts/`, `jobs/`). Flags add Postgres, auth, email, background jobs, Redis and live updates: see [`docs/guides/scaffolding.md`](docs/guides/scaffolding.md), which also says where each kind of code goes.
26
26
 
27
27
  All `monk` commands and flags are listed by:
28
28
 
@@ -87,6 +87,6 @@ More (server modes, Docker) in [`docs/development.md`](docs/development.md).
87
87
 
88
88
  ## Status
89
89
 
90
- 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).
90
+ 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), the built-in mailer `Monk::Mail` (0.17, 2026-09-26, ADR 0012), and background jobs on Postgres, `Monk::Jobs`, with mail sent from jobs (0.18, 2026-09-29, ADRs 0013 and 0014). In 0.19 (2026-10-01): `monk new` lays the app out under `app/`, one directory per role (ADR 0015); a WebSocket fanout listens only in the process that holds the sockets (`#listen!`); `authenticate: :optional` lets visitors' sockets in anonymously, for Live's rules to decide; and the WebSocket server stops cleanly on `TERM` (`docs/design/websocket.md`).
91
91
 
92
92
  [Made with Love ❤️, Ruby 💎 and AI 🤖](docs/ai_usage_disclaimer.md)
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { Idiomorph } from "./idiomorph.js";
13
13
  import {
14
- INITIAL_DELAY, nextDelay, topicsFrom, diffTopics, subscribeMessage, unsubscribeMessage, parseMessage, checkSeq, resyncVerdict,
14
+ INITIAL_DELAY, nextDelay, jittered, topicsFrom, diffTopics, subscribeMessage, unsubscribeMessage, parseMessage, checkSeq, resyncVerdict,
15
15
  } from "./protocol.js";
16
16
 
17
17
  const emit = (name, detail = {}) => document.dispatchEvent(new CustomEvent(`monk-live:${name}`, { detail }));
@@ -185,7 +185,7 @@ function connect() {
185
185
  socket.onclose = () => {
186
186
  emit("disconnected");
187
187
  if (stopped) return;
188
- setTimeout(connect, delay);
188
+ setTimeout(connect, jittered(delay));
189
189
  delay = nextDelay(delay);
190
190
  };
191
191
  }
@@ -8,6 +8,11 @@ export const MAX_DELAY = 30000;
8
8
  // Reconnect backoff: double, capped.
9
9
  export const nextDelay = (delay) => Math.min(delay * 2, MAX_DELAY);
10
10
 
11
+ // The wait actually used: 50%..150% of the backoff delay. When the WS process
12
+ // restarts, every open page loses its socket at once; without jitter they all
13
+ // reconnect, and all refetch their page (resync), at the same instant.
14
+ export const jittered = (delay, random = Math.random) => Math.floor(delay * (0.5 + random()));
15
+
11
16
  const MODES = ["morph", "replace", "append", "prepend", "remove"];
12
17
 
13
18
  // data-live-topic values ("a:1 b:2") -> a sorted, de-duplicated topic list.
data/lib/monk/live.rb CHANGED
@@ -62,6 +62,16 @@ module Monk
62
62
  @max_topics = max_topics
63
63
  end
64
64
 
65
+ # Boot-time, main Ractor, in the WS process only (bin/websocket_server):
66
+ # starts the configured registry relaying other processes' updates
67
+ # to this process's sockets. A publish-only process (bin/server,
68
+ # bin/jobs) never calls it, so it opens no subscriber connection.
69
+ def listen!
70
+ registry || raise(NotConfiguredError,
71
+ "Monk::Live isn't configured -- call Monk::Live.configure(registry: ...) before listen!",)
72
+ registry.listen!
73
+ end
74
+
65
75
  # Boot-time, main Ractor. See Monk::Live::Policy for the semantics:
66
76
  # deny by default, first matching rule wins, anonymous subjects need
67
77
  # `anonymous: true`. The block must be Ractor-shareable.
data/lib/monk/scaffold.rb CHANGED
@@ -10,18 +10,26 @@ module Monk
10
10
  class Scaffold
11
11
  TEMPLATES_DIR = File.expand_path("templates", __dir__)
12
12
 
13
+ # The role directories under app/, in config/load.rb's load order.
14
+ APP_ROLES = %w[models presenters helpers mailers broadcasts jobs].freeze
15
+
13
16
  BASE_FILES = {
14
17
  "Gemfile" => "base/Gemfile",
15
18
  "config.ru" => "base/config.ru",
16
19
  "config/settings.rb" => "base/config/settings.rb",
20
+ "config/load.rb" => "base/config/load.rb",
21
+ "app/app.rb" => "base/app/app.rb",
17
22
  ".ruby-version" => "base/.ruby-version",
18
23
  ".gitignore" => "base/.gitignore",
19
24
  ".dockerignore" => "base/.dockerignore",
20
25
  "Dockerfile" => "base/Dockerfile",
21
26
  "bin/server" => "base/bin/server",
22
27
  "bin/websocket_server" => "base/bin/websocket_server",
23
- "views/layouts/app.erb" => "base/views/layouts/app.erb",
24
- "views/index.erb" => "base/views/index.erb",
28
+ "app/views/layouts/app.erb" => "base/app/views/layouts/app.erb",
29
+ "app/views/index.erb" => "base/app/views/index.erb",
30
+ # Every role directory, whatever the flags (docs/adr/0015): the tree
31
+ # shows where each kind of code goes. Flags add files next to these.
32
+ **APP_ROLES.to_h { |role| ["app/#{role}/.keep", "base/app/#{role}/.keep"] },
25
33
  "public/css/app.css" => "base/public/css/app.css",
26
34
  "public/js/app.js" => "base/public/js/app.js",
27
35
  }.freeze
@@ -45,9 +53,11 @@ module Monk
45
53
  "config/auth.rb" => "auth/config/auth.rb",
46
54
  "db/migrate/00000000000001_create_auth_tables.up.sql" => "auth/db/migrate/00000000000001_create_auth_tables.up.sql",
47
55
  "db/migrate/00000000000001_create_auth_tables.down.sql" => "auth/db/migrate/00000000000001_create_auth_tables.down.sql",
48
- # The magic link's HTML part -- config/auth.rb's AppMailer::DELIVER
49
- # renders it and sends through Monk::Mail (MAIL_FILES, implied by --auth).
50
- "views/mail/magic_link.erb" => "auth/views/mail/magic_link.erb",
56
+ # The magic link's sender, Monk::Auth's deliver: hook -- config/auth.rb
57
+ # requires it -- and the HTML part it renders and sends through
58
+ # Monk::Mail (MAIL_FILES, implied by --auth).
59
+ "app/mailers/app_mailer.rb" => "auth/app/mailers/app_mailer.rb",
60
+ "app/views/mail/magic_link.erb" => "auth/app/views/mail/magic_link.erb",
51
61
  }.freeze
52
62
 
53
63
  # --mail, or implied by --auth: Monk::Mail's config. Its Gemfile line
@@ -61,7 +71,7 @@ module Monk
61
71
  # numbered after auth's so the two sort in order when both are present.
62
72
  JOBS_FILES = {
63
73
  "config/jobs.rb" => "jobs/config/jobs.rb",
64
- "jobs/hello_job.rb" => "jobs/jobs/hello_job.rb",
74
+ "app/jobs/hello_job.rb" => "jobs/app/jobs/hello_job.rb",
65
75
  "bin/jobs" => "jobs/bin/jobs",
66
76
  "db/migrate/00000000000002_create_jobs_tables.up.sql" => "jobs/db/migrate/00000000000002_create_jobs_tables.up.sql",
67
77
  "db/migrate/00000000000002_create_jobs_tables.down.sql" =>
@@ -71,7 +81,7 @@ module Monk
71
81
  # --auth --jobs: the magic link is created and sent inside a job, so the
72
82
  # raw token is never stored (docs/adr/0014).
73
83
  JOBS_AUTH_FILES = {
74
- "jobs/send_login_link.rb" => "jobs/jobs/send_login_link.rb",
84
+ "app/jobs/send_login_link.rb" => "jobs/app/jobs/send_login_link.rb",
75
85
  }.freeze
76
86
 
77
87
  # --mail --jobs (or --auth --jobs): Monk::Mail.deliver_later, loaded in
@@ -79,34 +89,34 @@ module Monk
79
89
  JOBS_REQUIRE_ANCHOR = %(require "monk/jobs"\n).freeze
80
90
  JOBS_MAIL_REQUIRE = %(require "monk/mail/later"\n).freeze
81
91
 
82
- # --jobs's demo route, added right after this line in config.ru (the
92
+ # --jobs's demo route, added right after this line in app/app.rb (the
83
93
  # base and the --live one both end their routes with it), so the
84
94
  # round trip -- enqueue from a request, run in bin/jobs -- works out of
85
95
  # the box.
86
96
  JOBS_ROUTE_ANCHOR = %( get("/api/hello") { json(message: "hello from monk") }\n).freeze
87
97
  JOBS_ROUTE = <<~RUBY.gsub(/^(?!$)/, " ").freeze
88
98
 
89
- # Enqueues the demo job (jobs/hello_job.rb) for bin/jobs to run:
99
+ # Enqueues the demo job (app/jobs/hello_job.rb) for bin/jobs to run:
90
100
  # curl -X POST "http://localhost:9292/jobs/hello?name=Ann"
91
101
  post("/jobs/hello") { json(enqueued: HelloJob.enqueue(params[:name] || "world")) }
92
102
  RUBY
93
103
 
94
104
  # --live: Monk::Live's demo (a counter whose open tabs update when
95
105
  # another request changes it). These replace three base files outright
96
- # rather than patching them -- a live config.ru and index are different
97
- # files, not one line of diff -- and add two. config.ru's first line stays
98
- # the settings require, so --postgres/--auth wiring still finds it.
106
+ # rather than patching them -- a live app and index are different
107
+ # files, not one line of diff -- and add two. config.ru stays the base
108
+ # one: config/load.rb is where --live's config gets wired in.
99
109
  LIVE_OVERRIDES = {
100
- "config.ru" => "live/config.ru",
110
+ "app/app.rb" => "live/app/app.rb",
101
111
  "bin/websocket_server" => "live/bin/websocket_server",
102
- "views/index.erb" => "live/views/index.erb",
112
+ "app/views/index.erb" => "live/app/views/index.erb",
103
113
  }.freeze
104
114
 
105
115
  # config/live.rb itself isn't here -- #write_live! picks between
106
116
  # LIVE_CONFIG_TEMPLATES[@live_transport] instead, since which one gets
107
117
  # written depends on --redis vs --postgres, not a fixed mapping.
108
118
  LIVE_FILES = {
109
- "views/live/_hits.erb" => "live/views/live/_hits.erb",
119
+ "app/views/live/_hits.erb" => "live/app/views/live/_hits.erb",
110
120
  }.freeze
111
121
 
112
122
  LIVE_CONFIG_TEMPLATES = {
@@ -235,7 +245,7 @@ module Monk
235
245
  add_deliver_later! if @mail
236
246
  end
237
247
 
238
- wire_config_ru! if @postgres || @mail
248
+ wire_load! if @postgres || @mail || @live
239
249
  append_gemfile_extra("redis/Gemfile.extra") if @redis
240
250
 
241
251
  # --postgres, --redis and --mail are the flags with anything worth
@@ -272,7 +282,7 @@ module Monk
272
282
 
273
283
  def combinations
274
284
  pairs = []
275
- pairs << ["--auth + --jobs", "jobs/send_login_link.rb: login links are sent from a job"] if @auth && @jobs
285
+ pairs << ["--auth + --jobs", "app/jobs/send_login_link.rb: login links are sent from a job"] if @auth && @jobs
276
286
  if @mail && @jobs
277
287
  pairs << ["--mail + --jobs",
278
288
  "config/jobs.rb loads Monk::Mail.deliver_later; JOBS_QUEUES serves mailers first",]
@@ -307,7 +317,7 @@ module Monk
307
317
  end
308
318
 
309
319
  def add_live_to_layout!
310
- path = File.join(@dir, "views/layouts/app.erb")
320
+ path = File.join(@dir, "app/views/layouts/app.erb")
311
321
  content = File.read(path)
312
322
  raise "layout wiring failed: </head> not found" unless content.include?(" </head>\n")
313
323
 
@@ -337,36 +347,37 @@ module Monk
337
347
  File.write(path, content.sub(DOTENV_COMMENTED_LINE, DOTENV_LINE))
338
348
  end
339
349
 
340
- # config.ru ships in BASE_FILES unconditionally (it has to -- it's the
341
- # only rackup entrypoint), so --postgres/--auth can't gate whether it
342
- # exists, only what it requires. Unlike BASE_FILES/POSTGRES_FILES/
350
+ # config/load.rb ships in BASE_FILES unconditionally (config.ru and the
351
+ # tests require it), so the flags can't gate whether it exists, only
352
+ # which module configs it requires. Unlike BASE_FILES/POSTGRES_FILES/
343
353
  # AUTH_FILES, this is a post-write edit rather than a verbatim copy --
344
- # the alternative (a second, fuller config.ru template per flag
345
- # combination) would duplicate the whole file for one line of diff.
346
- def wire_config_ru!
347
- path = File.join(@dir, "config.ru")
348
- settings_require = %(require_relative "config/settings"\n)
354
+ # the alternative (a second, fuller load.rb template per flag
355
+ # combination) would duplicate the whole file for a few lines of diff.
356
+ def wire_load!
357
+ path = File.join(@dir, "config/load.rb")
358
+ settings_require = %(require_relative "settings"\n)
349
359
  requires = +""
350
360
  if @postgres
351
- target = @auth ? "config/auth" : "config/persistence" # config/auth.rb itself require_relative "persistence"
361
+ target = @auth ? "auth" : "persistence" # config/auth.rb itself require_relative "persistence"
352
362
  requires << "require_relative \"#{target}\"\n"
353
363
  end
354
- # config/mail.rb from config.ru only, never from config/auth.rb:
364
+ # config/mail.rb from config/load.rb only, never from config/auth.rb:
355
365
  # bin/websocket_server loads config/auth.rb too and never sends mail,
356
366
  # so it shouldn't need MAIL_URL to boot.
357
- requires << "require_relative \"config/mail\"\n" if @mail
358
- requires << "require_relative \"config/jobs\"\n" if @jobs
367
+ requires << "require_relative \"mail\"\n" if @mail
368
+ requires << "require_relative \"jobs\"\n" if @jobs
369
+ requires << "require_relative \"live\"\n" if @live
359
370
 
360
371
  content = File.read(path)
361
- raise "config.ru wiring failed: #{settings_require.inspect} not found" unless content.include?(settings_require)
372
+ raise "config/load.rb wiring failed: #{settings_require.inspect} not found" unless content.include?(settings_require)
362
373
 
363
374
  File.write(path, content.sub(settings_require, "#{settings_require}#{requires}"))
364
375
  end
365
376
 
366
377
  def add_jobs_route!
367
- path = File.join(@dir, "config.ru")
378
+ path = File.join(@dir, "app/app.rb")
368
379
  content = File.read(path)
369
- raise "config.ru wiring failed: #{JOBS_ROUTE_ANCHOR.inspect} not found" unless content.include?(JOBS_ROUTE_ANCHOR)
380
+ raise "app/app.rb wiring failed: #{JOBS_ROUTE_ANCHOR.inspect} not found" unless content.include?(JOBS_ROUTE_ANCHOR)
370
381
 
371
382
  File.write(path, content.sub(JOBS_ROUTE_ANCHOR, "#{JOBS_ROUTE_ANCHOR}#{JOBS_ROUTE}"))
372
383
  end
@@ -419,12 +430,16 @@ module Monk
419
430
  [dev, example].each { |lines| lines.push("JOBS_WORKERS=2", "JOBS_QUEUES=#{queues}") }
420
431
  end
421
432
 
422
- # REDIS_URL is deliberately absent from .env.test -- only a test that
423
- # actually exercises RedisFanout needs it, unlike DB_NAME/AUTH_SECRET
424
- # which every test touching persistence/auth needs.
433
+ # REDIS_URL is absent from .env.test for --redis alone -- only a test
434
+ # that actually exercises RedisFanout needs it. --live --redis is
435
+ # different: config/live.rb raises without it, and the test helper
436
+ # loads config/live.rb through config/load.rb. Building the fanout
437
+ # connects to nothing, so tests still need no Redis running unless
438
+ # one publishes.
425
439
  if @redis
426
440
  dev << "REDIS_URL=redis://localhost:6379/0"
427
441
  example << "REDIS_URL=redis://localhost:6379/0"
442
+ test << "REDIS_URL=redis://localhost:6379/0" if live_over_redis?
428
443
  end
429
444
 
430
445
  write_lines(".env", dev)
@@ -480,17 +495,21 @@ module Monk
480
495
 
481
496
  - `config/live.rb` -- the Redis wiring (`Monk::WebSocket::RedisFanout`)
482
497
  and the subscribe rules (nothing is allowed unless a rule says so).
483
- - `config.ru` -- `POST /hit` changes state and calls `Monk::Live.patch`.
484
- - `views/index.erb` -- `live_topic "hits"` marks what to subscribe to.
485
- - `views/live/_hits.erb` -- the fragment that gets pushed. Partials
498
+ - `app/app.rb` -- `POST /hit` changes state and calls `Monk::Live.patch`.
499
+ - `app/views/index.erb` -- `live_topic "hits"` marks what to subscribe to.
500
+ - `app/views/live/_hits.erb` -- the fragment that gets pushed. Partials
486
501
  used this way see only their locals (`locals[:hits]`), never
487
502
  `params` or the session.
503
+ - `bin/websocket_server` -- calls `Monk::Live.listen!` at boot: the
504
+ only process that listens for updates. `bin/server`#{@jobs ? " and `bin/jobs`" : ""} only
505
+ publish.
488
506
  - `public/js/monk_live/` -- the browser runtime, copied from the gem.
489
507
  The layout points at it and at `LIVE_WS_URL` (default
490
508
  `ws://localhost:9293`; use `wss://` in production).
491
509
 
492
510
  `WS_ALLOWED_ORIGINS` (default `http://localhost:9292`) must list the
493
511
  origin your pages are served from, or the browser's socket is refused.
512
+ #{live_auth_setup_paragraph}
494
513
  MARKDOWN
495
514
  end
496
515
 
@@ -517,18 +536,21 @@ module Monk
517
536
 
518
537
  - `config/live.rb` -- the Postgres wiring (`Monk::WebSocket::PgFanout`)
519
538
  and the subscribe rules (nothing is allowed unless a rule says so).
520
- - `config.ru` -- `POST /hit` changes state and calls `Monk::Live.patch`.
521
- - `views/index.erb` -- `live_topic "hits"` marks what to subscribe to.
522
- - `views/live/_hits.erb` -- the fragment that gets pushed. Partials
539
+ - `app/app.rb` -- `POST /hit` changes state and calls `Monk::Live.patch`.
540
+ - `app/views/index.erb` -- `live_topic "hits"` marks what to subscribe to.
541
+ - `app/views/live/_hits.erb` -- the fragment that gets pushed. Partials
523
542
  used this way see only their locals (`locals[:hits]`), never
524
543
  `params` or the session.
544
+ - `bin/websocket_server` -- calls `Monk::Live.listen!` at boot: the
545
+ only process that listens for updates. `bin/server`#{@jobs ? " and `bin/jobs`" : ""} only
546
+ publish.
525
547
  - `public/js/monk_live/` -- the browser runtime, copied from the gem.
526
548
  The layout points at it and at `LIVE_WS_URL` (default
527
549
  `ws://localhost:9293`; use `wss://` in production).
528
550
 
529
551
  `WS_ALLOWED_ORIGINS` (default `http://localhost:9292`) must list the
530
552
  origin your pages are served from, or the browser's socket is refused.
531
-
553
+ #{live_auth_setup_paragraph}
532
554
  A single broadcast is capped at just under 8000 bytes
533
555
  (`Monk::WebSocket::PgFanout::MAX_NOTIFY_PAYLOAD_BYTES`, Postgres's own
534
556
  `NOTIFY` limit) -- far more than this demo ever sends, but worth
@@ -583,8 +605,7 @@ module Monk
583
605
 
584
606
  ENV["MONK_ENV"] ||= "test"
585
607
  #{base_test_dotenv_lines}
586
- require "minitest/autorun"
587
- require_relative "../config/settings"#{%(\nrequire_relative "../config/mail") if @mail}
608
+ #{test_helper_app_lines}
588
609
  ```
589
610
 
590
611
  **Rakefile**:
@@ -600,22 +621,7 @@ module Monk
600
621
  task default: :test
601
622
  ```
602
623
 
603
- **test/settings_test.rb** -- `config.ru`'s `class App` lives inline in a
604
- rackup file, not a plain `.rb` a test could `require_relative`, so this
605
- starts with what's actually requirable standalone. Extract `App` into
606
- its own file (`require_relative`d from both `config.ru` and
607
- `test/test_helper.rb`) once there's real app behavior worth testing
608
- against requests:
609
-
610
- ```ruby
611
- require_relative "test_helper"
612
-
613
- class SettingsTest < Minitest::Test
614
- def test_monk_env_reads_as_test
615
- assert_equal "test", Monk::Settings[:monk_env]
616
- end
617
- end
618
- ```
624
+ #{app_test_sample}
619
625
 
620
626
  Then:
621
627
 
@@ -656,8 +662,9 @@ module Monk
656
662
  <<~MARKDOWN
657
663
  # Setting up #{app_name} (dev, then test)
658
664
 
659
- `config.ru`, `.env`, and `.env.test` are already wired up by `monk new`
660
- -- `config.ru` requires `config/#{@auth ? "auth" : "persistence"}` before `class App`, and
665
+ `config/load.rb`, `.env`, and `.env.test` are already wired up by `monk new`
666
+ -- `config/load.rb` requires `config/#{@auth ? "auth" : "persistence"}` (and every other config
667
+ you enabled), then loads `app/`, and
661
668
  `.env`/`.env.test` are pre-filled with a database name derived from
662
669
  this project's directory (`#{app_name}_development` / `#{app_name}_test`), not the
663
670
  generic `app_development` fallback baked into `config/persistence.rb`
@@ -719,7 +726,7 @@ module Monk
719
726
  ```bash
720
727
  bin/server # HTTP app on :9292
721
728
  bin/websocket_server # WS chat process on :9293, in another terminal
722
- #{"bin/jobs # background jobs (jobs/), in a third terminal\n" if @jobs}```
729
+ #{"bin/jobs # background jobs (app/jobs/), in a third terminal\n" if @jobs}```
723
730
  #{auth_or_redis_confirmation_note}#{mail_setup_note}#{jobs_setup_note}
724
731
  ## Test environment
725
732
 
@@ -755,7 +762,7 @@ module Monk
755
762
 
756
763
  **test/test_helper.rb** -- loads `.env.test` explicitly (not the default
757
764
  dotenv-in-`config/settings.rb` path, which only loads plain `.env`), then
758
- wires up the app config the same way `config.ru` does:
765
+ loads and boots the app the same way `config.ru` does:
759
766
 
760
767
  ```ruby
761
768
  $LOAD_PATH.unshift(File.expand_path("..", __dir__))
@@ -765,9 +772,7 @@ module Monk
765
772
  require "dotenv"
766
773
  Dotenv.load(File.expand_path(".env.test", __dir__ + "/.."))
767
774
 
768
- require "minitest/autorun"
769
- require_relative "../config/settings"
770
- require_relative "../config/#{@auth ? "auth" : "persistence"}"#{%(\nrequire_relative "../config/mail") if @mail}#{%(\nrequire_relative "../config/jobs") if @jobs}
775
+ #{test_helper_app_lines}
771
776
  ```
772
777
 
773
778
  **Rakefile**:
@@ -793,6 +798,8 @@ module Monk
793
798
  #{sample_test_body}
794
799
  end
795
800
  ```
801
+
802
+ #{app_test_sample}
796
803
  #{jobs_test_sample}
797
804
  Then:
798
805
 
@@ -811,6 +818,7 @@ module Monk
811
818
  end
812
819
 
813
820
  def auth_or_redis_confirmation_note
821
+ return live_auth_confirmation_note if @live
814
822
  return "" unless @auth || @redis
815
823
 
816
824
  flags = [("authenticate: true" if @auth), ("redis fan-out: on" if @redis)].compact.join(", ")
@@ -824,6 +832,34 @@ module Monk
824
832
  "#{visible} visible to it -- that confirms everything's actually wired up.\n"
825
833
  end
826
834
 
835
+ # Who gets which topics once there are users. Interpolated into a
836
+ # squiggly heredoc, so no indentation of its own.
837
+ def live_auth_setup_paragraph
838
+ return "" unless @auth
839
+
840
+ <<~MARKDOWN
841
+
842
+ **Visitors and logged-in users.** A logged-in user's socket carries
843
+ their identity (the session cookie reaches `:9293` too); a visitor's
844
+ comes in anonymous. Each topic's rule in `config/live.rb` decides:
845
+ `anonymous: true` opens it to visitors (the demo's `hits`), and a
846
+ rule without it is for logged-in users only, e.g. one topic per user.
847
+ `monk new` writes no login routes: see `docs/guides/auth.md` in the
848
+ monk repo, and `docs/guides/live.md`, "Who may subscribe".
849
+ MARKDOWN
850
+ end
851
+
852
+ # The --live bin/websocket_server prints only `authenticate:`, and with
853
+ # --auth it's :optional (a visitor's socket is anonymous, not refused).
854
+ def live_auth_confirmation_note
855
+ return "" unless @auth
856
+
857
+ "\n`bin/websocket_server`'s startup line should print `authenticate: optional` once " \
858
+ "`AUTH_SECRET` is visible to it: a logged-in user's socket carries their identity, and a " \
859
+ "visitor's comes in anonymous, so it gets only the topics `config/live.rb` opens with " \
860
+ "`anonymous: true`.\n"
861
+ end
862
+
827
863
  # Interpolated into a squiggly heredoc, so no leading indentation of
828
864
  # its own (interpolated text isn't dedented).
829
865
  def mail_setup_note
@@ -854,9 +890,9 @@ module Monk
854
890
 
855
891
  `config/jobs.rb` configures `Monk::Jobs` on the app's own database --
856
892
  its tables come from `db/migrate/00000000000002_create_jobs_tables`,
857
- which `bin/setup_db` already applied -- and loads the job classes in
858
- `jobs/`. With `bin/server` and `bin/jobs` both running, enqueue the
859
- demo job and watch it run:
893
+ which `bin/setup_db` already applied. The job classes live in
894
+ `app/jobs/`, which `config/load.rb` loads. With `bin/server` and
895
+ `bin/jobs` both running, enqueue the demo job and watch it run:
860
896
 
861
897
  ```bash
862
898
  curl -X POST "http://localhost:9292/jobs/hello?name=Ann"
@@ -892,7 +928,7 @@ module Monk
892
928
 
893
929
  <<~MARKDOWN.chomp
894
930
 
895
- Magic links go through a job too, `jobs/send_login_link.rb`: it
931
+ Magic links go through a job too, `app/jobs/send_login_link.rb`: it
896
932
  creates the token and sends the link inside the job, so the raw
897
933
  token is never stored, not even in the queue. Your login route,
898
934
  after its own per-email rate limit, just enqueues it:
@@ -939,11 +975,12 @@ module Monk
939
975
  end
940
976
 
941
977
  # The no-Postgres test helper normally has nothing to load from
942
- # .env.test -- but with --mail it has MAIL_URL=log://, which tests need:
943
- # they boot outside development, where an unset MAIL_URL raises.
978
+ # .env.test -- but with --mail it has MAIL_URL=log://, which tests need
979
+ # (they boot outside development, where an unset MAIL_URL raises), and
980
+ # with --live --redis it has REDIS_URL, which config/live.rb needs.
944
981
  # Interpolated into a squiggly heredoc, so no indentation of its own.
945
982
  def base_test_dotenv_lines
946
- return "" unless @mail
983
+ return "" unless @mail || live_over_redis?
947
984
 
948
985
  %(\nrequire "dotenv"\nDotenv.load(File.expand_path(".env.test", __dir__ + "/.."))\n)
949
986
  end
@@ -951,8 +988,8 @@ module Monk
951
988
  def auth_mail_sentence
952
989
  return "" unless @auth
953
990
 
954
- " `config/auth.rb`'s `AppMailer::DELIVER` sends each login link through it " \
955
- "(HTML part: `views/mail/magic_link.erb`)."
991
+ " `AppMailer::MAGIC_LINK` (`app/mailers/app_mailer.rb`) sends each login link through it " \
992
+ "(HTML part: `app/views/mail/magic_link.erb`)."
956
993
  end
957
994
 
958
995
  def sample_test_body
@@ -976,6 +1013,44 @@ module Monk
976
1013
  end
977
1014
  end
978
1015
 
1016
+ def live_over_redis? = @live && @live_transport == :redis
1017
+
1018
+ # The end of every SETUP.md test helper: the app loaded the way
1019
+ # config.ru loads it (config/load.rb, then app/app.rb), booted once.
1020
+ # Interpolated into a squiggly heredoc, so no indentation of its own.
1021
+ def test_helper_app_lines
1022
+ <<~RUBY.chomp
1023
+ require "minitest/autorun"
1024
+ require_relative "../config/load"
1025
+ require_relative "../app/app"
1026
+
1027
+ # Booted once, as config.ru does: request tests call APP.
1028
+ APP = Monk.boot(App)
1029
+ RUBY
1030
+ end
1031
+
1032
+ # A request through the booted app. Every generated app, --live's
1033
+ # included, has GET /hello.
1034
+ def app_test_sample
1035
+ <<~MARKDOWN.chomp
1036
+ **test/app_test.rb** -- a request through the booted app:
1037
+
1038
+ ```ruby
1039
+ require_relative "test_helper"
1040
+ require "rack/mock_request"
1041
+
1042
+ class AppTest < Minitest::Test
1043
+ def test_hello
1044
+ status, _headers, body = APP.call(Rack::MockRequest.env_for("/hello"))
1045
+
1046
+ assert_equal 200, status
1047
+ assert_equal "hello from monk", body.join
1048
+ end
1049
+ end
1050
+ ```
1051
+ MARKDOWN
1052
+ end
1053
+
979
1054
  def write_file(relative_path, template_path, executable: false)
980
1055
  destination = File.join(@dir, relative_path)
981
1056
  FileUtils.mkdir_p(File.dirname(destination))