monkrb 0.15.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 (80) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +581 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +88 -0
  5. data/exe/monk +116 -0
  6. data/lib/monk/assets.rb +197 -0
  7. data/lib/monk/auth/errors.rb +13 -0
  8. data/lib/monk/auth/helpers.rb +76 -0
  9. data/lib/monk/auth/login_token.rb +11 -0
  10. data/lib/monk/auth/rate_limiter.rb +46 -0
  11. data/lib/monk/auth/session.rb +11 -0
  12. data/lib/monk/auth.rb +301 -0
  13. data/lib/monk/base.rb +323 -0
  14. data/lib/monk/context.rb +78 -0
  15. data/lib/monk/environment.rb +50 -0
  16. data/lib/monk/errors.rb +37 -0
  17. data/lib/monk/freeze_hooks.rb +23 -0
  18. data/lib/monk/live/client/idiomorph.LICENSE +13 -0
  19. data/lib/monk/live/client/idiomorph.js +4 -0
  20. data/lib/monk/live/client/monk_live.js +204 -0
  21. data/lib/monk/live/client/protocol.js +87 -0
  22. data/lib/monk/live/envelope.rb +51 -0
  23. data/lib/monk/live/errors.rb +9 -0
  24. data/lib/monk/live/helpers.rb +22 -0
  25. data/lib/monk/live/policy.rb +58 -0
  26. data/lib/monk/live/publisher.rb +91 -0
  27. data/lib/monk/live/renderer.rb +47 -0
  28. data/lib/monk/live/session.rb +121 -0
  29. data/lib/monk/live.rb +96 -0
  30. data/lib/monk/log.rb +130 -0
  31. data/lib/monk/persistence/errors.rb +7 -0
  32. data/lib/monk/persistence/model.rb +41 -0
  33. data/lib/monk/persistence/pg/errors.rb +4 -0
  34. data/lib/monk/persistence/pg/migrator.rb +165 -0
  35. data/lib/monk/persistence/pg/model.rb +233 -0
  36. data/lib/monk/persistence/pg.rb +34 -0
  37. data/lib/monk/persistence.rb +113 -0
  38. data/lib/monk/scaffold.rb +606 -0
  39. data/lib/monk/settings.rb +151 -0
  40. data/lib/monk/state_ractor.rb +45 -0
  41. data/lib/monk/templates/auth/config/auth.rb +28 -0
  42. data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.down.sql +2 -0
  43. data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.up.sql +18 -0
  44. data/lib/monk/templates/base/.dockerignore +5 -0
  45. data/lib/monk/templates/base/.gitignore +4 -0
  46. data/lib/monk/templates/base/.ruby-version +1 -0
  47. data/lib/monk/templates/base/Dockerfile +28 -0
  48. data/lib/monk/templates/base/Gemfile +7 -0
  49. data/lib/monk/templates/base/bin/server +5 -0
  50. data/lib/monk/templates/base/bin/websocket_server +62 -0
  51. data/lib/monk/templates/base/config/settings.rb +30 -0
  52. data/lib/monk/templates/base/config.ru +13 -0
  53. data/lib/monk/templates/base/public/css/app.css +17 -0
  54. data/lib/monk/templates/base/public/js/app.js +5 -0
  55. data/lib/monk/templates/base/views/index.erb +6 -0
  56. data/lib/monk/templates/base/views/layouts/app.erb +18 -0
  57. data/lib/monk/templates/live/bin/websocket_server +30 -0
  58. data/lib/monk/templates/live/config/live.rb +47 -0
  59. data/lib/monk/templates/live/config.ru +27 -0
  60. data/lib/monk/templates/live/views/index.erb +18 -0
  61. data/lib/monk/templates/live/views/live/_hits.erb +1 -0
  62. data/lib/monk/templates/postgres/Dockerfile +30 -0
  63. data/lib/monk/templates/postgres/Gemfile.extra +2 -0
  64. data/lib/monk/templates/postgres/bin/console +7 -0
  65. data/lib/monk/templates/postgres/bin/migrate +22 -0
  66. data/lib/monk/templates/postgres/bin/setup_db +9 -0
  67. data/lib/monk/templates/postgres/config/persistence.rb +10 -0
  68. data/lib/monk/templates/redis/Gemfile.extra +1 -0
  69. data/lib/monk/version.rb +9 -0
  70. data/lib/monk/views.rb +175 -0
  71. data/lib/monk/websocket/connection.rb +226 -0
  72. data/lib/monk/websocket/errors.rb +9 -0
  73. data/lib/monk/websocket/frame.rb +71 -0
  74. data/lib/monk/websocket/handshake.rb +77 -0
  75. data/lib/monk/websocket/redis_fanout.rb +103 -0
  76. data/lib/monk/websocket/registry.rb +92 -0
  77. data/lib/monk/websocket/server.rb +234 -0
  78. data/lib/monk/websocket.rb +19 -0
  79. data/lib/monk.rb +45 -0
  80. metadata +252 -0
