monkrb 0.18.0 → 0.20.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +166 -0
  3. data/README.md +2 -2
  4. data/lib/monk/base.rb +20 -0
  5. data/lib/monk/jobs/adapters/pg.rb +3 -1
  6. data/lib/monk/live/client/monk_live.js +2 -2
  7. data/lib/monk/live/client/protocol.js +5 -0
  8. data/lib/monk/live/policy.rb +39 -4
  9. data/lib/monk/live/session.rb +7 -0
  10. data/lib/monk/live.rb +15 -3
  11. data/lib/monk/persistence/errors.rb +32 -0
  12. data/lib/monk/persistence/pg.rb +92 -1
  13. data/lib/monk/persistence/pool.rb +513 -0
  14. data/lib/monk/persistence.rb +196 -7
  15. data/lib/monk/scaffold.rb +155 -78
  16. data/lib/monk/templates/auth/app/mailers/app_mailer.rb +22 -0
  17. data/lib/monk/templates/auth/config/auth.rb +13 -20
  18. data/lib/monk/templates/base/app/app.rb +9 -0
  19. data/lib/monk/templates/base/app/broadcasts/.keep +0 -0
  20. data/lib/monk/templates/base/app/helpers/.keep +0 -0
  21. data/lib/monk/templates/base/app/jobs/.keep +0 -0
  22. data/lib/monk/templates/base/app/mailers/.keep +0 -0
  23. data/lib/monk/templates/base/app/models/.keep +0 -0
  24. data/lib/monk/templates/base/app/presenters/.keep +0 -0
  25. data/lib/monk/templates/base/bin/websocket_server +17 -2
  26. data/lib/monk/templates/base/config/load.rb +18 -0
  27. data/lib/monk/templates/base/config.ru +3 -12
  28. data/lib/monk/templates/base/public/js/app.js +1 -1
  29. data/lib/monk/templates/jobs/{jobs → app/jobs}/send_login_link.rb +2 -1
  30. data/lib/monk/templates/jobs/bin/jobs +5 -13
  31. data/lib/monk/templates/jobs/config/jobs.rb +2 -4
  32. data/lib/monk/templates/live/{config.ru → app/app.rb} +1 -6
  33. data/lib/monk/templates/live/{views → app/views}/index.erb +2 -2
  34. data/lib/monk/templates/live/bin/websocket_server +23 -3
  35. data/lib/monk/templates/live/config/live.rb +1 -1
  36. data/lib/monk/templates/live/config/live_pg.rb +1 -1
  37. data/lib/monk/templates/mail/config/mail.rb +2 -2
  38. data/lib/monk/templates/postgres/bin/console +4 -2
  39. data/lib/monk/templates/postgres/db/migrate/.keep +0 -0
  40. data/lib/monk/version.rb +1 -1
  41. data/lib/monk/websocket/connection.rb +25 -6
  42. data/lib/monk/websocket/errors.rb +11 -0
  43. data/lib/monk/websocket/listeners.rb +47 -0
  44. data/lib/monk/websocket/pg_fanout.rb +189 -31
  45. data/lib/monk/websocket/redis_fanout.rb +125 -33
  46. data/lib/monk/websocket/registry.rb +30 -0
  47. data/lib/monk/websocket/server.rb +128 -29
  48. data/lib/monk/websocket.rb +2 -0
  49. data/lib/monk.rb +10 -0
  50. metadata +21 -9
  51. /data/lib/monk/templates/auth/{views → app/views}/mail/magic_link.erb +0 -0
  52. /data/lib/monk/templates/base/{views → app/views}/index.erb +0 -0
  53. /data/lib/monk/templates/base/{views → app/views}/layouts/app.erb +0 -0
  54. /data/lib/monk/templates/jobs/{jobs → app/jobs}/hello_job.rb +0 -0
  55. /data/lib/monk/templates/live/{views → app/views}/live/_hits.erb +0 -0
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
@@ -31,6 +39,9 @@ module Monk
31
39
  "bin/console" => "postgres/bin/console",
32
40
  "bin/setup_db" => "postgres/bin/setup_db",
33
41
  "bin/migrate" => "postgres/bin/migrate",
