monkrb 0.15.1 → 0.17.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 +61 -0
- data/README.md +6 -5
- data/exe/monk +40 -11
- data/lib/monk/auth.rb +3 -3
- data/lib/monk/errors.rb +3 -0
- data/lib/monk/mail/address.rb +41 -0
- data/lib/monk/mail/errors.rb +28 -0
- data/lib/monk/mail/message.rb +66 -0
- data/lib/monk/mail/mime.rb +124 -0
- data/lib/monk/mail/transports.rb +115 -0
- data/lib/monk/mail/url.rb +76 -0
- data/lib/monk/mail.rb +121 -0
- data/lib/monk/scaffold.rb +178 -22
- data/lib/monk/templates/auth/config/auth.rb +18 -8
- data/lib/monk/templates/auth/views/mail/magic_link.erb +3 -0
- data/lib/monk/templates/live/config/live_pg.rb +60 -0
- data/lib/monk/templates/mail/Gemfile.extra +1 -0
- data/lib/monk/templates/mail/config/mail.rb +19 -0
- data/lib/monk/version.rb +1 -1
- data/lib/monk/websocket/errors.rb +7 -0
- data/lib/monk/websocket/pg_fanout.rb +174 -0
- metadata +27 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module Monk
|
|
4
|
+
module Mail
|
|
5
|
+
# MAIL_URL -> a transport value. Runs once, from Monk::Mail.configure
|
|
6
|
+
# in the main Ractor at boot, so a malformed URL fails the boot rather
|
|
7
|
+
# than the first send (docs/adr/0003-boot-time-fail-fast-shareability.md's
|
|
8
|
+
# posture). Error messages redact the password: the URL is a secret.
|
|
9
|
+
module URL
|
|
10
|
+
SUPPORTED = "smtp://, smtps://, log://".freeze
|
|
11
|
+
STARTTLS_MODES = %w[auto always never].freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def parse(url)
|
|
16
|
+
uri = URI.parse(url)
|
|
17
|
+
case uri.scheme&.downcase
|
|
18
|
+
when "log" then Transports::Log.new
|
|
19
|
+
when "smtp" then smtp(uri, url, tls: false)
|
|
20
|
+
when "smtps" then smtp(uri, url, tls: true)
|
|
21
|
+
else invalid!(url, "unsupported scheme #{uri.scheme.inspect}; supported: #{SUPPORTED}")
|
|
22
|
+
end
|
|
23
|
+
rescue URI::InvalidURIError
|
|
24
|
+
invalid!(url, "not a valid URL; expected e.g. smtp://user:pass@smtp.example.com:587")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def smtp(uri, url, tls:)
|
|
28
|
+
invalid!(url, "missing host") if uri.host.nil? || uri.host.empty?
|
|
29
|
+
|
|
30
|
+
Transports::SMTP.require_library!
|
|
31
|
+
user = decode(uri.user)
|
|
32
|
+
Transports::SMTP.new(
|
|
33
|
+
host: uri.host,
|
|
34
|
+
port: uri.port || (tls ? 465 : 587),
|
|
35
|
+
user: user,
|
|
36
|
+
password: decode(uri.password),
|
|
37
|
+
tls: tls,
|
|
38
|
+
starttls: starttls(uri, url, tls: tls, credentials: !user.nil?),
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Default :always with credentials (a stripped STARTTLS would
|
|
43
|
+
# otherwise send the password in clear), :auto without (a local
|
|
44
|
+
# relay usually has no TLS set up).
|
|
45
|
+
def starttls(uri, url, tls:, credentials:)
|
|
46
|
+
options = URI.decode_www_form(uri.query || "").to_h
|
|
47
|
+
unknown = options.keys - ["starttls"]
|
|
48
|
+
invalid!(url, "unknown option(s) #{unknown.join(", ")}; the only one is starttls=") if unknown.any?
|
|
49
|
+
|
|
50
|
+
mode = options["starttls"]
|
|
51
|
+
if tls
|
|
52
|
+
invalid!(url, "starttls= doesn't apply to smtps://, which is TLS from the start") if mode
|
|
53
|
+
return :never
|
|
54
|
+
end
|
|
55
|
+
return credentials ? :always : :auto if mode.nil?
|
|
56
|
+
|
|
57
|
+
invalid!(url, "starttls= must be one of #{STARTTLS_MODES.join(", ")}") unless STARTTLS_MODES.include?(mode)
|
|
58
|
+
mode.to_sym
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def decode(component)
|
|
62
|
+
return nil if component.nil? || component.empty?
|
|
63
|
+
|
|
64
|
+
URI.decode_uri_component(component)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def invalid!(url, reason)
|
|
68
|
+
raise InvalidMailUrlError, "MAIL_URL #{redact(url).inspect}: #{reason}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def redact(url)
|
|
72
|
+
url.to_s.sub(%r{(://[^:/@]*:)[^@/]*@}, '\1***@')
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/monk/mail.rb
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
require_relative "freeze_hooks"
|
|
2
|
+
require_relative "environment"
|
|
3
|
+
require_relative "log"
|
|
4
|
+
require_relative "views"
|
|
5
|
+
require_relative "context"
|
|
6
|
+
require_relative "mail/errors"
|
|
7
|
+
require_relative "mail/address"
|
|
8
|
+
require_relative "mail/mime"
|
|
9
|
+
require_relative "mail/message"
|
|
10
|
+
require_relative "mail/transports"
|
|
11
|
+
require_relative "mail/url"
|
|
12
|
+
|
|
13
|
+
module Monk
|
|
14
|
+
# Sending email. Opt-in: require "monk/mail" explicitly -- `require
|
|
15
|
+
# "monk"` alone does not load this. Text and/or HTML messages only, MIME
|
|
16
|
+
# built by Monk itself, since the `mail` gem can't run inside a worker
|
|
17
|
+
# Ractor (docs/adr/0012-minimal-built-in-mailer.md).
|
|
18
|
+
module Mail
|
|
19
|
+
class << self
|
|
20
|
+
# Typically, in config/mail.rb:
|
|
21
|
+
#
|
|
22
|
+
# Monk::Mail.configure(url: ENV["MAIL_URL"], from: ENV["MAIL_FROM"])
|
|
23
|
+
#
|
|
24
|
+
# url: picks the transport (smtp://, smtps://, log://) and is parsed
|
|
25
|
+
# here, so a bad one fails the boot. Unset or empty means log:// in
|
|
26
|
+
# development and raises everywhere else -- same posture as
|
|
27
|
+
# Monk::Auth.deliver_link: an app that forgot to wire mail should
|
|
28
|
+
# find out at boot, not when a user never gets their login link.
|
|
29
|
+
# from: is the default sender, optional since each send can pass its own.
|
|
30
|
+
def configure(url:, from: nil)
|
|
31
|
+
Fields.header!(:from, from) unless from.nil?
|
|
32
|
+
|
|
33
|
+
@config = { transport: transport_for(url), from: from }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
attr_reader :config
|
|
37
|
+
|
|
38
|
+
# Builds a Message and sends it through the configured transport,
|
|
39
|
+
# synchronously -- the calling worker Ractor waits for the send
|
|
40
|
+
# (docs/adr/0012-minimal-built-in-mailer.md accepts that cost; a
|
|
41
|
+
# local relay or a fast HTTPS provider keeps it short). Returns the
|
|
42
|
+
# Message. Invalid input raises InvalidMessageError before anything
|
|
43
|
+
# is sent; a failed send raises DeliveryError.
|
|
44
|
+
#
|
|
45
|
+
# Monk::Mail.deliver(to: email, subject: "Your login link", text: "Log in: #{link}")
|
|
46
|
+
def deliver(to:, subject:, text: nil, html: nil, reply_to: nil, from: nil)
|
|
47
|
+
raise NotConfiguredError, "call Monk::Mail.configure(url:, from:) before Monk::Mail.deliver" unless config
|
|
48
|
+
|
|
49
|
+
from ||= config[:from]
|
|
50
|
+
if from.nil?
|
|
51
|
+
raise InvalidMessageError, "no from: given, and Monk::Mail.configure has no default from: (MAIL_FROM)"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
message = Message.new(from: from, to: to, subject: subject, text: text, html: html, reply_to: reply_to)
|
|
55
|
+
config[:transport].deliver(message)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Renders a Monk::Views template to an HTML String for deliver's
|
|
59
|
+
# html: -- e.g. views/mail/magic_link.erb:
|
|
60
|
+
#
|
|
61
|
+
# html: Monk::Mail.render("mail/magic_link", link: link)
|
|
62
|
+
#
|
|
63
|
+
# The same detached-Context render Monk::Live::Renderer does: no
|
|
64
|
+
# request behind it, so the template sees only its locals, never
|
|
65
|
+
# params or the session. The app's page layout is skipped (layout:
|
|
66
|
+
# false); pass layout: "mail/layout" for an email-specific one.
|
|
67
|
+
# `<%= %>` escapes as usual, which is right for HTML and wrong for a
|
|
68
|
+
# plain-text body -- build text: as a Ruby String instead.
|
|
69
|
+
def render(template, layout: false, **locals)
|
|
70
|
+
ensure_views_frozen!
|
|
71
|
+
String.new(Monk::Context.new({}).render(template, layout: layout, **locals)).freeze
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Called from Base#freeze! (Seam B), via Monk.freeze_hooks. Freezes
|
|
75
|
+
# the value, not the module, same as Monk::Auth.freeze_registry! --
|
|
76
|
+
# an unfrozen config Hash can't be read from a worker Ractor at all.
|
|
77
|
+
def freeze_registry!
|
|
78
|
+
transport = @config&.fetch(:transport)
|
|
79
|
+
transport.class.prepare! if transport.class.respond_to?(:prepare!)
|
|
80
|
+
@config = Ractor.make_shareable(@config)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Test-only: drops the current config. Not part of the app-facing API.
|
|
84
|
+
def reset!
|
|
85
|
+
@config = nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# Unfrozen, a render from a worker Ractor dies with an opaque
|
|
91
|
+
# Ractor::IsolationError inside Monk::Views, and one from the main
|
|
92
|
+
# Ractor works by accident and then fails in production -- so both
|
|
93
|
+
# fail here, naming the fix (same check as Monk::Live::Renderer).
|
|
94
|
+
def ensure_views_frozen!
|
|
95
|
+
return if Ractor.shareable?(Monk::Views.registry)
|
|
96
|
+
|
|
97
|
+
raise_views_not_frozen
|
|
98
|
+
rescue Ractor::IsolationError
|
|
99
|
+
raise_views_not_frozen
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def raise_views_not_frozen
|
|
103
|
+
raise ViewsNotFrozenError,
|
|
104
|
+
"Monk::Mail.render can't render before views are compiled and frozen -- call Monk.boot(app) " \
|
|
105
|
+
"(or Monk.freeze!) in the main Ractor first"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def transport_for(url)
|
|
109
|
+
return URL.parse(url) unless url.nil? || url.empty?
|
|
110
|
+
return Transports::Log.new if Monk.env.development?
|
|
111
|
+
|
|
112
|
+
raise MissingMailUrlError,
|
|
113
|
+
"Monk::Mail.configure(url:) is empty and this isn't development (MONK_ENV=#{Monk.env}) -- " \
|
|
114
|
+
"no email would ever be sent. Set MAIL_URL, e.g. smtp://user:pass@smtp.example.com:587, " \
|
|
115
|
+
"or log:// if logging messages instead of sending them is truly intended here."
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
Monk.freeze_hooks << self
|
|
120
|
+
end
|
|
121
|
+
end
|
data/lib/monk/scaffold.rb
CHANGED
|
@@ -45,6 +45,15 @@ module Monk
|
|
|
45
45
|
"config/auth.rb" => "auth/config/auth.rb",
|
|
46
46
|
"db/migrate/00000000000001_create_auth_tables.up.sql" => "auth/db/migrate/00000000000001_create_auth_tables.up.sql",
|
|
47
47
|
"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",
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
# --mail, or implied by --auth: Monk::Mail's config. Its Gemfile line
|
|
54
|
+
# (net-smtp) and MAIL_* env vars are added by #write! / #write_env_files!.
|
|
55
|
+
MAIL_FILES = {
|
|
56
|
+
"config/mail.rb" => "mail/config/mail.rb",
|
|
48
57
|
}.freeze
|
|
49
58
|
|
|
50
59
|
# --live: Monk::Live's demo (a counter whose open tabs update when
|
|
@@ -58,11 +67,18 @@ module Monk
|
|
|
58
67
|
"views/index.erb" => "live/views/index.erb",
|
|
59
68
|
}.freeze
|
|
60
69
|
|
|
70
|
+
# config/live.rb itself isn't here -- #write_live! picks between
|
|
71
|
+
# LIVE_CONFIG_TEMPLATES[@live_transport] instead, since which one gets
|
|
72
|
+
# written depends on --redis vs --postgres, not a fixed mapping.
|
|
61
73
|
LIVE_FILES = {
|
|
62
|
-
"config/live.rb" => "live/config/live.rb",
|
|
63
74
|
"views/live/_hits.erb" => "live/views/live/_hits.erb",
|
|
64
75
|
}.freeze
|
|
65
76
|
|
|
77
|
+
LIVE_CONFIG_TEMPLATES = {
|
|
78
|
+
redis: "live/config/live.rb",
|
|
79
|
+
postgres: "live/config/live_pg.rb",
|
|
80
|
+
}.freeze
|
|
81
|
+
|
|
66
82
|
# Where the client runtime lands in the app's public root (an app serves
|
|
67
83
|
# it like any other static file; its files import each other by relative
|
|
68
84
|
# path, so they stay together in one directory).
|
|
@@ -87,15 +103,45 @@ module Monk
|
|
|
87
103
|
# and only then requires "redis", so the gem has to already be in the
|
|
88
104
|
# bundle for that to work.
|
|
89
105
|
#
|
|
90
|
-
# live:
|
|
91
|
-
# processes, so
|
|
92
|
-
#
|
|
93
|
-
|
|
106
|
+
# live: bin/server and bin/websocket_server are always separate
|
|
107
|
+
# processes, so *something* has to carry a publish between them --
|
|
108
|
+
# --redis (Monk::WebSocket::RedisFanout) or --postgres
|
|
109
|
+
# (Monk::WebSocket::PgFanout, reusing --postgres's own DB_* settings,
|
|
110
|
+
# no Redis to run). --live no longer silently defaults to one: with
|
|
111
|
+
# neither flag, there's no way to tell whether this app has Postgres
|
|
112
|
+
# available at all, so guessing would be as likely to hand back a
|
|
113
|
+
# template that can't connect as one that can. --live --redis wins
|
|
114
|
+
# over --postgres if both are passed (an explicit ask beats an
|
|
115
|
+
# implied default); --live --postgres alone (or via --auth, which
|
|
116
|
+
# implies --postgres) picks PgFanout; --live with neither raises.
|
|
117
|
+
# See docs/design/live-pg-fanout.md / docs/history/plan-live-pg-fanout.md
|
|
118
|
+
# for the design this decision comes from.
|
|
119
|
+
#
|
|
120
|
+
# mail: Monk::Mail on its own, no Postgres needed. auth: implies it, the
|
|
121
|
+
# same way it implies postgres: a magic link has to reach someone.
|
|
122
|
+
def initialize(dir, postgres: false, auth: false, redis: false, live: false, mail: false)
|
|
94
123
|
@dir = dir
|
|
95
124
|
@auth = auth
|
|
96
125
|
@postgres = postgres || auth
|
|
126
|
+
@mail = mail || auth
|
|
97
127
|
@live = live
|
|
98
|
-
|
|
128
|
+
|
|
129
|
+
if @live
|
|
130
|
+
@redis = redis || false
|
|
131
|
+
@live_transport = if redis
|
|
132
|
+
:redis
|
|
133
|
+
elsif @postgres
|
|
134
|
+
:postgres
|
|
135
|
+
else
|
|
136
|
+
raise Monk::AmbiguousLiveTransportError,
|
|
137
|
+
"monk new --live needs --redis or --postgres to pick Monk::Live's cross-process " \
|
|
138
|
+
"transport (bin/server and bin/websocket_server are always separate processes) -- " \
|
|
139
|
+
"pass one explicitly, e.g. `--live --postgres` (docs/guides/live.md's " \
|
|
140
|
+
"\"Without Redis\" section has the tradeoffs)"
|
|
141
|
+
end
|
|
142
|
+
else
|
|
143
|
+
@redis = redis
|
|
144
|
+
end
|
|
99
145
|
end
|
|
100
146
|
|
|
101
147
|
def write!
|
|
@@ -112,15 +158,20 @@ module Monk
|
|
|
112
158
|
POSTGRES_FILES.each { |relative, template| write_file(relative, template, executable: EXECUTABLE_FILES.include?(relative)) }
|
|
113
159
|
FileUtils.mkdir_p(File.join(@dir, "db/migrate"))
|
|
114
160
|
append_gemfile_extra("postgres/Gemfile.extra")
|
|
115
|
-
wire_config_ru!
|
|
116
161
|
end
|
|
117
162
|
|
|
163
|
+
if @mail
|
|
164
|
+
MAIL_FILES.each { |relative, template| write_file(relative, template) }
|
|
165
|
+
append_gemfile_extra("mail/Gemfile.extra")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
wire_config_ru! if @postgres || @mail
|
|
118
169
|
append_gemfile_extra("redis/Gemfile.extra") if @redis
|
|
119
170
|
|
|
120
|
-
# --postgres and --
|
|
121
|
-
# putting in an env file (a database, a REDIS_URL) -- the
|
|
122
|
-
# skeleton alone has nothing to configure this way.
|
|
123
|
-
if @postgres || @redis
|
|
171
|
+
# --postgres, --redis and --mail are the flags with anything worth
|
|
172
|
+
# putting in an env file (a database, a REDIS_URL, MAIL_*) -- the
|
|
173
|
+
# base skeleton alone has nothing to configure this way.
|
|
174
|
+
if @postgres || @redis || @mail
|
|
124
175
|
write_env_files!
|
|
125
176
|
uncomment_dotenv!
|
|
126
177
|
end
|
|
@@ -136,6 +187,7 @@ module Monk
|
|
|
136
187
|
private
|
|
137
188
|
|
|
138
189
|
def write_live!
|
|
190
|
+
write_file("config/live.rb", LIVE_CONFIG_TEMPLATES.fetch(@live_transport))
|
|
139
191
|
LIVE_FILES.each { |relative, template| write_file(relative, template) }
|
|
140
192
|
copy_live_client!
|
|
141
193
|
add_live_to_layout!
|
|
@@ -190,12 +242,20 @@ module Monk
|
|
|
190
242
|
def wire_config_ru!
|
|
191
243
|
path = File.join(@dir, "config.ru")
|
|
192
244
|
settings_require = %(require_relative "config/settings"\n)
|
|
193
|
-
|
|
245
|
+
requires = +""
|
|
246
|
+
if @postgres
|
|
247
|
+
target = @auth ? "config/auth" : "config/persistence" # config/auth.rb itself require_relative "persistence"
|
|
248
|
+
requires << "require_relative \"#{target}\"\n"
|
|
249
|
+
end
|
|
250
|
+
# config/mail.rb from config.ru only, never from config/auth.rb:
|
|
251
|
+
# bin/websocket_server loads config/auth.rb too and never sends mail,
|
|
252
|
+
# so it shouldn't need MAIL_URL to boot.
|
|
253
|
+
requires << "require_relative \"config/mail\"\n" if @mail
|
|
194
254
|
|
|
195
255
|
content = File.read(path)
|
|
196
256
|
raise "config.ru wiring failed: #{settings_require.inspect} not found" unless content.include?(settings_require)
|
|
197
257
|
|
|
198
|
-
File.write(path, content.sub(settings_require, "#{settings_require}
|
|
258
|
+
File.write(path, content.sub(settings_require, "#{settings_require}#{requires}"))
|
|
199
259
|
end
|
|
200
260
|
|
|
201
261
|
# .env/.env.test/.env.example are the other deliberate exception to
|
|
@@ -218,6 +278,17 @@ module Monk
|
|
|
218
278
|
example << "AUTH_SECRET=change-me"
|
|
219
279
|
end
|
|
220
280
|
|
|
281
|
+
if @mail
|
|
282
|
+
# MAIL_URL is left out of .env on purpose: unset in development
|
|
283
|
+
# means log://, the message printed to the console. Tests boot
|
|
284
|
+
# outside development, where an unset one raises, hence log:// there.
|
|
285
|
+
dev << %(MAIL_FROM="#{app_name} <no-reply@localhost>")
|
|
286
|
+
test << "MAIL_URL=log://"
|
|
287
|
+
test << %(MAIL_FROM="#{app_name} <no-reply@localhost>")
|
|
288
|
+
example << "MAIL_URL=smtp://user:password@smtp.example.com:587"
|
|
289
|
+
example << %(MAIL_FROM="#{app_name} <no-reply@example.com>")
|
|
290
|
+
end
|
|
291
|
+
|
|
221
292
|
# REDIS_URL is deliberately absent from .env.test -- only a test that
|
|
222
293
|
# actually exercises RedisFanout needs it, unlike DB_NAME/AUTH_SECRET
|
|
223
294
|
# which every test touching persistence/auth needs.
|
|
@@ -253,12 +324,17 @@ module Monk
|
|
|
253
324
|
end
|
|
254
325
|
|
|
255
326
|
def live_setup_md_content
|
|
327
|
+
@live_transport == :postgres ? live_setup_md_content_postgres : live_setup_md_content_redis
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def live_setup_md_content_redis
|
|
256
331
|
<<~MARKDOWN
|
|
257
332
|
|
|
258
333
|
## Live updates (Monk::Live)
|
|
259
334
|
|
|
260
|
-
`monk new --live` wired a demo: a counter whose open tabs
|
|
261
|
-
themselves. It needs three things running, each in its own
|
|
335
|
+
`monk new --live --redis` wired a demo: a counter whose open tabs
|
|
336
|
+
update by themselves. It needs three things running, each in its own
|
|
337
|
+
terminal:
|
|
262
338
|
|
|
263
339
|
```bash
|
|
264
340
|
docker run --rm -d -p 6379:6379 --name #{File.basename(@dir)}_redis redis:7 # skip if one is already up
|
|
@@ -272,8 +348,45 @@ module Monk
|
|
|
272
348
|
(`REDIS_URL`, already in `.env`) is what carries an update from one to
|
|
273
349
|
the other. Where things are:
|
|
274
350
|
|
|
275
|
-
- `config/live.rb` -- the Redis wiring
|
|
276
|
-
is allowed unless a rule says so).
|
|
351
|
+
- `config/live.rb` -- the Redis wiring (`Monk::WebSocket::RedisFanout`)
|
|
352
|
+
and the subscribe rules (nothing is allowed unless a rule says so).
|
|
353
|
+
- `config.ru` -- `POST /hit` changes state and calls `Monk::Live.patch`.
|
|
354
|
+
- `views/index.erb` -- `live_topic "hits"` marks what to subscribe to.
|
|
355
|
+
- `views/live/_hits.erb` -- the fragment that gets pushed. Partials
|
|
356
|
+
used this way see only their locals (`locals[:hits]`), never
|
|
357
|
+
`params` or the session.
|
|
358
|
+
- `public/js/monk_live/` -- the browser runtime, copied from the gem.
|
|
359
|
+
The layout points at it and at `LIVE_WS_URL` (default
|
|
360
|
+
`ws://localhost:9293`; use `wss://` in production).
|
|
361
|
+
|
|
362
|
+
`WS_ALLOWED_ORIGINS` (default `http://localhost:9292`) must list the
|
|
363
|
+
origin your pages are served from, or the browser's socket is refused.
|
|
364
|
+
MARKDOWN
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def live_setup_md_content_postgres
|
|
368
|
+
<<~MARKDOWN
|
|
369
|
+
|
|
370
|
+
## Live updates (Monk::Live)
|
|
371
|
+
|
|
372
|
+
`monk new --live --postgres` wired a demo: a counter whose open tabs
|
|
373
|
+
update by themselves. It needs two things running, each in its own
|
|
374
|
+
terminal -- no Redis, since Postgres `LISTEN`/`NOTIFY` carries the
|
|
375
|
+
update instead:
|
|
376
|
+
|
|
377
|
+
```bash
|
|
378
|
+
bin/server # the HTTP app on :9292, which publishes updates
|
|
379
|
+
bin/websocket_server # the sockets on :9293, which deliver them
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Open http://localhost:9292 in two tabs and press the button in one.
|
|
383
|
+
|
|
384
|
+
`bin/server` and `bin/websocket_server` are separate processes, so
|
|
385
|
+
Postgres `LISTEN`/`NOTIFY` (the same `DB_*` settings already in `.env`)
|
|
386
|
+
is what carries an update from one to the other. Where things are:
|
|
387
|
+
|
|
388
|
+
- `config/live.rb` -- the Postgres wiring (`Monk::WebSocket::PgFanout`)
|
|
389
|
+
and the subscribe rules (nothing is allowed unless a rule says so).
|
|
277
390
|
- `config.ru` -- `POST /hit` changes state and calls `Monk::Live.patch`.
|
|
278
391
|
- `views/index.erb` -- `live_topic "hits"` marks what to subscribe to.
|
|
279
392
|
- `views/live/_hits.erb` -- the fragment that gets pushed. Partials
|
|
@@ -285,6 +398,12 @@ module Monk
|
|
|
285
398
|
|
|
286
399
|
`WS_ALLOWED_ORIGINS` (default `http://localhost:9292`) must list the
|
|
287
400
|
origin your pages are served from, or the browser's socket is refused.
|
|
401
|
+
|
|
402
|
+
A single broadcast is capped at just under 8000 bytes
|
|
403
|
+
(`Monk::WebSocket::PgFanout::MAX_NOTIFY_PAYLOAD_BYTES`, Postgres's own
|
|
404
|
+
`NOTIFY` limit) -- far more than this demo ever sends, but worth
|
|
405
|
+
knowing before pushing much larger partials this way. Regenerate with
|
|
406
|
+
`--redis` instead of `--postgres` if that becomes a real constraint.
|
|
288
407
|
MARKDOWN
|
|
289
408
|
end
|
|
290
409
|
|
|
@@ -311,7 +430,7 @@ module Monk
|
|
|
311
430
|
bin/server # HTTP app on :9292 -> http://localhost:9292/hello
|
|
312
431
|
bin/websocket_server # WS chat process on :9293, in another terminal
|
|
313
432
|
```
|
|
314
|
-
#{redis_only_note}
|
|
433
|
+
#{redis_only_note}#{mail_setup_note}
|
|
315
434
|
## Test environment
|
|
316
435
|
|
|
317
436
|
`monk new` scaffolds no test framework at all -- this is the minimum to
|
|
@@ -333,9 +452,9 @@ module Monk
|
|
|
333
452
|
$LOAD_PATH.unshift(File.expand_path("..", __dir__))
|
|
334
453
|
|
|
335
454
|
ENV["MONK_ENV"] ||= "test"
|
|
336
|
-
|
|
455
|
+
#{base_test_dotenv_lines}
|
|
337
456
|
require "minitest/autorun"
|
|
338
|
-
require_relative "../config/settings"
|
|
457
|
+
require_relative "../config/settings"#{%(\nrequire_relative "../config/mail") if @mail}
|
|
339
458
|
```
|
|
340
459
|
|
|
341
460
|
**Rakefile**:
|
|
@@ -471,7 +590,7 @@ module Monk
|
|
|
471
590
|
bin/server # HTTP app on :9292
|
|
472
591
|
bin/websocket_server # WS chat process on :9293, in another terminal
|
|
473
592
|
```
|
|
474
|
-
#{auth_or_redis_confirmation_note}
|
|
593
|
+
#{auth_or_redis_confirmation_note}#{mail_setup_note}
|
|
475
594
|
## Test environment
|
|
476
595
|
|
|
477
596
|
### 1. Create the test database
|
|
@@ -518,7 +637,7 @@ module Monk
|
|
|
518
637
|
|
|
519
638
|
require "minitest/autorun"
|
|
520
639
|
require_relative "../config/settings"
|
|
521
|
-
require_relative "../config/#{@auth ? "auth" : "persistence"}"
|
|
640
|
+
require_relative "../config/#{@auth ? "auth" : "persistence"}"#{%(\nrequire_relative "../config/mail") if @mail}
|
|
522
641
|
```
|
|
523
642
|
|
|
524
643
|
**Rakefile**:
|
|
@@ -575,6 +694,43 @@ module Monk
|
|
|
575
694
|
"#{visible} visible to it -- that confirms everything's actually wired up.\n"
|
|
576
695
|
end
|
|
577
696
|
|
|
697
|
+
# Interpolated into a squiggly heredoc, so no leading indentation of
|
|
698
|
+
# its own (interpolated text isn't dedented).
|
|
699
|
+
def mail_setup_note
|
|
700
|
+
return "" unless @mail
|
|
701
|
+
|
|
702
|
+
<<~MARKDOWN
|
|
703
|
+
|
|
704
|
+
### Email
|
|
705
|
+
|
|
706
|
+
`config/mail.rb` configures `Monk::Mail`; send with
|
|
707
|
+
`Monk::Mail.deliver(to:, subject:, text:, html:)`.#{auth_mail_sentence} In
|
|
708
|
+
development `MAIL_URL` is unset, so messages are printed to the
|
|
709
|
+
console instead of sent. Outside
|
|
710
|
+
development set `MAIL_URL` (e.g. `smtp://user:pass@smtp.provider.com:587`)
|
|
711
|
+
and `MAIL_FROM` to a sender on a domain you've verified with your
|
|
712
|
+
provider -- an unset `MAIL_URL` fails the boot there. Provider
|
|
713
|
+
examples: `docs/guides/mail.md` in the monk repo.
|
|
714
|
+
MARKDOWN
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
# The no-Postgres test helper normally has nothing to load from
|
|
718
|
+
# .env.test -- but with --mail it has MAIL_URL=log://, which tests need:
|
|
719
|
+
# they boot outside development, where an unset MAIL_URL raises.
|
|
720
|
+
# Interpolated into a squiggly heredoc, so no indentation of its own.
|
|
721
|
+
def base_test_dotenv_lines
|
|
722
|
+
return "" unless @mail
|
|
723
|
+
|
|
724
|
+
%(\nrequire "dotenv"\nDotenv.load(File.expand_path(".env.test", __dir__ + "/.."))\n)
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
def auth_mail_sentence
|
|
728
|
+
return "" unless @auth
|
|
729
|
+
|
|
730
|
+
" `config/auth.rb`'s `AppMailer::DELIVER` sends each login link through it " \
|
|
731
|
+
"(HTML part: `views/mail/magic_link.erb`)."
|
|
732
|
+
end
|
|
733
|
+
|
|
578
734
|
def sample_test_body
|
|
579
735
|
if @auth
|
|
580
736
|
<<~RUBY.chomp.gsub(/^/, " ")
|
|
@@ -7,14 +7,24 @@ require_relative "persistence"
|
|
|
7
7
|
# "#{Monk::Settings[:public_url]}/auth/callback/#{token}"), see its comment
|
|
8
8
|
# there and auth.md's "Sending the magic link".
|
|
9
9
|
|
|
10
|
-
# Sends the magic link
|
|
11
|
-
# a module constant, not a lambda inline in
|
|
12
|
-
# self at this file's top level isn't
|
|
13
|
-
# Monk::Live.authorize blocks).
|
|
14
|
-
#
|
|
15
|
-
#
|
|
10
|
+
# Sends the magic link by email, through Monk::Mail (config/mail.rb, which
|
|
11
|
+
# config.ru requires) -- a module constant, not a lambda inline in
|
|
12
|
+
# Auth.configure below, since self at this file's top level isn't
|
|
13
|
+
# Ractor-shareable (same constraint as Monk::Live.authorize blocks). The
|
|
14
|
+
# HTML part is views/mail/magic_link.erb. Swap the body for SMS or any
|
|
15
|
+
# other channel; see docs/guides/auth.md.
|
|
16
16
|
module AppMailer
|
|
17
|
-
|
|
17
|
+
DELIVER = lambda do |email:, link:, token:|
|
|
18
|
+
# Development only (a no-op elsewhere): the link on the console, plus a
|
|
19
|
+
# QR code for a phone if rqrcode is in the Gemfile.
|
|
20
|
+
Monk::Auth.log_dev_link(link, subject: email)
|
|
21
|
+
Monk::Mail.deliver(
|
|
22
|
+
to: email,
|
|
23
|
+
subject: "Your login link",
|
|
24
|
+
text: "Here's your login link:\n\n#{link}\n\nIf you didn't ask for it, you can ignore this email.",
|
|
25
|
+
html: Monk::Mail.render("mail/magic_link", link: link),
|
|
26
|
+
)
|
|
27
|
+
end
|
|
18
28
|
end
|
|
19
29
|
|
|
20
30
|
Monk::Auth.configure(
|
|
@@ -24,5 +34,5 @@ Monk::Auth.configure(
|
|
|
24
34
|
session_ttl: 1_209_600, # seconds a session stays valid (14 days)
|
|
25
35
|
redirect_allowlist: [], # paths request_login(redirect_to:) is allowed to target
|
|
26
36
|
secure: !Monk.env.development?, # Secure cookie flag; off in dev so plain-http testing works (Safari drops it)
|
|
27
|
-
|
|
37
|
+
deliver: AppMailer::DELIVER,
|
|
28
38
|
)
|
|
@@ -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)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gem "net-smtp" # Monk::Mail's smtp:// transport (config/mail.rb)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require "monk"
|
|
2
|
+
require "monk/mail"
|
|
3
|
+
|
|
4
|
+
# Sends email with Monk::Mail.deliver(to:, subject:, text:, html:) from
|
|
5
|
+
# any route -- and, with --auth, the magic links config/auth.rb's AppMailer
|
|
6
|
+
# delivers. See docs/guides/mail.md. Required from config.ru (not from
|
|
7
|
+
# config/auth.rb: bin/websocket_server loads that too, and never sends mail).
|
|
8
|
+
Monk::Settings.configure do
|
|
9
|
+
# Where mail goes. Unset in development means log:// (printed to the
|
|
10
|
+
# console, nothing sent); anywhere else an unset one fails the boot.
|
|
11
|
+
# e.g. smtp://user:pass@smtp.provider.com:587 or smtp://localhost:25,
|
|
12
|
+
# provider examples in docs/guides/mail.md.
|
|
13
|
+
optional :mail_url, default: ""
|
|
14
|
+
# The sender of every message, e.g. "My App <no-reply@myapp.com>" --
|
|
15
|
+
# a domain you've verified with your mail provider (SPF, DKIM).
|
|
16
|
+
optional :mail_from, default: "App <no-reply@localhost>"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
Monk::Mail.configure(url: Monk::Settings[:mail_url], from: Monk::Settings[:mail_from])
|
data/lib/monk/version.rb
CHANGED
|
@@ -5,5 +5,5 @@ module Monk
|
|
|
5
5
|
# class of bug .freeze! guards against for routes/error handlers/models,
|
|
6
6
|
# just on a top-level constant that every worker Ractor reads on boot
|
|
7
7
|
# (found live under kino: GET /hello 500'd until this was frozen).
|
|
8
|
-
VERSION = "0.
|
|
8
|
+
VERSION = "0.17.0".freeze
|
|
9
9
|
end
|
|
@@ -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
|