@@ -0,0 +1,151 @@
1
+ require_relative "freeze_hooks"
2
+
3
+ module Monk
4
+ # A configuration facility for app-defined values -- MONK_ENV plus
5
+ # anything else an app needs to configure. Declared once via #configure
6
+ # (a DSL of #required/#optional key declarations) and read back via #[].
7
+ # Deliberately separate from Persistence.register/Auth.configure -- see
8
+ # docs/adr/0006-settings-alongside-persistence-and-auth-config.md.
9
+ module Settings
10
+ # MONK_ENV's fixed set of allowed values (CONTEXT.md's MONK_ENV entry).
11
+ # :monk_env is implicitly declared below -- an app never has to
12
+ # `required :monk_env` itself -- and its value is validated against
13
+ # this set at Boot (#freeze_registry!), not before.
14
+ MONK_ENV_VALUES = %w[development test staging production].freeze
15
+
16
+ # Monk::Log's fixed set of allowed levels, ordered least to most severe.
17
+ # Like :monk_env, :log_level is implicitly declared below and validated
18
+ # against this set at Boot rather than before. Log::LEVELS aliases this
19
+ # constant and reads it from worker Ractors on every #enabled? call, so
20
+ # unlike MONK_ENV_VALUES this needs Ractor.make_shareable, not just
21
+ # #freeze -- an Array#freeze only freezes the array itself, not the
22
+ # strings inside, and a worker reading a non-shareable constant raises
23
+ # Ractor::IsolationError.
24
+ LOG_LEVEL_VALUES = Ractor.make_shareable(%w[debug info warn error])
25
+
26
+ DEFAULT_DECLARATIONS = {
27
+ monk_env: { required: false, default: "development" },
28
+ log_level: { required: false, default: "info" },
29
+ }.freeze
30
+
31
+ # The DSL #configure's block runs against. Kept as its own object,
32
+ # rather than instance_eval'd straight against Settings' singleton
33
+ # class, so #required/#optional don't leak onto Settings' own public
34
+ # interface (#[], #configure) inside the block.
35
+ class DSL
36
+ def initialize(declarations)
37
+ @declarations = declarations
38
+ end
39
+
40
+ def required(key)
41
+ declare(key, required: true, default: nil)
42
+ end
43
+
44
+ def optional(key, default:)
45
+ declare(key, required: false, default: default)
46
+ end
47
+
48
+ private
49
+
50
+ def declare(key, required:, default:)
51
+ key = key.to_sym
52
+ raise DuplicateSettingError, "#{key.inspect} is already declared" if @declarations.key?(key)
53
+
54
+ @declarations[key] = { required: required, default: default }
55
+ end
56
+ end
57
+
58
+ class << self
59
+ def configure(&block)
60
+ raise SettingsFrozenError, "Settings can't be configured after Boot" if booted?
61
+
62
+ DSL.new(declarations).instance_eval(&block)
63
+ end
64
+
65
+ # A key's value. Before Boot, reads ENV live (its uppercased name)
66
+ # or the declared default -- config/settings.rb and boot-time code
67
+ # in config.ru need that. After Boot, reads the frozen snapshot
68
+ # instead: ENV is main-Ractor state, so a per-request read from a
69
+ # worker Ractor is exactly the hazard docs/persistence-ractor-
70
+ # connections.md keeps naming. Reading a key nobody declared raises
71
+ # rather than returning nil, either way.
72
+ def [](key)
73
+ key = key.to_sym
74
+
75
+ if booted?
76
+ raise UnknownSettingError, "no setting #{key.inspect} was declared" unless frozen_values.key?(key)
77
+
78
+ return frozen_values.fetch(key)
79
+ end
80
+
81
+ declaration = declarations.fetch(key) { raise UnknownSettingError, "no setting #{key.inspect} was declared" }
82
+ ENV.fetch(env_var_name(key)) { declaration[:default] }
83
+ end
84
+
85
+ def booted?
86
+ !!@booted
87
+ end
88
+
89
+ # Called from Base#freeze! (Seam B), via Monk.freeze_hooks. Every
90
+ # required key must be present in ENV or this raises naming it;
91
+ # every declared key's resolved value is then frozen and made
92
+ # Ractor.shareable?, so a worker Ractor can read it after Boot
93
+ # without Ractor::IsolationError.
94
+ def freeze_registry!
95
+ values = declarations.each_with_object({}) do |(key, declaration), result|
96
+ if declaration[:required] && !ENV.key?(env_var_name(key))
97
+ raise MissingSettingError, "required setting #{key.inspect} (ENV[#{env_var_name(key).inspect}]) is not set"
98
+ end
99
+
100
+ result[key] = ENV.fetch(env_var_name(key)) { declaration[:default] }
101
+ end
102
+
103
+ validate_monk_env!(values[:monk_env])
104
+ validate_log_level!(values[:log_level])
105
+
106
+ @frozen_values = Ractor.make_shareable(values)
107
+ @booted = true
108
+ end
109
+
110
+ # Test-only.
111
+ def reset!
112
+ @declarations = DEFAULT_DECLARATIONS.dup
113
+ @frozen_values = nil
114
+ @booted = false
115
+ end
116
+
117
+ private
118
+
119
+ def validate_monk_env!(value)
120
+ return if MONK_ENV_VALUES.include?(value)
121
+
122
+ raise InvalidMonkEnvError, "MONK_ENV must be one of #{MONK_ENV_VALUES.join(", ")}, got #{value.inspect}"
123
+ end
124
+
125
+ def validate_log_level!(value)
126
+ return if LOG_LEVEL_VALUES.include?(value)
127
+
128
+ raise InvalidLogLevelError, "LOG_LEVEL must be one of #{LOG_LEVEL_VALUES.join(", ")}, got #{value.inspect}"
129
+ end
130
+
131
+ def declarations
132
+ @declarations ||= DEFAULT_DECLARATIONS.dup
133
+ end
134
+
135
+ # A plain reader, not `@frozen_values ||= ...`: freeze_registry!
136
+ # always sets this alongside @booted, and a lazy write here would
137
+ # be a write to a module ivar from whichever Ractor first reads it
138
+ # after Boot -- exactly the isolation error this method exists to
139
+ # avoid.
140
+ def frozen_values
141
+ @frozen_values
142
+ end
143
+
144
+ def env_var_name(key)
145
+ key.to_s.upcase
146
+ end
147
+ end
148
+
149
+ Monk.freeze_hooks << self
150
+ end
151
+ end
@@ -0,0 +1,45 @@
1
+ module Monk
2
+ class StateRactor
3
+ def initialize(initial_value)
4
+ @ractor = Ractor.new(initial_value) do |value|
5
+ loop do
6
+ op, arg, reply_port = Ractor.receive
7
+ case op
8
+ when :value
9
+ reply_port.send(value)
10
+ when :update
11
+ value = arg.call(value)
12
+ reply_port.send(value)
13
+ end
14
+ end
15
+ end
16
+ freeze
17
+ end
18
+
19
+ def value
20
+ ask(:value)
21
+ end
22
+
23
+ def update(&block)
24
+ begin
25
+ shareable_block = Ractor.make_shareable(block)
26
+ rescue ArgumentError, Ractor::IsolationError => e
27
+ raise UnshareableBlockError,
28
+ "StateRactor#update block is not Ractor-shareable: #{e.message} " \
29
+ "(build it where self is shareable, e.g. at app-definition time, not inline in a route handler)"
30
+ end
31
+
32
+ ask(:update, shareable_block)
33
+ end
34
+
35
+ private
36
+
37
+ def ask(op, arg = nil)
38
+ reply_port = Ractor::Port.new
39
+ @ractor.send([op, arg, reply_port])
40
+ reply_port.receive
41
+ ensure
42
+ reply_port&.close
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,28 @@
1
+ require "monk"
2
+ require "monk/auth"
3
+ require_relative "persistence"
4
+
5
+ # config/settings.rb already declares public_url -- the trusted origin for
6
+ # the magic link your own login route builds (e.g.
7
+ # "#{Monk::Settings[:public_url]}/auth/callback/#{token}"), see its comment
8
+ # there and auth.md's "Sending the magic link".
9
+
10
+ # Sends the magic link once you have real delivery (email, SMS, ...) --
11
+ # a module constant, not a lambda inline in Auth.configure below, since
12
+ # self at this file's top level isn't Ractor-shareable (same constraint as
13
+ # Monk::Live.authorize blocks). Until this is wired up for real,
14
+ # Monk::Auth.deliver_link falls back to Monk::Auth.log_dev_link in
15
+ # development and raises everywhere else -- see docs/guides/auth.md.
16
+ module AppMailer
17
+ # DELIVER = ->(email:, link:, token:) { YourMailer.magic_link(email, link) }
18
+ end
19
+
20
+ Monk::Auth.configure(
21
+ db_name: :primary,
22
+ secret: ENV.fetch("AUTH_SECRET"),
23
+ login_ttl: 600, # seconds a login token stays redeemable
24
+ session_ttl: 1_209_600, # seconds a session stays valid (14 days)
25
+ redirect_allowlist: [], # paths request_login(redirect_to:) is allowed to target
26
+ secure: !Monk.env.development?, # Secure cookie flag; off in dev so plain-http testing works (Safari drops it)
27
+ # deliver: AppMailer::DELIVER,
28
+ )
@@ -0,0 +1,2 @@
1
+ DROP TABLE sessions;
2
+ DROP TABLE login_tokens;
@@ -0,0 +1,18 @@
1
+ CREATE TABLE login_tokens (
2
+ id BIGSERIAL PRIMARY KEY,
3
+ email TEXT NOT NULL,
4
+ token_hash TEXT NOT NULL UNIQUE,
5
+ redirect_to TEXT,
6
+ expires_at TIMESTAMPTZ NOT NULL,
7
+ used_at TIMESTAMPTZ,
8
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
9
+ );
10
+
11
+ CREATE TABLE sessions (
12
+ id BIGSERIAL PRIMARY KEY,
13
+ subject TEXT NOT NULL,
14
+ token_hash TEXT NOT NULL UNIQUE,
15
+ expires_at TIMESTAMPTZ NOT NULL,
16
+ revoked_at TIMESTAMPTZ,
17
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
18
+ );
@@ -0,0 +1,5 @@
1
+ .git
2
+ log/
3
+ .env
4
+ .env.*
5
+ !.env.example
@@ -0,0 +1,4 @@
1
+ /log/
2
+ /.env
3
+ /.env.*
4
+ !/.env.example
@@ -0,0 +1 @@
1
+ 4.0.6
@@ -0,0 +1,28 @@
1
+ # Two-stage build: the builder resolves and installs gems, the final image
2
+ # only keeps the installed bundle plus the app -- no compiler, no gem
3
+ # cache, in the image that actually runs. No system packages needed here:
4
+ # kino ships as precompiled platform gems, and a plain `bundle install`
5
+ # (Gemfile.lock committed) already resolves every compatible platform, not
6
+ # just the machine that ran it -- nothing to add by hand before building on
7
+ # a different host (docs/guides/deploying.md, "Before the first build").
8
+ # monk/rackup/webrick are pure Ruby. The --postgres/--auth scaffold
9
+ # overrides this file to add libpq for the pg gem's native extension.
10
+ FROM ruby:4.0-slim AS builder
11
+ WORKDIR /app
12
+
13
+ COPY Gemfile Gemfile.lock* ./
14
+ RUN bundle install
15
+
16
+ FROM ruby:4.0-slim
17
+ WORKDIR /app
18
+
19
+ COPY --from=builder /usr/local/bundle /usr/local/bundle
20
+ COPY . .
21
+
22
+ # Matches bin/server's own default (PORT overrides both, e.g. on a
23
+ # platform that assigns its own). bin/websocket_server (WS_PORT default
24
+ # 9293) is a separate process/deploy unit -- run this same image with
25
+ # `bin/websocket_server` as the command instead of publishing another
26
+ # port from this Dockerfile -- see docs/guides/deploying.md.
27
+ EXPOSE 9292
28
+ CMD ["bin/server", "--bind", "0.0.0.0"]
@@ -0,0 +1,7 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "monkrb", require: "monk"
4
+ gem "rackup"
5
+ gem "webrick"
6
+ gem "kino"
7
+ # gem "dotenv" # uncomment to load a local .env file (config/settings.rb)
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ cd "$(dirname "$0")/.."
4
+
5
+ exec bundle exec kino -p "${PORT:-9292}" "$@" config.ru
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env ruby
2
+ require "bundler/setup"
3
+ require_relative "../config/settings"
4
+
5
+ # config/auth.rb only exists if this app was scaffolded with --auth -- a
6
+ # missing file is a harmless no-op, the same way config/settings.rb treats
7
+ # a missing dotenv gem. Present or not, it's what decides whether this
8
+ # process authenticates connections below.
9
+ begin
10
+ require_relative "../config/auth"
11
+ rescue LoadError
12
+ end
13
+
14
+ require "monk/websocket"
15
+
16
+ # A single broadcast chat channel: every connection subscribes to :chat,
17
+ # and whatever one connection sends is broadcast to everyone on it
18
+ # (including itself), prefixed with the authenticated subject when there
19
+ # is one.
20
+ #
21
+ # Defined inside a module, not at this script's own top level -- self at a
22
+ # script's top level is the main object, which is *not* Ractor-shareable,
23
+ # so a proc built there fails Server#run's Ractor.make_shareable check.
24
+ # Module bodies are always shareable, same as StateRactor#update's block
25
+ # requirement.
26
+ module ChatServer
27
+ # REDIS_URL set (by an app scaffolded with --redis, or by hand) wraps the
28
+ # registry so :chat also fans out to every other Monk::WebSocket::Server
29
+ # process sharing that Redis -- unset, it's in-process only. Either way
30
+ # the app code above never has to know which: RedisFanout has the exact
31
+ # same #register/#unregister/#count/#broadcast interface as Registry.
32
+ REGISTRY =
33
+ if (redis_url = ENV["REDIS_URL"])
34
+ require "monk/websocket/redis_fanout"
35
+ Monk::WebSocket::RedisFanout.new(Monk::WebSocket::Registry.new, redis_url: redis_url)
36
+ else
37
+ Monk::WebSocket::Registry.new
38
+ end
39
+
40
+ HANDLER = proc do |connection|
41
+ connection.subscribe(REGISTRY, :chat)
42
+ loop do
43
+ message = connection.read
44
+ break unless message
45
+
46
+ REGISTRY.broadcast(:chat, "#{connection.subject || "guest"}: #{message}")
47
+ end
48
+ end
49
+ end
50
+
51
+ authenticate = defined?(Monk::Auth) && !!Monk::Auth.config
52
+ port = ENV.fetch("WS_PORT", "9293").to_i
53
+ # Defaults to public_url (config/settings.rb) so one PUBLIC_URL env var
54
+ # keeps this in sync with the app's actual origin instead of two vars
55
+ # drifting apart; set WS_ALLOWED_ORIGINS directly for anything more, e.g.
56
+ # more than one origin (comma-separated).
57
+ allowed_origins = ENV.fetch("WS_ALLOWED_ORIGINS", Monk::Settings[:public_url]).split(",")
58
+
59
+ server = Monk::WebSocket::Server.new(port: port, authenticate: authenticate, allowed_origins: allowed_origins)
60
+ puts "Monk::WebSocket::Server listening on ws://0.0.0.0:#{port} " \
61
+ "(authenticate: #{authenticate}, redis fan-out: #{ENV["REDIS_URL"] ? "on" : "off"})"
62
+ server.run(&ChatServer::HANDLER)
@@ -0,0 +1,30 @@
1
+ # Loads a local .env file in development, if the app has uncommented the
2
+ # dotenv gem in its Gemfile (see the commented-out line there) and added
3
+ # a .env file of its own. A missing .env, or the gem not being in the
4
+ # bundle at all, is a harmless no-op either way -- production deploys
5
+ # get their env vars from the hosting platform directly, not from here.
6
+ begin
7
+ require "dotenv/load"
8
+ rescue LoadError
9
+ end
10
+
11
+ require "monk"
12
+
13
+ Monk::Settings.configure do
14
+ # This app's own public origin -- the one trusted source for building an
15
+ # absolute URL back to itself (a magic link, the WebSocket URL a browser
16
+ # should open), instead of request headers like X-Forwarded-Proto/Host,
17
+ # which any direct client can spoof. Declared here, not in config/auth.rb
18
+ # or config/live.rb, since both read it: auth.md's "Sending the magic
19
+ # link" and live.md's `live_ws_url`. Set to your real https:// origin
20
+ # outside development.
21
+ optional :public_url, default: "http://localhost:9292"
22
+ end
23
+
24
+ # Declare more of the app's own settings here, read anywhere via
25
+ # Monk::Settings[:key] or, per-request, Context#settings[:key]:
26
+ #
27
+ # Monk::Settings.configure do
28
+ # required :api_key
29
+ # optional :port, default: "9292"
30
+ # end
@@ -0,0 +1,13 @@
1
+ require_relative "config/settings"
2
+
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)
@@ -0,0 +1,17 @@
1
+ :root { --bg: #fbfbfa; --fg: #1c1c1a; --muted: #6b6b66; }
2
+
3
+ @media (prefers-color-scheme: dark) {
4
+ :root { --bg: #14140f; --fg: #eceae2; --muted: #97958c; }
5
+ }
6
+
7
+ body {
8
+ margin: 0;
9
+ background: var(--bg);
10
+ color: var(--fg);
11
+ font: 16px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif;
12
+ }
13
+
14
+ main { max-width: 42rem; margin: 0 auto; padding: 3rem 1.5rem; }
15
+ h1 { font-size: 2rem; letter-spacing: -0.02em; margin: 0 0 0.5rem; }
16
+ p { color: var(--muted); }
17
+ code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
@@ -0,0 +1,5 @@
1
+ // Served as-is and loaded as an ES module -- no build step. Import your
2
+ // own files by relative path with a real extension
3
+ // (import { x } from "./x.js"), and anything third-party through the
4
+ // import map in views/layouts/app.erb.
5
+ console.log("monk");
@@ -0,0 +1,6 @@
1
+ <h1>It works</h1>
2
+ <p>
3
+ Edit <code>views/index.erb</code> and restart the server to see this
4
+ change. Templates compile once at boot; <code>&lt;%= %&gt;</code>
5
+ escapes by default, <code>raw(...)</code> opts out.
6
+ </p>
@@ -0,0 +1,18 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title><%= @title %></title>
7
+ <link rel="stylesheet" href="<%= asset_path "/css/app.css" %>">
8
+ <%# No bundler, no node_modules: third-party JS is a bare specifier
9
+ mapped here to a file served out of public/. %>
10
+ <script type="importmap">
11
+ { "imports": {} }
12
+ </script>
13
+ <script type="module" src="<%= asset_path "/js/app.js" %>"></script>
14
+ </head>
15
+ <body>
16
+ <main><%= yield %></main>
17
+ </body>
18
+ </html>
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env ruby
2
+ require "bundler/setup"
3
+ require_relative "../config/settings"
4
+
5
+ # config/auth.rb only exists if this app was scaffolded with --auth -- a
6
+ # missing file is a harmless no-op, the same way config/settings.rb treats
7
+ # a missing dotenv gem. Present or not, it's what decides whether this
8
+ # process authenticates connections below.
9
+ begin
10
+ require_relative "../config/auth"
11
+ rescue LoadError
12
+ end
13
+
14
+ # Monk::Live: the browsers' sockets end here. The handler takes their
15
+ # subscribe/unsubscribe messages, checks each topic against the rules in
16
+ # config/live.rb, and relays whatever config.ru's process publishes over
17
+ # Redis.
18
+ require_relative "../config/live"
19
+
20
+ authenticate = defined?(Monk::Auth) && !!Monk::Auth.config
21
+ port = ENV.fetch("WS_PORT", "9293").to_i
22
+ # Defaults to public_url (config/settings.rb) so one PUBLIC_URL env var
23
+ # keeps this in sync with the app's actual origin instead of two vars
24
+ # drifting apart; set WS_ALLOWED_ORIGINS directly for anything more, e.g.
25
+ # more than one origin (comma-separated).
26
+ allowed_origins = ENV.fetch("WS_ALLOWED_ORIGINS", Monk::Settings[:public_url]).split(",")
27
+
28
+ server = Monk::WebSocket::Server.new(port: port, authenticate: authenticate, allowed_origins: allowed_origins)
29
+ puts "Monk::Live WebSocket server listening on ws://0.0.0.0:#{port} (authenticate: #{authenticate})"
30
+ server.run(&Monk::Live::HANDLER)
@@ -0,0 +1,47 @@
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 Redis is what carries a publish from one to
6
+ # the other -- REDIS_URL (set in .env by `monk new --live`) is required.
7
+ require_relative "settings"
8
+ require "monk/live"
9
+ require "monk/websocket/redis_fanout"
10
+
11
+ # Where the browser opens its WebSocket (read by the layout). In
12
+ # development there's no reverse proxy in front, so this is the direct WS
13
+ # port; outside development it defaults to a /ws path under public_url
14
+ # (config/settings.rb) -- matching the path-based proxy routing
15
+ # docs/guides/deploying.md's "Adding Monk::WebSocket" section sets up, so
16
+ # setting PUBLIC_URL alone keeps this in sync instead of two env vars
17
+ # drifting apart. Override with LIVE_WS_URL directly if your setup doesn't
18
+ # fit that shape.
19
+ default_live_ws_url =
20
+ if Monk.env.development?
21
+ "ws://localhost:9293"
22
+ else
23
+ Monk::Settings[:public_url].sub(/\Ahttps:\/\//, "wss://").sub(/\Ahttp:\/\//, "ws://") + "/ws"
24
+ end
25
+
26
+ Monk::Settings.configure do
27
+ optional :live_ws_url, default: default_live_ws_url
28
+ end
29
+
30
+ redis_url = ENV["REDIS_URL"] or raise "Monk::Live needs REDIS_URL: bin/server and bin/websocket_server " \
31
+ "are separate processes, and Redis is how an update published in one reaches a socket in the other"
32
+
33
+ Monk::Live.configure(
34
+ registry: Monk::WebSocket::RedisFanout.new(Monk::WebSocket::Registry.new, redis_url: redis_url),
35
+ )
36
+
37
+ # Who may subscribe to what. Nothing is allowed unless a rule says so, and an
38
+ # anonymous connection is denied unless the rule opts in with `anonymous: true`.
39
+ # The rule blocks live in a module because they have to be Ractor-shareable
40
+ # (self at the top of this file is not).
41
+ module AppLive
42
+ # The demo counter is public. A real rule looks at who is asking:
43
+ # proc { |subject, topic| topic == "contacts:#{subject}" }
44
+ PUBLIC = proc { |_subject, _topic| true }
45
+ end
46
+
47
+ Monk::Live.authorize("hits", anonymous: true, &AppLive::PUBLIC)
@@ -0,0 +1,27 @@
1
+ require_relative "config/settings"
2
+ require_relative "config/live"
3
+
4
+ class App < Monk::Base
5
+ views "views"
6
+ layout "layouts/app"
7
+ assets "public"
8
+
9
+ # State shared across requests lives in a StateRactor; the update block is
10
+ # built here, where self is shareable, not inline in the route.
11
+ hits = Monk::StateRactor.new(0)
12
+ increment = Ractor.make_shareable(proc { |n| n + 1 })
13
+
14
+ get("/") { @title = "App"; render "index", hits: hits.value }
15
+
16
+ # Change state, then tell every open page that shows it. Monk::Live.patch
17
+ # renders the partial once and pushes it to the "hits" topic's subscribers.
18
+ post("/hit") do
19
+ Monk::Live.patch("hits", to: "#hits", partial: "live/_hits", hits: hits.update(&increment))
20
+ redirect "/"
21
+ end
22
+
23
+ get("/hello") { "hello from monk" }
24
+ get("/api/hello") { json(message: "hello from monk") }
25
+ end
26
+
27
+ run Monk.boot(App)
@@ -0,0 +1,18 @@
1
+ <h1>Monk::Live</h1>
2
+ <p>
3
+ Open this page in two tabs and press the button in one: the counter in the
4
+ other updates by itself. The server pushes the new HTML over a WebSocket;
5
+ there is no polling and no page JavaScript of your own.
6
+ </p>
7
+
8
+ <%# live_topic makes this element's page subscribe to the "hits" topic. %>
9
+ <p <%= live_topic "hits" %>>Hits so far: <%= render "live/_hits", hits: locals[:hits], layout: false %></p>
10
+
11
+ <form method="post" action="/hit"><button>+1</button></form>
12
+
13
+ <p>
14
+ The pieces: <code>live_topic</code> in this view, the partial
15
+ <code>views/live/_hits.erb</code>, <code>Monk::Live.patch</code> in
16
+ <code>POST /hit</code> (<code>config.ru</code>), and the rules and Redis
17
+ wiring in <code>config/live.rb</code>.
18
+ </p>
@@ -0,0 +1 @@
1
+ <strong id="hits"><%= locals[:hits] %></strong>
@@ -0,0 +1,30 @@
1
+ # Same shape as the base Dockerfile, plus libpq: the pg gem's native
2
+ # extension needs libpq-dev (headers) to compile in the builder stage and
3
+ # libpq5 (the runtime lib alone, no headers) in the final image.
4
+ FROM ruby:4.0-slim AS builder
5
+ WORKDIR /app
6
+
7
+ RUN apt-get update -qq \
8
+ && apt-get install -y --no-install-recommends build-essential libpq-dev \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ COPY Gemfile Gemfile.lock* ./
12
+ RUN bundle install
13
+
14
+ FROM ruby:4.0-slim
15
+ WORKDIR /app
16
+
17
+ RUN apt-get update -qq \
18
+ && apt-get install -y --no-install-recommends libpq5 \
19
+ && rm -rf /var/lib/apt/lists/*
20
+
21
+ COPY --from=builder /usr/local/bundle /usr/local/bundle
22
+ COPY . .
23
+
24
+ # Matches bin/server's own default (PORT overrides both, e.g. on a
25
+ # platform that assigns its own). bin/websocket_server (WS_PORT default
26
+ # 9293) is a separate process/deploy unit -- run this same image with
27
+ # `bin/websocket_server` as the command instead of publishing another
28
+ # port from this Dockerfile -- see docs/guides/deploying.md.
29
+ EXPOSE 9292
30
+ CMD ["bin/server", "--bind", "0.0.0.0"]
@@ -0,0 +1,2 @@
1
+ gem "pg"
2
+ gem "irb"
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ require "bundler/setup"
3
+ require_relative "../config/settings"
4
+ require_relative "../config/persistence"
5
+ require "irb"
6
+
7
+ IRB.start(__FILE__)