42
+ # Kept by git when empty, like app/'s role directories: a fresh clone
43
+ # still shows where migrations go. The migrator reads only *.sql.
44
+ "db/migrate/.keep" => "postgres/db/migrate/.keep",
34
45
  }.freeze
35
46
 
36
47
  # The pg gem's native extension needs libpq -- the base Dockerfile has
@@ -45,9 +56,11 @@ module Monk
45
56
  "config/auth.rb" => "auth/config/auth.rb",
46
57
  "db/migrate/00000000000001_create_auth_tables.up.sql" => "auth/db/migrate/00000000000001_create_auth_tables.up.sql",
47
58
  "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",
59
+ # The magic link's sender, Monk::Auth's deliver: hook -- config/auth.rb
60
+ # requires it -- and the HTML part it renders and sends through
61
+ # Monk::Mail (MAIL_FILES, implied by --auth).
62
+ "app/mailers/app_mailer.rb" => "auth/app/mailers/app_mailer.rb",
63
+ "app/views/mail/magic_link.erb" => "auth/app/views/mail/magic_link.erb",
51
64
  }.freeze
52
65
 
53
66
  # --mail, or implied by --auth: Monk::Mail's config. Its Gemfile line
@@ -61,7 +74,7 @@ module Monk
61
74
  # numbered after auth's so the two sort in order when both are present.
