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,103 @@
1
+ require "redis"
2
+ require "securerandom"
3
+
4
+ module Monk
5
+ module WebSocket
6
+ # Cross-process fan-out for Registry, opt-in: require
7
+ # "monk/websocket/redis_fanout" explicitly -- "monk/websocket" alone
8
+ # does not load this (docs/history/plan-websocket.md Phase 6, chosen over Postgres
9
+ # LISTEN/NOTIFY per docs/design/websocket.md Open Question 3).
10
+ #
11
+ # Wraps a Registry with the same public interface (#register,
12
+ # #unregister, #count, #broadcast), so an app swaps which object it
13
+ # holds and nothing else changes. #broadcast still delivers to this
14
+ # process's local Registry directly -- same latency and reliability as
15
+ # a plain Registry if Redis is briefly unavailable -- and additionally
16
+ # publishes to Redis so sibling WS processes' subscriber Ractors
17
+ # deliver it to their own local connections.
18
+ #
19
+ # Every published payload is tagged with this instance's origin id so
20
+ # the subscriber ignores messages that are this process's own
21
+ # broadcast echoed back over Redis -- without the tag, a broadcast
22
+ # would reach local connections twice (once directly, once via the
23
+ # round trip through Redis).
24
+ #
25
+ # #broadcast/#register keys must be Symbols. A Redis channel name is
26
+ # always a String, so the subscriber has to pick one Ruby type to
27
+ # convert the recovered channel back into -- Symbol, matching every
28
+ # existing caller (Registry's own tests, the websocket_server
29
+ # scaffold's :chat channel). A String-keyed #register(...) would still
30
+ # get this process's own direct/local deliveries but never a relayed
31
+ # one, since the relayed call always uses a Symbol.
32
+ class RedisFanout
33
+ # Frozen explicitly, not just left as a String literal -- the
34
+ # subscriber Ractor below reads this constant from a non-main
35
+ # Ractor, which raises Ractor::IsolationError unless its value is
36
+ # already shareable.
37
+ CHANNEL_PREFIX = "monk:ws:".freeze
38
+
39
+ def initialize(registry, redis_url:)
40
+ @registry = registry
41
+ @redis_url = redis_url.dup.freeze
42
+ @origin = SecureRandom.uuid.freeze
43
+
44
+ # Ractor.new's block args must be shareable: registry is (Registry
45
+ # freezes itself around a Ractor, which is inherently shareable),
46
+ # redis_url and @origin are plain frozen Strings. The Redis client
47
+ # itself is built inside the block, never passed in -- mirrors
48
+ # PG::Connection's per-Ractor pattern (persistence/pg.rb).
49
+ @subscriber = Ractor.new(registry, @redis_url, @origin) do |registry, url, origin|
50
+ Redis.new(url: url).psubscribe("#{Monk::WebSocket::RedisFanout::CHANNEL_PREFIX}*") do |on|
51
+ on.pmessage do |_pattern, channel, envelope|
52
+ sender, payload = envelope.split("\0", 2)
53
+ next if sender == origin
54
+
55
+ # Redis channel names are always Strings, but Registry's own
56
+ # keys are ordinary Hash keys the app chooses -- every
57
+ # existing caller (Registry's own tests, the websocket_server
58
+ # scaffold's :chat channel) uses a Symbol. #to_sym here is
59
+ # what makes registry.broadcast(:chat, ...) land on the same
60
+ # Hash key #register(:chat, port) used -- without it this
61
+ # silently broadcasts to an unrelated "chat" String key that
62
+ # nothing is ever registered under, and #broadcast still
63
+ # returns true either way (Registry#broadcast succeeds
64
+ # whether or not any port is registered for a key).
65
+ key = channel.delete_prefix(Monk::WebSocket::RedisFanout::CHANNEL_PREFIX).to_sym
66
+ registry.broadcast(key, payload)
67
+ end
68
+ end
69
+ end
70
+
71
+ # No live connection lives on this object -- #publisher below opens
72
+ # one per Ractor, lazily -- so there's nothing left unshareable and
73
+ # this instance can freeze itself the same way Registry does. That
74
+ # matters for real use: an app holds this behind a module constant
75
+ # (e.g. ChatServer::REGISTRY, mirroring Registry's own existing
76
+ # usage) read from every connection's own Ractor, which raises
77
+ # Ractor::IsolationError unless the constant's value is shareable.
78
+ freeze
79
+ end
80
+
81
+ def register(key, port) = @registry.register(key, port)
82
+ def unregister(key, port) = @registry.unregister(key, port)
83
+ def count(key) = @registry.count(key)
84
+
85
+ def broadcast(key, payload)
86
+ @registry.broadcast(key, payload)
87
+ publisher.publish("#{CHANNEL_PREFIX}#{key}", "#{@origin}\0#{payload}")
88
+ end
89
+
90
+ private
91
+
92
+ # A single shared Redis client called concurrently from every
93
+ # connection Ractor's #broadcast wouldn't be safe -- redis-rb clients
94
+ # aren't documented safe for concurrent commands from multiple
95
+ # threads, let alone Ractors. One client per calling Ractor, opened
96
+ # on first use and cached there, mirrors
97
+ # Monk::Persistence::Registry's #ractor_local/#entry exactly.
98
+ def publisher
99
+ Ractor.current[:monk_redis_fanout_publisher] ||= Redis.new(url: @redis_url)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,92 @@
1
+ module Monk
2
+ module WebSocket
3
+ # A dedicated Ractor holding the live set of connection handles keyed by
4
+ # an app-assigned channel/subject -- mirrors StateRactor's shape
5
+ # (docs/design/ractor.md): the mutable Hash stays hidden inside this one
6
+ # Ractor, and every call is a synchronous "ask" via a fresh
7
+ # Ractor::Port, so registration/broadcast can never race each other.
8
+ class Registry
9
+ def initialize
10
+ @ractor = Ractor.new do
11
+ keys = Hash.new { |h, k| h[k] = [] }
12
+
13
+ loop do
14
+ op, key, arg, reply_port = Ractor.receive
15
+ result =
16
+ case op
17
+ when :register
18
+ keys[key] << arg unless keys[key].include?(arg)
19
+ true
20
+ when :unregister
21
+ keys[key].delete(arg)
22
+ true
23
+ when :broadcast
24
+ # A port can be closed out from under this loop without
25
+ # ever going through #unregister -- e.g. the owning
26
+ # connection Ractor dies before its own cleanup runs.
27
+ # port.send on a closed port raises Ractor::ClosedError;
28
+ # left unguarded, that exception would propagate out of
29
+ # this single-threaded loop and kill the *registry*
30
+ # Ractor, taking down delivery for every key, not just
31
+ # this one. Rescuing it here, per port, keeps the
32
+ # broadcast going for every other subscriber and also
33
+ # drops the dead port from keys[key] -- self-healing the
34
+ # stale entry #unregister was supposed to have caught.
35
+ keys[key].reject! do |port|
36
+ port.send(arg)
37
+ false
38
+ rescue Ractor::ClosedError
39
+ true
40
+ end
41
+ true
42
+ when :count
43
+ keys[key].size
44
+ end
45
+ reply_port.send(result)
46
+ end
47
+ end
48
+ freeze
49
+ end
50
+
51
+ def register(key, port)
52
+ ask(:register, key, port)
53
+ end
54
+
55
+ def unregister(key, port)
56
+ ask(:unregister, key, port)
57
+ end
58
+
59
+ def broadcast(key, payload)
60
+ ask(:broadcast, key, payload)
61
+ end
62
+
63
+ # Not part of Phase 4's own plan bullets, but needed to actually
64
+ # observe step 17's "a crashed handler never leaks a stale registry
65
+ # entry" claim from outside the registry, rather than trusting it.
66
+ def count(key)
67
+ ask(:count, key, nil)
68
+ end
69
+
70
+ private
71
+
72
+ def ask(op, key, arg)
73
+ reply_port = Ractor::Port.new
74
+ @ractor.send([op, key, arg, reply_port])
75
+ reply_port.receive
76
+ rescue Ractor::ClosedError
77
+ # The registry Ractor itself can be gone by the time this runs --
78
+ # e.g. process-wide shutdown (Ctrl+C) tearing down Ractors in
79
+ # whatever order, with a connection's own cleanup (Connection#
80
+ # unsubscribe!, called from Server.serve's ensure) losing the race
81
+ # against the registry's. Nothing on the other end to reply to a
82
+ # dead call anyway; nil is indistinguishable from "the process is
83
+ # already exiting" to every caller here (register/unregister/
84
+ # broadcast ignore their return value, and #count is never read
85
+ # this late).
86
+ nil
87
+ ensure
88
+ reply_port&.close
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,234 @@
1
+ require "socket"
2
+
3
+ module Monk
4
+ module WebSocket
5
+ # Intended to be the entire body of a small standalone script (e.g.
6
+ # bin/websocket_server) -- never embedded in the same process as Kino
7
+ # (docs/history/plan-websocket.md Decision 1).
8
+ class Server
9
+ # allowed_origins: and authenticate: implement docs/history/plan-websocket.md
10
+ # Phase 5 (identity via Monk::Auth, reused unmodified -- Decision
11
+ # 5). authenticate: defaults false so a plain server (Phases 1-4)
12
+ # behaves exactly as before; turning it on requires Monk::Auth to
13
+ # already be configured, fail-fast the same way Monk::Auth's own
14
+ # methods do (ADR 0003) rather than failing obscurely on the first
15
+ # connection.
16
+ #
17
+ # ping_interval: (seconds) opts a connection into a server-sent
18
+ # heartbeat ping -- see Connection#start_heartbeat. Defaults to nil
19
+ # (disabled) for the same reason authenticate: defaults false: a
20
+ # server that doesn't ask for it behaves exactly as before.
21
+ #
22
+ # reverify_interval: (seconds) re-runs Monk::Auth.verify against the
23
+ # same credential on that cadence for as long as the connection
24
+ # stays open, closing it the moment verify comes back nil (gap 4,
25
+ # docs/history/chat-gap-analysis.md) -- authenticate: true only checks the
26
+ # credential once, at the handshake, so a session revoked or
27
+ # expired afterward would otherwise leave the socket live
28
+ # indefinitely. Requires authenticate: true (there's no credential
29
+ # to reverify without it) and defaults to nil (disabled), same as
30
+ # ping_interval:.
31
+ #
32
+ # max_payload_size: (bytes) -- see Connection::DEFAULT_MAX_PAYLOAD_SIZE
33
+ # for what it guards against (gap 5). Unlike ping_interval:/
34
+ # reverify_interval:, always on: this is insurance against a
35
+ # hostile client on a public endpoint, not an opt-in behavior
36
+ # change, so a server that doesn't mention it still gets the
37
+ # default cap rather than no cap at all.
38
+ def initialize(
39
+ port:, bind: "0.0.0.0", allowed_origins: nil, authenticate: false, ping_interval: nil,
40
+ reverify_interval: nil, max_payload_size: Connection::DEFAULT_MAX_PAYLOAD_SIZE
41
+ )
42
+ if ping_interval && !ping_interval.positive?
43
+ raise ArgumentError, "ping_interval must be positive, got #{ping_interval.inspect}"
44
+ end
45
+
46
+ if reverify_interval
47
+ unless reverify_interval.positive?
48
+ raise ArgumentError, "reverify_interval must be positive, got #{reverify_interval.inspect}"
49
+ end
50
+ raise ArgumentError, "reverify_interval requires authenticate: true" unless authenticate
51
+ end
52
+
53
+ unless max_payload_size.positive?
54
+ raise ArgumentError, "max_payload_size must be positive, got #{max_payload_size.inspect}"
55
+ end
56
+
57
+ if authenticate
58
+ unless defined?(Monk::Auth) && Monk::Auth.config
59
+ raise Monk::AuthNotConfiguredError,
60
+ "Monk::WebSocket::Server.new(authenticate: true) requires Monk::Auth to already be " \
61
+ "configured -- call Monk::Auth.configure first"
62
+ end
63
+
64
+ # Monk::Auth's config is a plain, unfrozen Hash until this runs
65
+ # (lib/monk/freeze_hooks.rb) -- without it, the first
66
+ # Monk::Auth.verify call from inside a connection Ractor raises
67
+ # Ractor::IsolationError (docs/history/plan-websocket.md Phase 5 step 20).
68
+ # Called here, not left to the boot script to remember, the same
69
+ # way Base.call already auto-freezes on first use rather than
70
+ # trusting every app to call Base.freeze! itself.
71
+ Monk.freeze!
72
+ end
73
+
74
+ @tcp_server = TCPServer.new(bind, port)
75
+ @allowed_origins = allowed_origins ? Ractor.make_shareable(allowed_origins.dup) : nil
76
+ @authenticate = authenticate
77
+ @ping_interval = ping_interval
78
+ @reverify_interval = reverify_interval
79
+ @max_payload_size = max_payload_size
80
+ end
81
+
82
+ def port
83
+ @tcp_server.addr[1]
84
+ end
85
+
86
+ def run(&block)
87
+ shareable_block =
88
+ begin
89
+ Ractor.make_shareable(block)
90
+ rescue ArgumentError, Ractor::IsolationError => e
91
+ raise Monk::UnshareableBlockError,
92
+ "Monk::WebSocket::Server#run block is not Ractor-shareable: #{e.message} " \
93
+ "(build it where self is shareable, e.g. at class-body scope, not inline in a script's " \
94
+ "top-level block -- mirrors StateRactor#update's own constraint)"
95
+ end
96
+
97
+ loop do
98
+ socket = @tcp_server.accept
99
+ ractor = Ractor.new(
100
+ shareable_block, @authenticate, @allowed_origins, @ping_interval, @reverify_interval, @max_payload_size
101
+ ) do |blk, authenticate, origins, ping_interval, reverify_interval, max_payload_size|
102
+ Monk::WebSocket::Server.serve(
103
+ Ractor.receive, blk,
104
+ authenticate: authenticate, allowed_origins: origins,
105
+ ping_interval: ping_interval, reverify_interval: reverify_interval, max_payload_size: max_payload_size
106
+ )
107
+ end
108
+ ractor.send(socket, move: true)
109
+ end
110
+ rescue Interrupt
111
+ # Ctrl+C's default Ruby behavior: raise Interrupt in the thread
112
+ # blocked in the syscall, here TCPServer#accept. Unrescued, that's
113
+ # an unhandled exception -- Ruby prints its backtrace and exits
114
+ # non-zero, which reads as a crash even though this is the normal,
115
+ # intended way to stop a long-running server. Registry#ask's
116
+ # Ractor::ClosedError rescue (registry.rb) guards a different
117
+ # failure point in this same shutdown -- a live connection's
118
+ # cleanup racing the registry Ractor's teardown -- not this one.
119
+ @tcp_server.close
120
+ end
121
+
122
+ # Runs entirely inside the connection's own dedicated Ractor
123
+ # (docs/history/plan-websocket.md Decision 3) -- a class method, not an instance
124
+ # method, since the Server instance itself (holding a live
125
+ # TCPServer) is never Ractor-shareable and can't cross into here.
126
+ def self.serve(
127
+ socket, block, authenticate: false, allowed_origins: nil, ping_interval: nil, reverify_interval: nil,
128
+ max_payload_size: Connection::DEFAULT_MAX_PAYLOAD_SIZE
129
+ )
130
+ request = read_handshake_request(socket)
131
+ return socket.close unless request
132
+
133
+ headers =
134
+ begin
135
+ Handshake.parse_request(request)
136
+ rescue Monk::WebSocket::HandshakeError
137
+ return socket.write("HTTP/1.1 400 Bad Request\r\n\r\n")
138
+ end
139
+
140
+ subject = nil
141
+ if authenticate
142
+ subject = authenticate!(socket, headers, allowed_origins)
143
+ return unless subject
144
+ end
145
+
146
+ socket.write(Handshake.response_for(request))
147
+
148
+ connection = Connection.new(socket, subject: subject, max_payload_size: max_payload_size)
149
+ connection.start_heartbeat(ping_interval) if ping_interval
150
+ reverify_thread =
151
+ if reverify_interval
152
+ start_reverify(connection, Handshake.credential_from(headers)[:token], reverify_interval)
153
+ end
154
+ begin
155
+ block.call(connection)
156
+ rescue StandardError
157
+ # Isolates this connection's failure to its own Ractor
158
+ # (docs/history/plan-websocket.md step 12): neither the accept loop nor any
159
+ # other connection's Ractor is affected. Deliberately swallowed,
160
+ # not re-raised -- there's no caller left to hand it to once
161
+ # we're inside this connection's own dedicated Ractor.
162
+ end
163
+ ensure
164
+ # Runs on every exit path -- normal completion, the close
165
+ # handshake, and a crashed handler alike -- so a subscribed
166
+ # connection never leaks a stale registry entry (step 17), a
167
+ # heartbeat thread, or a reverify thread outliving its socket.
168
+ reverify_thread&.kill
169
+ connection&.unsubscribe!
170
+ connection&.stop_heartbeat!
171
+ socket.close
172
+ end
173
+
174
+ # docs/history/plan-websocket.md steps 22-23: the Origin allowlist check runs
175
+ # before Monk::Auth.verify, and only for a cookie-derived
176
+ # credential -- a Bearer connection has no Origin header to check
177
+ # by construction (mirrors require_csrf!'s own Bearer exemption in
178
+ # docs/history/plan-auth.md). Returns the verified subject, or nil after writing
179
+ # the appropriate 403/401 response itself.
180
+ def self.authenticate!(socket, headers, allowed_origins)
181
+ credential = Handshake.credential_from(headers)
182
+
183
+ if credential&.fetch(:via) == :cookie && allowed_origins
184
+ origin = headers["origin"]
185
+ unless origin && allowed_origins.include?(origin)
186
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n")
187
+ return nil
188
+ end
189
+ end
190
+
191
+ subject = credential && Monk::Auth.verify(credential[:token])
192
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n") unless subject
193
+ subject
194
+ end
195
+ private_class_method :authenticate!
196
+
197
+ # Sleeps in `interval`-sized steps, re-running Monk::Auth.verify
198
+ # against the same raw credential each time, until it comes back
199
+ # nil (revoked or expired) -- then closes the connection with the
200
+ # standard "policy violation" close code, the one bit of RFC 6455's
201
+ # registered code space that fits "this session is no longer
202
+ # authorized" without inventing an app-specific one. Runs in its
203
+ # own Thread rather than blocking the handler's own read loop,
204
+ # mirroring Connection#start_heartbeat's shape; killed from #serve's
205
+ # ensure the same way.
206
+ def self.start_reverify(connection, raw_token, interval)
207
+ Thread.new do
208
+ loop do
209
+ sleep interval
210
+ break unless Monk::Auth.verify(raw_token)
211
+ end
212
+ connection.close(code: 1008, reason: "session no longer valid")
213
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
214
+ # The connection already closed via some other path (normal
215
+ # disconnect, the close handshake, a dead ping) -- nothing left
216
+ # to close.
217
+ end
218
+ end
219
+ private_class_method :start_reverify
220
+
221
+ def self.read_handshake_request(socket)
222
+ request = +""
223
+ until request.end_with?("\r\n\r\n")
224
+ chunk = socket.read(1)
225
+ return nil if chunk.nil?
226
+
227
+ request << chunk
228
+ end
229
+ request
230
+ end
231
+ private_class_method :read_handshake_request
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,19 @@
1
+ require_relative "errors"
2
+ # Standalone, dependency-free -- required here so Server.new(authenticate:
3
+ # true) always raises this precise error, per ADR 0003, rather than a bare
4
+ # NameError when an app calls it without ever requiring "monk/auth" at all.
5
+ require_relative "auth/errors"
6
+ require_relative "websocket/errors"
7
+ require_relative "websocket/handshake"
8
+ require_relative "websocket/frame"
9
+ require_relative "websocket/connection"
10
+ require_relative "websocket/server"
11
+ require_relative "websocket/registry"
12
+
13
+ # Opt-in, like Monk::Persistence backends and Monk::Auth: require
14
+ # "monk/websocket" explicitly. `require "monk"` alone must not load this
15
+ # (docs/history/plan-websocket.md Decision 7).
16
+ module Monk
17
+ module WebSocket
18
+ end
19
+ end
data/lib/monk.rb ADDED
@@ -0,0 +1,45 @@
1
+ require_relative "monk/version"
2
+ require_relative "monk/settings"
3
+ require_relative "monk/environment"
4
+ require_relative "monk/errors"
5
+ require_relative "monk/views"
6
+ require_relative "monk/assets"
7
+ require_relative "monk/log"
8
+ require_relative "monk/context"
9
+ require_relative "monk/persistence/errors"
10
+ require_relative "monk/state_ractor"
11
+ require_relative "monk/persistence"
12
+ require_relative "monk/persistence/model"
13
+ require_relative "monk/base"
14
+
15
+ # Persistence backends (Monk::Persistence::Pg, and any future adapter) are
16
+ # opt-in -- require them explicitly, e.g. `require "monk/persistence/pg"`.
17
+
18
+ module Monk
19
+ def self.boot(app)
20
+ app.freeze!
21
+
22
+ app_class = app.is_a?(Class) ? app : app.class
23
+ parts = ['', "Monk #{VERSION} plays!", " - env=#{env}", " - routes=#{app_class.routes.size}"]
24
+ parts << " - auth=on" if defined?(Auth) && Auth.config
25
+ backends = persistence_backends
26
+ parts << " - persistence=#{backends.join(",")}" unless backends.empty?
27
+ parts << "\n"
28
+
29
+ $stdout.puts parts.join("\n")
30
+ app
31
+ end
32
+
33
+ # Backend module name (e.g. "pg") + its registered connection names
34
+ # (e.g. "primary"), for every Persistence::Registry backend actually in
35
+ # use -- Monk.freeze_hooks already lists every such module (each one
36
+ # extends Registry and adds itself there), so this just filters that
37
+ # list down to the ones with something registered, rather than keeping
38
+ # a second registry of backends.
39
+ def self.persistence_backends
40
+ freeze_hooks
41
+ .select { |hook| hook.is_a?(Module) && hook.singleton_class.include?(Persistence::Registry) }
42
+ .filter_map { |hook| "#{hook.name.split("::").last.downcase}:#{hook.names.join(",")}" if hook.names.any? }
43
+ end
44
+ private_class_method :persistence_backends
45
+ end