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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/README.md +2 -2
- data/lib/monk/live/client/monk_live.js +2 -2
- data/lib/monk/live/client/protocol.js +5 -0
- data/lib/monk/live.rb +10 -0
- data/lib/monk/scaffold.rb +152 -77
- data/lib/monk/templates/auth/app/mailers/app_mailer.rb +22 -0
- data/lib/monk/templates/auth/config/auth.rb +7 -20
- data/lib/monk/templates/base/app/app.rb +9 -0
- data/lib/monk/templates/base/app/broadcasts/.keep +0 -0
- data/lib/monk/templates/base/app/helpers/.keep +0 -0
- data/lib/monk/templates/base/app/jobs/.keep +0 -0
- data/lib/monk/templates/base/app/mailers/.keep +0 -0
- data/lib/monk/templates/base/app/models/.keep +0 -0
- data/lib/monk/templates/base/app/presenters/.keep +0 -0
- data/lib/monk/templates/base/bin/websocket_server +6 -0
- data/lib/monk/templates/base/config/load.rb +18 -0
- data/lib/monk/templates/base/config.ru +3 -12
- data/lib/monk/templates/base/public/js/app.js +1 -1
- data/lib/monk/templates/jobs/{jobs → app/jobs}/send_login_link.rb +2 -1
- data/lib/monk/templates/jobs/bin/jobs +5 -13
- data/lib/monk/templates/jobs/config/jobs.rb +2 -4
- data/lib/monk/templates/live/{config.ru → app/app.rb} +1 -6
- data/lib/monk/templates/live/{views → app/views}/index.erb +2 -2
- data/lib/monk/templates/live/bin/websocket_server +14 -2
- data/lib/monk/templates/live/config/live.rb +1 -1
- data/lib/monk/templates/live/config/live_pg.rb +1 -1
- data/lib/monk/templates/mail/config/mail.rb +2 -2
- data/lib/monk/templates/postgres/bin/console +4 -2
- data/lib/monk/version.rb +1 -1
- data/lib/monk/websocket/errors.rb +11 -0
- data/lib/monk/websocket/listeners.rb +23 -0
- data/lib/monk/websocket/pg_fanout.rb +55 -20
- data/lib/monk/websocket/redis_fanout.rb +57 -14
- data/lib/monk/websocket/registry.rb +6 -0
- data/lib/monk/websocket/server.rb +77 -17
- metadata +19 -9
- /data/lib/monk/templates/auth/{views → app/views}/mail/magic_link.erb +0 -0
- /data/lib/monk/templates/base/{views → app/views}/index.erb +0 -0
- /data/lib/monk/templates/base/{views → app/views}/layouts/app.erb +0 -0
- /data/lib/monk/templates/jobs/{jobs → app/jobs}/hello_job.rb +0 -0
- /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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5745b05b44d3f2953a037262d97c7a211ebea46b72f9872e6d626c58886338bf
|
|
4
|
+
data.tar.gz: f6a970fb8f3e724e992238c7c89f230e215cba4b9d36aadc6122bab452b36ea5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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, `
|
|
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
|
|
49
|
-
#
|
|
50
|
-
|
|
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
|
|
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
|
|
97
|
-
# files, not one line of diff -- and add two. config.ru
|
|
98
|
-
#
|
|
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
|
-
"
|
|
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
|
-
|
|
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.
|
|
341
|
-
#
|
|
342
|
-
#
|
|
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
|
|
345
|
-
# combination) would duplicate the whole file for
|
|
346
|
-
def
|
|
347
|
-
path = File.join(@dir, "config.
|
|
348
|
-
settings_require = %(require_relative "
|
|
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 ? "
|
|
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.
|
|
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 \"
|
|
358
|
-
requires << "require_relative \"
|
|
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.
|
|
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, "
|
|
378
|
+
path = File.join(@dir, "app/app.rb")
|
|
368
379
|
content = File.read(path)
|
|
369
|
-
raise "
|
|
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
|
|
423
|
-
# actually exercises RedisFanout needs it
|
|
424
|
-
#
|
|
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
|
-
- `
|
|
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
|
-
- `
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
660
|
-
-- `config.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
858
|
-
`jobs
|
|
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
|
-
" `
|
|
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))
|