62
75
  JOBS_FILES = {
63
76
  "config/jobs.rb" => "jobs/config/jobs.rb",
64
- "jobs/hello_job.rb" => "jobs/jobs/hello_job.rb",
77
+ "app/jobs/hello_job.rb" => "jobs/app/jobs/hello_job.rb",
65
78
  "bin/jobs" => "jobs/bin/jobs",
66
79
  "db/migrate/00000000000002_create_jobs_tables.up.sql" => "jobs/db/migrate/00000000000002_create_jobs_tables.up.sql",
67
80
  "db/migrate/00000000000002_create_jobs_tables.down.sql" =>
@@ -71,7 +84,7 @@ module Monk
71
84
  # --auth --jobs: the magic link is created and sent inside a job, so the
72
85
  # raw token is never stored (docs/adr/0014).
73
86
  JOBS_AUTH_FILES = {
74
- "jobs/send_login_link.rb" => "jobs/jobs/send_login_link.rb",
87
+ "app/jobs/send_login_link.rb" => "jobs/app/jobs/send_login_link.rb",
75
88
  }.freeze
76
89
 
77
90
  # --mail --jobs (or --auth --jobs): Monk::Mail.deliver_later, loaded in
@@ -79,34 +92,34 @@ module Monk
79
92
  JOBS_REQUIRE_ANCHOR = %(require "monk/jobs"\n).freeze
80
93
  JOBS_MAIL_REQUIRE = %(require "monk/mail/later"\n).freeze
81
94
 
82
- # --jobs's demo route, added right after this line in config.ru (the
95
+ # --jobs's demo route, added right after this line in app/app.rb (the
83
96
  # base and the --live one both end their routes with it), so the
84
97
  # round trip -- enqueue from a request, run in bin/jobs -- works out of
85
98
  # the box.
86
99
  JOBS_ROUTE_ANCHOR = %( get("/api/hello") { json(message: "hello from monk") }\n).freeze
87
100
  JOBS_ROUTE = <<~RUBY.gsub(/^(?!$)/, " ").freeze
88
101
 
89
- # Enqueues the demo job (jobs/hello_job.rb) for bin/jobs to run:
102
+ # Enqueues the demo job (app/jobs/hello_job.rb) for bin/jobs to run:
90
103
  # curl -X POST "http://localhost:9292/jobs/hello?name=Ann"
91
104
  post("/jobs/hello") { json(enqueued: HelloJob.enqueue(params[:name] || "world")) }
92
105
  RUBY
93
106
 
94
107
  # --live: Monk::Live's demo (a counter whose open tabs update when
95
108
  # 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.
109
+ # rather than patching them -- a live app and index are different
110
+ # files, not one line of diff -- and add two. config.ru stays the base
111
+ # one: config/load.rb is where --live's config gets wired in.
99
112
  LIVE_OVERRIDES = {
100
- "config.ru" => "live/config.ru",
113
+ "app/app.rb" => "live/app/app.rb",
101
114
  "bin/websocket_server" => "live/bin/websocket_server",
102
- "views/index.erb" => "live/views/index.erb",
115
+ "app/views/index.erb" => "live/app/views/index.erb",
103
116
  }.freeze
104
117
 
105
118
  # config/live.rb itself isn't here -- #write_live! picks between
106
119
  # LIVE_CONFIG_TEMPLATES[@live_transport] instead, since which one gets
107
120
  # written depends on --redis vs --postgres, not a fixed mapping.
108
121
  LIVE_FILES = {
109
- "views/live/_hits.erb" => "live/views/live/_hits.erb",
122
+ "app/views/live/_hits.erb" => "live/app/views/live/_hits.erb",
110
123
  }.freeze
111
124
 
112
125
  LIVE_CONFIG_TEMPLATES = {
@@ -219,7 +232,6 @@ module Monk
219
232
 
220
233
  if @postgres
221
234
  POSTGRES_FILES.each { |relative, template| write_file(relative, template, executable: EXECUTABLE_FILES.include?(relative)) }
222
- FileUtils.mkdir_p(File.join(@dir, "db/migrate"))
223
235
  append_gemfile_extra("postgres/Gemfile.extra")
224
236
  end
225
237
 
@@ -235,7 +247,7 @@ module Monk
235
247
  add_deliver_later! if @mail
236
248
  end
237
249
 
238
- wire_config_ru! if @postgres || @mail
250
+ wire_load! if @postgres || @mail || @live
239
251
  append_gemfile_extra("redis/Gemfile.extra") if @redis
240
252
 
241
253
  # --postgres, --redis and --mail are the flags with anything worth
@@ -272,7 +284,7 @@ module Monk
272
284
 
273
285
  def combinations
274
286
  pairs = []
275
- pairs << ["--auth + --jobs", "jobs/send_login_link.rb: login links are sent from a job"] if @auth && @jobs
287
+ pairs << ["--auth + --jobs", "app/jobs/send_login_link.rb: login links are sent from a job"] if @auth && @jobs
276
288
  if @mail && @jobs
277
289
  pairs << ["--mail + --jobs",
278
290
  "config/jobs.rb loads Monk::Mail.deliver_later; JOBS_QUEUES serves mailers first",]
@@ -307,7 +319,7 @@ module Monk
307
319
  end
308
320
 
309
321
  def add_live_to_layout!
310
- path = File.join(@dir, "views/layouts/app.erb")
322
+ path = File.join(@dir, "app/views/layouts/app.erb")
311
323
  content = File.read(path)
312
324
  raise "layout wiring failed: </head> not found" unless content.include?(" </head>\n")
313
325
 
@@ -337,36 +349,37 @@ module Monk
337
349
  File.write(path, content.sub(DOTENV_COMMENTED_LINE, DOTENV_LINE))
338
350
  end
339
351
 
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/
352
+ # config/load.rb ships in BASE_FILES unconditionally (config.ru and the
353
+ # tests require it), so the flags can't gate whether it exists, only
354
+ # which module configs it requires. Unlike BASE_FILES/POSTGRES_FILES/
343
355
  # 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)
356
+ # the alternative (a second, fuller load.rb template per flag
357
+ # combination) would duplicate the whole file for a few lines of diff.
358
+ def wire_load!
359
+ path = File.join(@dir, "config/load.rb")
360
+ settings_require = %(require_relative "settings"\n)
349
361
  requires = +""
350
362
  if @postgres
351
- target = @auth ? "config/auth" : "config/persistence" # config/auth.rb itself require_relative "persistence"
363
+ target = @auth ? "auth" : "persistence" # config/auth.rb itself require_relative "persistence"
352
364
  requires << "require_relative \"#{target}\"\n"
353
365
  end
354
- # config/mail.rb from config.ru only, never from config/auth.rb:
366
+ # config/mail.rb from config/load.rb only, never from config/auth.rb:
355
367
  # bin/websocket_server loads config/auth.rb too and never sends mail,
356
368
  # 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
369
+ requires << "require_relative \"mail\"\n" if @mail
370
+ requires << "require_relative \"jobs\"\n" if @jobs
371
+ requires << "require_relative \"live\"\n" if @live
359
372
 
360
373
  content = File.read(path)
361
- raise "config.ru wiring failed: #{settings_require.inspect} not found" unless content.include?(settings_require)
374
+ raise "config/load.rb wiring failed: #{settings_require.inspect} not found" unless content.include?(settings_require)
362
375
 
363
376
  File.write(path, content.sub(settings_require, "#{settings_require}#{requires}"))
364
377
  end
365
378
 
366
379
  def add_jobs_route!
367
- path = File.join(@dir, "config.ru")
380
+ path = File.join(@dir, "app/app.rb")
368
381
  content = File.read(path)
369
- raise "config.ru wiring failed: #{JOBS_ROUTE_ANCHOR.inspect} not found" unless content.include?(JOBS_ROUTE_ANCHOR)
382
+ raise "app/app.rb wiring failed: #{JOBS_ROUTE_ANCHOR.inspect} not found" unless content.include?(JOBS_ROUTE_ANCHOR)
370
383
 
371
384
  File.write(path, content.sub(JOBS_ROUTE_ANCHOR, "#{JOBS_ROUTE_ANCHOR}#{JOBS_ROUTE}"))
372
385
  end
@@ -419,12 +432,16 @@ module Monk
419
432
  [dev, example].each { |lines| lines.push("JOBS_WORKERS=2", "JOBS_QUEUES=#{queues}") }
420
433
  end
421
434
 
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.
435
+ # REDIS_URL is absent from .env.test for --redis alone -- only a test
436
+ # that actually exercises RedisFanout needs it. --live --redis is
437
+ # different: config/live.rb raises without it, and the test helper
438
+ # loads config/live.rb through config/load.rb. Building the fanout
439
+ # connects to nothing, so tests still need no Redis running unless
440
+ # one publishes.
425
441
  if @redis
426
442
  dev << "REDIS_URL=redis://localhost:6379/0"
427
443
  example << "REDIS_URL=redis://localhost:6379/0"
444
+ test << "REDIS_URL=redis://localhost:6379/0" if live_over_redis?
428
445
  end
429
446
 
430
447
  write_lines(".env", dev)
@@ -480,17 +497,21 @@ module Monk
480
497
 
481
498
  - `config/live.rb` -- the Redis wiring (`Monk::WebSocket::RedisFanout`)
482
499
  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
500
+ - `app/app.rb` -- `POST /hit` changes state and calls `Monk::Live.patch`.
501
+ - `app/views/index.erb` -- `live_topic "hits"` marks what to subscribe to.
502
+ - `app/views/live/_hits.erb` -- the fragment that gets pushed. Partials
486
503
  used this way see only their locals (`locals[:hits]`), never
487
504
  `params` or the session.
505
+ - `bin/websocket_server` -- calls `Monk::Live.listen!` at boot: the
506
+ only process that listens for updates. `bin/server`#{@jobs ? " and `bin/jobs`" : ""} only
507
+ publish.
488
508
  - `public/js/monk_live/` -- the browser runtime, copied from the gem.
489
509
  The layout points at it and at `LIVE_WS_URL` (default
490
510
  `ws://localhost:9293`; use `wss://` in production).
491
511
 
492
512
  `WS_ALLOWED_ORIGINS` (default `http://localhost:9292`) must list the
493
513
  origin your pages are served from, or the browser's socket is refused.
514
+ #{live_auth_setup_paragraph}
494
515
  MARKDOWN
495
516
  end
496
517
 
@@ -517,18 +538,21 @@ module Monk
517
538
 
518
539
  - `config/live.rb` -- the Postgres wiring (`Monk::WebSocket::PgFanout`)
519
540
  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
541
+ - `app/app.rb` -- `POST /hit` changes state and calls `Monk::Live.patch`.
542
+ - `app/views/index.erb` -- `live_topic "hits"` marks what to subscribe to.
543
+ - `app/views/live/_hits.erb` -- the fragment that gets pushed. Partials
523
544
  used this way see only their locals (`locals[:hits]`), never
524
545
  `params` or the session.
546
+ - `bin/websocket_server` -- calls `Monk::Live.listen!` at boot: the
547
+ only process that listens for updates. `bin/server`#{@jobs ? " and `bin/jobs`" : ""} only
548
+ publish.
525
549
  - `public/js/monk_live/` -- the browser runtime, copied from the gem.
526
550
  The layout points at it and at `LIVE_WS_URL` (default
527
551
  `ws://localhost:9293`; use `wss://` in production).
528
552
 
529
553
  `WS_ALLOWED_ORIGINS` (default `http://localhost:9292`) must list the
530
554
  origin your pages are served from, or the browser's socket is refused.
531
-
555
+ #{live_auth_setup_paragraph}
532
556
  A single broadcast is capped at just under 8000 bytes
533
557
  (`Monk::WebSocket::PgFanout::MAX_NOTIFY_PAYLOAD_BYTES`, Postgres's own
534
558
  `NOTIFY` limit) -- far more than this demo ever sends, but worth
@@ -583,8 +607,7 @@ module Monk
583
607
 
584
608
  ENV["MONK_ENV"] ||= "test"
585
609
  #{base_test_dotenv_lines}
586
- require "minitest/autorun"
587
- require_relative "../config/settings"#{%(\nrequire_relative "../config/mail") if @mail}
610
+ #{test_helper_app_lines}
588
611
  ```
589
612
 
590
613
  **Rakefile**:
@@ -600,22 +623,7 @@ module Monk
600
623
  task default: :test
601
624
  ```
602
625
 
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
- ```
626
+ #{app_test_sample}
619
627
 
620
628
  Then:
621
629
 
@@ -656,8 +664,9 @@ module Monk
656
664
  <<~MARKDOWN
657
665
  # Setting up #{app_name} (dev, then test)
658
666
 
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
667
+ `config/load.rb`, `.env`, and `.env.test` are already wired up by `monk new`
668
+ -- `config/load.rb` requires `config/#{@auth ? "auth" : "persistence"}` (and every other config
669
+ you enabled), then loads `app/`, and
661
670
  `.env`/`.env.test` are pre-filled with a database name derived from
662
671
  this project's directory (`#{app_name}_development` / `#{app_name}_test`), not the
663
672
  generic `app_development` fallback baked into `config/persistence.rb`
@@ -719,7 +728,7 @@ module Monk
719
728
  ```bash
720
729
  bin/server # HTTP app on :9292
721
730
  bin/websocket_server # WS chat process on :9293, in another terminal
722
- #{"bin/jobs # background jobs (jobs/), in a third terminal\n" if @jobs}```
731
+ #{"bin/jobs # background jobs (app/jobs/), in a third terminal\n" if @jobs}```
723
732
  #{auth_or_redis_confirmation_note}#{mail_setup_note}#{jobs_setup_note}
724
733
  ## Test environment
725
734
 
@@ -755,7 +764,7 @@ module Monk
755
764
 
756
765
  **test/test_helper.rb** -- loads `.env.test` explicitly (not the default
757
766
  dotenv-in-`config/settings.rb` path, which only loads plain `.env`), then
758
- wires up the app config the same way `config.ru` does:
767
+ loads and boots the app the same way `config.ru` does:
759
768
 
760
769
  ```ruby
761
770
  $LOAD_PATH.unshift(File.expand_path("..", __dir__))
@@ -765,9 +774,7 @@ module Monk
765
774
  require "dotenv"
766
775
  Dotenv.load(File.expand_path(".env.test", __dir__ + "/.."))
767
776
 
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}
777
+ #{test_helper_app_lines}
771
778
  ```
772
779
 
773
780
  **Rakefile**:
@@ -793,6 +800,8 @@ module Monk
793
800
  #{sample_test_body}
794
801
  end
795
802
  ```
803
+
804
+ #{app_test_sample}
796
805
  #{jobs_test_sample}
797
806
  Then:
798
807
 
@@ -811,6 +820,7 @@ module Monk
811
820
  end
812
821
 
813
822
  def auth_or_redis_confirmation_note
823
+ return live_auth_confirmation_note if @live
814
824
  return "" unless @auth || @redis
815
825
 
816
826
  flags = [("authenticate: true" if @auth), ("redis fan-out: on" if @redis)].compact.join(", ")
@@ -824,6 +834,34 @@ module Monk
824
834
  "#{visible} visible to it -- that confirms everything's actually wired up.\n"
825
835
  end
826
836
 
837
+ # Who gets which topics once there are users. Interpolated into a
838
+ # squiggly heredoc, so no indentation of its own.
839
+ def live_auth_setup_paragraph
840
+ return "" unless @auth
841
+
842
+ <<~MARKDOWN
843
+
844
+ **Visitors and logged-in users.** A logged-in user's socket carries
845
+ their identity (the session cookie reaches `:9293` too); a visitor's
846
+ comes in anonymous. Each topic's rule in `config/live.rb` decides:
847
+ `anonymous: true` opens it to visitors (the demo's `hits`), and a
848
+ rule without it is for logged-in users only, e.g. one topic per user.
849
+ `monk new` writes no login routes: see `docs/guides/auth.md` in the
850
+ monk repo, and `docs/guides/live.md`, "Who may subscribe".
851
+ MARKDOWN
852
+ end
853
+
854
+ # The --live bin/websocket_server prints only `authenticate:`, and with
855
+ # --auth it's :optional (a visitor's socket is anonymous, not refused).
856
+ def live_auth_confirmation_note
857
+ return "" unless @auth
858
+
859
+ "\n`bin/websocket_server`'s startup line should print `authenticate: optional` once " \
860
+ "`AUTH_SECRET` is visible to it: a logged-in user's socket carries their identity, and a " \
861
+ "visitor's comes in anonymous, so it gets only the topics `config/live.rb` opens with " \
862
+ "`anonymous: true`.\n"
863
+ end
864
+
827
865
  # Interpolated into a squiggly heredoc, so no leading indentation of
828
866
  # its own (interpolated text isn't dedented).
829
867
  def mail_setup_note
@@ -854,9 +892,9 @@ module Monk
854
892
 
855
893
  `config/jobs.rb` configures `Monk::Jobs` on the app's own database --
856
894
  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:
895
+ which `bin/setup_db` already applied. The job classes live in
896
+ `app/jobs/`, which `config/load.rb` loads. With `bin/server` and
897
+ `bin/jobs` both running, enqueue the demo job and watch it run:
860
898
 
861
899
  ```bash
862
900
  curl -X POST "http://localhost:9292/jobs/hello?name=Ann"
@@ -892,7 +930,7 @@ module Monk
892
930
 
893
931
  <<~MARKDOWN.chomp
894
932
 
895
- Magic links go through a job too, `jobs/send_login_link.rb`: it
933
+ Magic links go through a job too, `app/jobs/send_login_link.rb`: it
896
934
  creates the token and sends the link inside the job, so the raw
897
935
  token is never stored, not even in the queue. Your login route,
898
936
  after its own per-email rate limit, just enqueues it:
@@ -939,11 +977,12 @@ module Monk
939
977
  end
940
978
 
941
979
  # 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.
980
+ # .env.test -- but with --mail it has MAIL_URL=log://, which tests need
981
+ # (they boot outside development, where an unset MAIL_URL raises), and
982
+ # with --live --redis it has REDIS_URL, which config/live.rb needs.
944
983
  # Interpolated into a squiggly heredoc, so no indentation of its own.
945
984
  def base_test_dotenv_lines
946
- return "" unless @mail
985
+ return "" unless @mail || live_over_redis?
947
986
 
948
987
  %(\nrequire "dotenv"\nDotenv.load(File.expand_path(".env.test", __dir__ + "/.."))\n)
949
988
  end
@@ -951,8 +990,8 @@ module Monk
951
990
  def auth_mail_sentence
952
991
  return "" unless @auth
953
992
 
954
- " `config/auth.rb`'s `AppMailer::DELIVER` sends each login link through it " \
955
- "(HTML part: `views/mail/magic_link.erb`)."
993
+ " `AppMailer::MAGIC_LINK` (`app/mailers/app_mailer.rb`) sends each login link through it " \
994
+ "(HTML part: `app/views/mail/magic_link.erb`)."
956
995
  end
957
996
 
958
997
  def sample_test_body
@@ -976,6 +1015,44 @@ module Monk
976
1015
  end
977
1016
  end
978
1017
 
1018
+ def live_over_redis? = @live && @live_transport == :redis
1019
+
1020
+ # The end of every SETUP.md test helper: the app loaded the way
1021
+ # config.ru loads it (config/load.rb, then app/app.rb), booted once.
1022
+ # Interpolated into a squiggly heredoc, so no indentation of its own.
1023
+ def test_helper_app_lines
1024
+ <<~RUBY.chomp
1025
+ require "minitest/autorun"
1026
+ require_relative "../config/load"
1027
+ require_relative "../app/app"
1028
+
1029
+ # Booted once, as config.ru does: request tests call APP.
1030
+ APP = Monk.boot(App)
1031
+ RUBY
1032
+ end
1033
+
1034
+ # A request through the booted app. Every generated app, --live's
1035
+ # included, has GET /hello.
1036
+ def app_test_sample
1037
+ <<~MARKDOWN.chomp
1038
+ **test/app_test.rb** -- a request through the booted app:
1039
+
1040
+ ```ruby
1041
+ require_relative "test_helper"
1042
+ require "rack/mock_request"
1043
+
1044
+ class AppTest < Minitest::Test
1045
+ def test_hello
1046
+ status, _headers, body = APP.call(Rack::MockRequest.env_for("/hello"))
1047
+
1048
+ assert_equal 200, status
1049
+ assert_equal "hello from monk", body.join
1050
+ end
1051
+ end
1052
+ ```
1053
+ MARKDOWN
1054
+ end
1055
+
979
1056
  def write_file(relative_path, template_path, executable: false)
980
1057
  destination = File.join(@dir, relative_path)
981
1058
  FileUtils.mkdir_p(File.dirname(destination))
@@ -0,0 +1,22 @@
1
+ # The emails this app sends, through Monk::Mail (config/mail.rb). Module
2
+ # constants and module methods rather than an instance, since Monk::Auth's
3
+ # deliver: hook must be Ractor-shareable (a lambda built at a file's top
4
+ # level, where self isn't, would fail) -- the same constraint as
5
+ # Monk::Live.authorize blocks.
6
+ module AppMailer
7
+ # Monk::Auth's deliver: hook (config/auth.rb), called by
8
+ # Monk::Auth.deliver_link. The HTML part is
9
+ # app/views/mail/magic_link.erb. Swap the body for SMS or any other
10
+ # channel; see docs/guides/auth.md.
11
+ MAGIC_LINK = lambda do |email:, link:, token:|
12
+ # Development only (a no-op elsewhere): the link on the console, plus a
13
+ # QR code for a phone if rqrcode is in the Gemfile.
14
+ Monk::Auth.log_dev_link(link, subject: email)
15
+ Monk::Mail.deliver(
16
+ to: email,
17
+ subject: "Your login link",
18
+ text: "Here's your login link:\n\n#{link}\n\nIf you didn't ask for it, you can ignore this email.",
19
+ html: Monk::Mail.render("mail/magic_link", link: link),
20
+ )
21
+ end
22
+ end
@@ -7,25 +7,12 @@ 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 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
- module AppMailer
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
28
- end
10
+ # Sends the magic link by email: AppMailer::MAGIC_LINK, in
11
+ # app/mailers/app_mailer.rb. Required here, not left to config/load.rb:
12
+ # Monk::Auth.configure checks deliver: right away, and bin/websocket_server
13
+ # loads this file without config/load.rb. The mailer only touches
14
+ # Monk::Mail when it's called, so that process needs no mail config.
15
+ require_relative "../app/mailers/app_mailer"
29
16
 
30
17
  Monk::Auth.configure(
31
18
  db_name: :primary,
@@ -34,5 +21,11 @@ Monk::Auth.configure(
34
21
  session_ttl: 1_209_600, # seconds a session stays valid (14 days)
35
22
  redirect_allowlist: [], # paths request_login(redirect_to:) is allowed to target
36
23
  secure: !Monk.env.development?, # Secure cookie flag; off in dev so plain-http testing works (Safari drops it)
37
- deliver: AppMailer::DELIVER,
24
+ deliver: AppMailer::MAGIC_LINK,
38
25
  )
26
+
27
+ # bin/websocket_server checks each socket's session through this pool, so
28
+ # it holds 4 connections however many pages are open, instead of one per
29
+ # open page. Declaring it opens nothing: only bin/websocket_server starts
30
+ # it. See docs/guides/persistence.md, "Pools", in the monk repo.
31
+ Monk::Persistence::Pg.pool(:auth, size: 4)
@@ -0,0 +1,9 @@
1
+ class App < Monk::Base
2
+ views "app/views"
3
+ layout "layouts/app"
4
+ assets "public"
5
+
6
+ get("/") { @title = "App"; render "index" }
7
+ get("/hello") { "hello from monk" }
8
+ get("/api/hello") { json(message: "hello from monk") }
9
+ end
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -48,7 +48,20 @@ module ChatServer
48
48
  end
49
49
  end
50
50
 
51
- authenticate = defined?(Monk::Auth) && !!Monk::Auth.config
51
+ # This process holds the sockets, so it's the one that listens for other
52
+ # processes' broadcasts: with REDIS_URL, a subscriber connection, opened
53
+ # here at boot (an unreachable Redis fails right here). A plain Registry
54
+ # has nothing to listen to, and listen! does nothing.
55
+ ChatServer::REGISTRY.listen!
56
+
57
+ # false, not nil, without --auth: Server.new refuses nil.
58
+ authenticate = !!(defined?(Monk::Auth) && Monk::Auth.config)
59
+ # Sessions are checked through config/auth.rb's :auth pool rather than in
60
+ # each socket's own Ractor, which would hold a Postgres connection per
61
+ # open page. This is the only process that uses the pool, so it starts it.
62
+ db_pool = (:auth if authenticate)
63
+ Monk::Persistence::Pg.start_pools!(db_pool) if db_pool
64
+
52
65
  port = ENV.fetch("WS_PORT", "9293").to_i
53
66
  # Defaults to public_url (config/settings.rb) so one PUBLIC_URL env var
54
67
  # keeps this in sync with the app's actual origin instead of two vars
@@ -56,7 +69,9 @@ port = ENV.fetch("WS_PORT", "9293").to_i
56
69
  # more than one origin (comma-separated).
57
70
  allowed_origins = ENV.fetch("WS_ALLOWED_ORIGINS", Monk::Settings[:public_url]).split(",")
58
71
 
59
- server = Monk::WebSocket::Server.new(port: port, authenticate: authenticate, allowed_origins: allowed_origins)
72
+ server = Monk::WebSocket::Server.new(
73
+ port: port, authenticate: authenticate, allowed_origins: allowed_origins, db_pool: db_pool,
74
+ )
60
75
  puts "Monk::WebSocket::Server listening on ws://0.0.0.0:#{port} " \
61
76
  "(authenticate: #{authenticate}, redis fan-out: #{ENV["REDIS_URL"] ? "on" : "off"})"
62
77
  server.run(&ChatServer::HANDLER)
@@ -0,0 +1,18 @@
1
+ # Loads everything the app runs on: the settings, each Monk module's config,
2
+ # then the app's own code under app/. Required by config.ru and the tests;
3
+ # it never boots the app itself (that's config.ru's Monk.boot).
4
+ require_relative "settings"
5
+
6
+ # The app's code, one directory per role, in the order they call each
7
+ # other: presenters read models, helpers, mailers and broadcasts use both,
8
+ # and jobs call all of them. Everything is loaded here, before Monk.boot
9
+ # freezes the app -- nothing can be loaded later from a worker Ractor.
10
+ #
11
+ # The order only matters for code that runs while a file loads (a
12
+ # superclass, a constant in a class body, a Monk::Context.include): such a
13
+ # file require_relative's what it needs first. Code inside methods runs
14
+ # later, so it may use any role, even one loaded after it. See Monk's
15
+ # docs/guides/scaffolding.md, "Where code goes".
16
+ %w[models presenters helpers mailers broadcasts jobs].each do |role|
17
+ Dir[File.expand_path("../app/#{role}/**/*.rb", __dir__)].sort.each { |file| require file }
18
+ end
@@ -1,13 +1,4 @@
1
- require_relative "config/settings"
1
+ require_relative "config/load" # the configs and app/ (config/load.rb)
2
+ require_relative "app/app"
2
3
 
3
- class App < Monk::Base
4
- views "views"
5
- layout "layouts/app"
6
- assets "public"
7
-
8
- get("/") { @title = "App"; render "index" }
9
- get("/hello") { "hello from monk" }
10
- get("/api/hello") { json(message: "hello from monk") }
11
- end
12
-
13
- run Monk.boot(App)
4
+ run Monk.boot(App) # Boot: freezes the app and serves it