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,22 @@
1
+ #!/usr/bin/env ruby
2
+ require "bundler/setup"
3
+ require_relative "../config/settings"
4
+ require_relative "../config/persistence"
5
+ require "monk/persistence/pg/migrator"
6
+
7
+ migrator = Monk::Persistence::Pg::Migrator.new(db_name: :primary, dir: File.expand_path("../db/migrate", __dir__))
8
+
9
+ case ARGV[0]
10
+ when nil, "migrate"
11
+ applied = migrator.migrate!
12
+ puts applied.empty? ? "Nothing to migrate." : "Applied: #{applied.join(", ")}"
13
+ when "rollback"
14
+ reverted = migrator.rollback!(steps: (ARGV[1] || 1).to_i)
15
+ puts reverted.empty? ? "Nothing to roll back." : "Reverted: #{reverted.join(", ")}"
16
+ when "status"
17
+ puts "Applied: #{migrator.applied.join(", ")}"
18
+ puts "Pending: #{migrator.pending.join(", ")}"
19
+ else
20
+ warn "Usage: bin/migrate [migrate|rollback [N]|status]"
21
+ exit 1
22
+ end
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ require "bundler/setup"
3
+ require_relative "../config/settings"
4
+ require_relative "../config/persistence"
5
+ require "monk/persistence/pg/migrator"
6
+
7
+ Monk::Persistence::Pg::Migrator.new(db_name: :primary, dir: File.expand_path("../db/migrate", __dir__)).migrate!
8
+
9
+ puts "Migrated #{ENV.fetch("DB_NAME", "app_development")}."
@@ -0,0 +1,10 @@
1
+ require "monk"
2
+ require "monk/persistence/pg"
3
+
4
+ Monk::Persistence::Pg.register(:primary,
5
+ host: ENV.fetch("DB_HOST", "127.0.0.1"),
6
+ port: ENV.fetch("DB_PORT", "5432").to_i,
7
+ user: ENV.fetch("DB_USER", "postgres"),
8
+ password: ENV.fetch("DB_PASSWORD", "postgres"),
9
+ dbname: ENV.fetch("DB_NAME", "app_development"),
10
+ )
@@ -0,0 +1 @@
1
+ gem "redis"
@@ -0,0 +1,9 @@
1
+ module Monk
2
+ # Frozen, not just a plain literal: reading a String constant from a
3
+ # non-main Ractor raises Ractor::IsolationError unless it's shareable,
4
+ # and a frozen String with no unshareable ivars qualifies -- the same
5
+ # class of bug .freeze! guards against for routes/error handlers/models,
6
+ # just on a top-level constant that every worker Ractor reads on boot
7
+ # (found live under kino: GET /hello 500'd until this was frozen).
8
+ VERSION = "0.15.0".freeze
9
+ end
data/lib/monk/views.rb ADDED
@@ -0,0 +1,175 @@
1
+ require "erb"
2
+ require "cgi/escape"
3
+
4
+ require_relative "freeze_hooks"
5
+ require_relative "errors"
6
+
7
+ module Monk
8
+ # ERB templates, compiled once at Boot into real instance methods on a
9
+ # module that Context includes, then frozen. Never compiled at request
10
+ # time -- see docs/design/views.md for why that isn't a preference: a worker
11
+ # Ractor can't hold a template cache (shared mutable state) and mustn't
12
+ # install methods on a shared module, so the lazy-compile-and-cache
13
+ # design every other Ruby template engine uses is unavailable here.
14
+ #
15
+ # What that buys, beyond safety: a template's syntax error fails the
16
+ # boot naming file and line (ADR 0003's posture, applied to views), and
17
+ # a render is a plain method call (~2.6us for a small template) rather
18
+ # than an eval.
19
+ module Views
20
+ # An already-safe fragment: `h` passes it through instead of escaping
21
+ # it. #to_s must return self, because ERB::Compiler hardcodes
22
+ # `insert_cmd((expr).to_s)` and String#to_s downgrades a subclass
23
+ # instance to a plain String -- which would silently strip the marker.
24
+ class Raw < String
25
+ def to_s
26
+ self
27
+ end
28
+ end
29
+
30
+ # Compiled template methods land here; Monk::Context includes it, so
31
+ # `self` inside a template is the request's Context and bare `params`,
32
+ # `render`, ivars set by the route, and every other Context helper
33
+ # resolve exactly as they do in a route block.
34
+ module Compiled
35
+ end
36
+
37
+ DEFAULT_ROOT = "views".freeze
38
+
39
+ class << self
40
+ # Every reader here is a plain attr_reader over an ivar initialized
41
+ # eagerly below, never `@x ||= ...`: a lazy reader *writes* on first
42
+ # access, and writing a module ivar from a non-main Ractor is an
43
+ # isolation error. The request path only ever reads.
44
+ attr_reader :root, :layout, :registry
45
+ attr_writer :root, :layout
46
+
47
+ # Escapes unless the value is already Raw, and returns Raw either
48
+ # way -- so an explicit `<%= h(x) %>` isn't escaped twice by the
49
+ # implicit `h` the compiler wraps every `<%= %>` in.
50
+ # CGI.escapeHTML, not ERB::Util.html_escape, though they produce
51
+ # byte-identical output: ERB's is not flagged Ractor-safe, so a
52
+ # worker calling it raises Ractor::UnsafeError ("ractor unsafe
53
+ # method called from not main ractor"). Found by
54
+ # test/ractor_integration_test.rb, in a real worker, not by reading
55
+ # either implementation.
56
+ def h(value)
57
+ return value if value.is_a?(Raw)
58
+
59
+ Raw.new(CGI.escapeHTML(value.to_s))
60
+ end
61
+
62
+ def compile(name, source, path = "(erb)")
63
+ method_name = method_name_for(name)
64
+ # method_name_for deliberately reuses the same method name across
65
+ # repeated boots of the same template path (see its comment), so a
66
+ # second freeze in one process -- a second app, or the test suite --
67
+ # redefines this method rather than growing a new one. Ruby warns
68
+ # ("method redefined; discarding old ...") whenever a `def`
69
+ # overwrites an existing method, but not when the old one was
70
+ # explicitly removed first -- so remove it ourselves to keep that
71
+ # warning from firing on a redefinition this module intends.
72
+ Compiled.remove_method(method_name) if Compiled.method_defined?(method_name)
73
+ Compiled.module_eval("def #{method_name}(locals = {}); #{compiled_source(source)}; end", path, 0)
74
+ registry[name] = method_name
75
+ name
76
+ rescue SyntaxError => e
77
+ raise Monk::TemplateSyntaxError, e.message.lines.first.to_s.chomp
78
+ end
79
+
80
+ def render(context, name, locals, layout: :default)
81
+ outer = !context.rendering
82
+ context.rendering = true
83
+ inner = invoke(context, name, locals)
84
+ wrapper = layout == :default ? (outer ? self.layout : nil) : layout
85
+ wrapper ? invoke(context, wrapper, locals) { inner } : inner
86
+ ensure
87
+ context.rendering = false if outer
88
+ end
89
+
90
+ # Called from Base#freeze! (Seam B), via Monk.freeze_hooks: compile
91
+ # every template under `root`, then seal the registry so worker
92
+ # Ractors can read it. #dup first, so booting twice in one process
93
+ # (two apps, or a test suite) doesn't hit the frozen hash.
94
+ def freeze_registry!
95
+ @registry = registry.dup
96
+ compile_all!
97
+
98
+ if @layout && !@registry.key?(@layout)
99
+ raise Monk::TemplateNotFoundError,
100
+ "layout #{@layout.inspect} doesn't exist (looked under #{root.inspect}; " \
101
+ "known: #{@registry.keys.sort.inspect})"
102
+ end
103
+
104
+ @registry = Ractor.make_shareable(@registry)
105
+ # #root and #layout are read on the request path (the not-found
106
+ # message, layout resolution), and an unfrozen String in a module
107
+ # ivar is unreadable from a worker Ractor at all -- the same
108
+ # restriction Persistence::Registry#freeze_registry! exists for.
109
+ @root = root.freeze
110
+ @layout = layout.freeze
111
+ end
112
+
113
+ # Test-only: forgets every compiled template. The methods stay
114
+ # defined on Compiled (harmless -- they're unreachable once the
115
+ # registry no longer names them), since a module can't undefine
116
+ # itself back to a clean slate cheaply.
117
+ def reset!
118
+ @registry = {}
119
+ @root = DEFAULT_ROOT
120
+ @layout = nil
121
+ end
122
+
123
+ private
124
+
125
+ def compile_all!
126
+ return unless root && Dir.exist?(root)
127
+
128
+ Dir.glob("**/*.erb", base: root).sort.each do |relative|
129
+ path = File.join(root, relative)
130
+ compile(relative.delete_suffix(".erb"), File.read(path), path)
131
+ end
132
+ end
133
+
134
+ def invoke(context, name, locals, &block)
135
+ method_name = registry.fetch(name) do
136
+ raise Monk::TemplateNotFoundError,
137
+ "no template #{name.inspect} (looked under #{root.inspect}; " \
138
+ "known: #{registry.keys.sort.inspect})"
139
+ end
140
+
141
+ context.send(method_name, locals, &block)
142
+ end
143
+
144
+ # `<%= %>` escapes by default -- a deliberate break from stock ERB,
145
+ # where it doesn't. ERB::Compiler emits `insert_cmd((expr).to_s)`
146
+ # for every insertion, so pointing insert_cmd at Views.h is the
147
+ # whole implementation; `raw(x)` opts back out.
148
+ def compiled_source(source)
149
+ compiler = ERB::Compiler.new("-")
150
+ compiler.pre_cmd = ["_erbout = +''"]
151
+ compiler.post_cmd = ["Monk::Views::Raw.new(_erbout)"]
152
+ compiler.put_cmd = "_erbout.<<"
153
+ compiler.insert_cmd = "_erbout.<< Monk::Views.h"
154
+ compiler.compile(source).first
155
+ end
156
+
157
+ def method_name_for(name)
158
+ return registry[name] if registry.key?(name)
159
+
160
+ base = "__monk_view_#{name.gsub(/[^A-Za-z0-9_]/, "_")}"
161
+ return base unless Compiled.method_defined?(base)
162
+
163
+ suffix = 2
164
+ suffix += 1 while Compiled.method_defined?("#{base}_#{suffix}")
165
+ "#{base}_#{suffix}"
166
+ end
167
+ end
168
+
169
+ @registry = {}
170
+ @root = DEFAULT_ROOT
171
+ @layout = nil
172
+
173
+ Monk.freeze_hooks << self
174
+ end
175
+ end
@@ -0,0 +1,226 @@
1
+ module Monk
2
+ module WebSocket
3
+ # Monk's Context-equivalent for a WS connection (docs/history/plan-websocket.md step
4
+ # 10): the socket and frame machinery are never exposed directly to app
5
+ # code. Lives entirely inside its own connection Ractor -- never itself
6
+ # crosses a Ractor boundary, so it needs no shareability of its own.
7
+ class Connection
8
+ # 1 MiB -- generous for chat-sized text, cheap insurance against a
9
+ # hostile length claim on a public endpoint (gap 5,
10
+ # docs/history/chat-gap-analysis.md). Enforced twice: against a single
11
+ # frame's declared length (#read_frame, before the payload is ever
12
+ # read off the wire) and against a fragmented message's reassembled
13
+ # total (#read) -- otherwise chunking a message into many frames
14
+ # each just under the per-frame cap would silently bypass it.
15
+ DEFAULT_MAX_PAYLOAD_SIZE = 1 * 1024 * 1024
16
+
17
+ # subject is whatever Monk::Auth.verify returned when
18
+ # Server.new(authenticate: true) verified this handshake
19
+ # (docs/history/plan-websocket.md Phase 5) -- nil for an unauthenticated server.
20
+ attr_reader :subject
21
+
22
+ def initialize(socket, subject: nil, max_payload_size: DEFAULT_MAX_PAYLOAD_SIZE)
23
+ @socket = socket
24
+ @subject = subject
25
+ @write_mutex = Mutex.new
26
+ @max_payload_size = max_payload_size
27
+ end
28
+
29
+ # Reassembles a fragmented message per RFC 6455 5.4: a frame with
30
+ # fin: false starts one, opcode 0x0 continues it, and the frame
31
+ # carrying fin: true ends it. Previously `fin` was decoded and then
32
+ # ignored entirely, so a continuation frame fell straight through
33
+ # the `else` branch below and reached app code as if it were its
34
+ # own complete message (gap 5). A continuation frame with nothing
35
+ # to continue, or a new fragmented message started before the
36
+ # current one finished, are both protocol violations -- closed with
37
+ # 1002 rather than silently misinterpreted.
38
+ def read
39
+ fragments = nil
40
+ fragments_size = 0
41
+
42
+ loop do
43
+ frame = read_frame
44
+ return nil unless frame
45
+
46
+ case frame[:opcode]
47
+ when 0x8
48
+ # The RFC 6455 closing handshake, not a bare TCP close (step
49
+ # 13): echo a close frame back, close the socket, and report
50
+ # this to app code the same way any other disconnect is
51
+ # reported -- #read returning nil (step 15).
52
+ write_frame(frame[:payload], 0x8)
53
+ @socket.close
54
+ return nil
55
+ when 0x9
56
+ # Answered automatically, without reaching app code (step
57
+ # 24): keeps a long-lived authenticated connection alive
58
+ # through idle reverse-proxy timeouts. Echoes the ping's own
59
+ # payload back, per RFC 6455 5.5.3.
60
+ write_frame(frame[:payload], 0xA)
61
+ when 0xA
62
+ # An unsolicited pong -- nothing to do yet (no pending-ping
63
+ # tracking exists), but still not a message for app code.
64
+ when 0x0
65
+ return close_with(1002, "unexpected continuation frame") unless fragments
66
+
67
+ fragments << frame[:payload]
68
+ fragments_size += frame[:payload].bytesize
69
+ return close_with(1009, "message too large") if fragments_size > @max_payload_size
70
+
71
+ return fragments.join if frame[:fin]
72
+ else
73
+ return close_with(1002, "expected a continuation frame") if fragments
74
+ return frame[:payload] if frame[:fin]
75
+
76
+ fragments = [frame[:payload]]
77
+ fragments_size = frame[:payload].bytesize
78
+ end
79
+ end
80
+ end
81
+
82
+ def write(payload, opcode: 0x1)
83
+ write_frame(payload, opcode)
84
+ end
85
+
86
+ # The server-initiated close path (step 14): send a close frame
87
+ # carrying the 2-byte status code + reason RFC 6455 expects, then
88
+ # close the socket outright -- no wait for the client's own close
89
+ # frame in reply.
90
+ def close(code: 1000, reason: "")
91
+ write_frame([code].pack("n") + reason.to_s, 0x8)
92
+ @socket.close
93
+ end
94
+
95
+ # Registers this connection's own Ractor::Port -- the "connection
96
+ # handle" a Monk::WebSocket::Registry holds (docs/history/plan-websocket.md step
97
+ # 17) -- under key, and relays whatever the registry broadcasts onto
98
+ # this socket via a background Thread inside this connection's own
99
+ # Ractor (safe: a Ractor may freely spawn ordinary Threads within
100
+ # itself, same as the main Ractor already does elsewhere in Monk).
101
+ def subscribe(registry, key)
102
+ @registry = registry
103
+ @key = key
104
+ @port = Ractor::Port.new
105
+ registry.register(key, @port)
106
+ @relay_thread = Thread.new { relay_broadcasts }
107
+ end
108
+
109
+ # Called unconditionally from Server.serve's ensure on every exit
110
+ # path -- normal completion, the close handshake, and a crashed
111
+ # handler alike -- so a subscribed connection never leaves a stale
112
+ # entry in the registry (step 17). A no-op if #subscribe was never
113
+ # called.
114
+ def unsubscribe!
115
+ return unless @registry
116
+
117
+ @relay_thread.kill
118
+ @registry.unregister(@key, @port)
119
+ @port.close
120
+ end
121
+
122
+ # Sends an unsolicited ping frame every `interval` seconds for as
123
+ # long as the connection stays open (gap 3,
124
+ # docs/history/chat-gap-analysis.md): answering a client's own ping keeps a
125
+ # connection alive only if the client ever sends one, and browser
126
+ # JavaScript has no API to send WS ping frames at all -- so a truly
127
+ # idle browser connection still gets dropped by a reverse proxy's
128
+ # idle timeout without this. A spec-compliant client (including
129
+ # every browser's own WebSocket implementation, transparently, with
130
+ # no app-level JS involved) answers a server-sent ping with a pong
131
+ # automatically; #read already discards an incoming pong silently
132
+ # either way, so nothing app-visible changes.
133
+ def start_heartbeat(interval)
134
+ @heartbeat_thread = Thread.new do
135
+ loop do
136
+ sleep interval
137
+ write("", opcode: 0x9)
138
+ end
139
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
140
+ # The socket closed out from under this loop -- #close or a
141
+ # real disconnect racing the next scheduled ping. #stop_heartbeat!
142
+ # still runs from Server.serve's ensure regardless; this just
143
+ # keeps the thread from surfacing the expected teardown race as
144
+ # a Thread.report_on_exception warning.
145
+ end
146
+ end
147
+
148
+ # Called unconditionally from Server.serve's ensure, alongside
149
+ # #unsubscribe! -- a no-op if #start_heartbeat was never called.
150
+ def stop_heartbeat!
151
+ @heartbeat_thread&.kill
152
+ end
153
+
154
+ private
155
+
156
+ def relay_broadcasts
157
+ loop { write(@port.receive) }
158
+ rescue Ractor::ClosedError
159
+ # #unsubscribe! closed the port out from under a pending #receive.
160
+ end
161
+
162
+ def write_frame(payload, opcode)
163
+ @write_mutex.synchronize { @socket.write(Monk::WebSocket::Frame.encode(payload, opcode: opcode)) }
164
+ end
165
+
166
+ # Sends a close frame carrying `code`/`reason`, closes the socket,
167
+ # and returns nil -- the shared exit path for both gap-5 violations
168
+ # (an oversized declared length, an out-of-sequence fragment),
169
+ # always returned via `return close_with(...)` so the nil correctly
170
+ # propagates out of #read the same way any other disconnect does.
171
+ def close_with(code, reason)
172
+ write_frame([code].pack("n") + reason, 0x8)
173
+ @socket.close
174
+ nil
175
+ end
176
+
177
+ def read_frame
178
+ header = read_exactly(2)
179
+ return nil unless header
180
+
181
+ byte1 = header.getbyte(1)
182
+ masked = byte1.anybits?(0x80)
183
+ length_indicator = byte1 & 0x7F
184
+
185
+ extended =
186
+ case length_indicator
187
+ when 126 then read_exactly(2)
188
+ when 127 then read_exactly(8)
189
+ else ""
190
+ end
191
+ return nil if extended.nil?
192
+
193
+ mask_key = masked ? read_exactly(4) : ""
194
+ return nil if mask_key.nil?
195
+
196
+ length = extended_length(length_indicator, extended)
197
+ # Checked before the payload is read off the wire, not after --
198
+ # the whole point is to never attempt to buffer a hostile
199
+ # multi-gigabyte claim in the first place (gap 5).
200
+ return close_with(1009, "payload too large") if length > @max_payload_size
201
+
202
+ payload = read_exactly(length)
203
+ return nil unless payload
204
+
205
+ Monk::WebSocket::Frame.decode(header + extended + mask_key + payload)
206
+ end
207
+
208
+ def extended_length(length_indicator, extended)
209
+ case length_indicator
210
+ when 126 then extended.unpack1("n")
211
+ when 127 then extended.unpack1("Q>")
212
+ else length_indicator
213
+ end
214
+ end
215
+
216
+ def read_exactly(n)
217
+ return "" if n.zero?
218
+
219
+ data = @socket.read(n)
220
+ return nil if data.nil? || data.bytesize < n
221
+
222
+ data
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,9 @@
1
+ module Monk
2
+ module WebSocket
3
+ class HandshakeError < StandardError
4
+ end
5
+
6
+ class ProtocolError < StandardError
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,71 @@
1
+ module Monk
2
+ module WebSocket
3
+ module Frame
4
+ def self.decode(bytes)
5
+ raise Monk::WebSocket::ProtocolError, "frame header too short" if bytes.bytesize < 2
6
+
7
+ byte0, byte1 = bytes.unpack("C2")
8
+ fin = byte0.anybits?(0x80)
9
+ opcode = byte0 & 0x0F
10
+ masked = byte1.anybits?(0x80)
11
+ length = byte1 & 0x7F
12
+
13
+ offset = 2
14
+ case length
15
+ when 126
16
+ require_bytes!(bytes, offset, 2, "extended 16-bit length")
17
+ length = bytes[offset, 2].unpack1("n")
18
+ offset += 2
19
+ when 127
20
+ require_bytes!(bytes, offset, 8, "extended 64-bit length")
21
+ length = bytes[offset, 8].unpack1("Q>")
22
+ offset += 8
23
+ end
24
+
25
+ if masked
26
+ require_bytes!(bytes, offset, 4, "mask key")
27
+ mask_key = bytes[offset, 4]
28
+ offset += 4
29
+ end
30
+
31
+ require_bytes!(bytes, offset, length, "payload")
32
+ payload = bytes[offset, length]
33
+ payload = unmask(payload, mask_key) if masked
34
+
35
+ { fin: fin, opcode: opcode, masked: masked, payload: payload }
36
+ end
37
+
38
+ def self.require_bytes!(bytes, offset, needed, what)
39
+ return if bytes.bytesize - offset >= needed
40
+
41
+ raise Monk::WebSocket::ProtocolError, "truncated frame: #{what} missing or incomplete"
42
+ end
43
+ private_class_method :require_bytes!
44
+
45
+ # Server frames are never masked, per RFC 6455 -- masking is a
46
+ # client-to-server-only requirement.
47
+ def self.encode(payload, opcode:)
48
+ byte0 = 0x80 | opcode
49
+
50
+ length_bytes =
51
+ if payload.bytesize <= 125
52
+ [payload.bytesize].pack("C")
53
+ elsif payload.bytesize <= 0xFFFF
54
+ [126].pack("C") + [payload.bytesize].pack("n")
55
+ else
56
+ [127].pack("C") + [payload.bytesize].pack("Q>")
57
+ end
58
+
59
+ # payload.b: the header is BINARY and holds non-ASCII bytes, so a
60
+ # UTF-8 payload with non-ASCII characters can't be concatenated to it
61
+ # (Encoding::CompatibilityError). Frames are bytes on the wire.
62
+ [byte0].pack("C") + length_bytes + payload.b
63
+ end
64
+
65
+ def self.unmask(payload, mask_key)
66
+ payload.each_byte.with_index.map { |byte, i| byte ^ mask_key.getbyte(i % 4) }.pack("C*")
67
+ end
68
+ private_class_method :unmask
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,77 @@
1
+ require "digest"
2
+ require "base64"
3
+
4
+ module Monk
5
+ module WebSocket
6
+ module Handshake
7
+ # An unfrozen constant here is exactly the bug Phase 0's spike hit
8
+ # (docs/design/websocket.md) -- Ractor::IsolationError the first time this is
9
+ # read from a connection Ractor. .freeze is not optional.
10
+ MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".freeze
11
+
12
+ def self.accept_key(client_key)
13
+ Base64.strict_encode64(Digest::SHA1.digest(client_key + MAGIC))
14
+ end
15
+
16
+ def self.parse_request(raw_request)
17
+ headers = raw_request.split("\r\n").drop(1).each_with_object({}) do |line, h|
18
+ next if line.empty?
19
+
20
+ name, value = line.split(":", 2)
21
+ h[name.strip.downcase] = value.strip if name && value
22
+ end
23
+
24
+ unless headers["upgrade"]&.downcase == "websocket"
25
+ raise Monk::WebSocket::HandshakeError, "missing or invalid Upgrade header"
26
+ end
27
+ unless headers["connection"]&.downcase&.split(/\s*,\s*/)&.include?("upgrade")
28
+ raise Monk::WebSocket::HandshakeError, "missing or invalid Connection header"
29
+ end
30
+ raise Monk::WebSocket::HandshakeError, "missing Sec-WebSocket-Key header" unless headers["sec-websocket-key"]
31
+
32
+ headers
33
+ end
34
+
35
+ # docs/history/plan-websocket.md step 21: (a) Authorization: Bearer -- the
36
+ # non-browser/S2S path -- takes priority; (b) otherwise the
37
+ # session_token cookie, arriving automatically because cookies
38
+ # aren't port-scoped. Mirrors Monk::Auth::Helpers' identical
39
+ # bearer_token/session_cookie_token logic, reimplemented here since
40
+ # this runs from parsed handshake headers, not a Context.
41
+ def self.credential_from(headers)
42
+ if (token = bearer_token(headers))
43
+ { token: token, via: :bearer }
44
+ elsif (token = session_cookie_token(headers))
45
+ { token: token, via: :cookie }
46
+ end
47
+ end
48
+
49
+ def self.bearer_token(headers)
50
+ auth_header = headers["authorization"]
51
+ return nil unless auth_header&.start_with?("Bearer ")
52
+
53
+ token = auth_header.delete_prefix("Bearer ")
54
+ token.empty? ? nil : token
55
+ end
56
+ private_class_method :bearer_token
57
+
58
+ def self.session_cookie_token(headers)
59
+ headers["cookie"].to_s.split(";").each_with_object({}) do |pair, cookies|
60
+ name, value = pair.strip.split("=", 2)
61
+ cookies[name] = value if name
62
+ end["session_token"]
63
+ end
64
+ private_class_method :session_cookie_token
65
+
66
+ def self.response_for(raw_request)
67
+ headers = parse_request(raw_request)
68
+
69
+ "HTTP/1.1 101 Switching Protocols\r\n" \
70
+ "Upgrade: websocket\r\n" \
71
+ "Connection: Upgrade\r\n" \
72
+ "Sec-WebSocket-Accept: #{accept_key(headers["sec-websocket-key"])}\r\n" \
73
+ "\r\n"
74
+ end
75
+ end
76
+ end
77
+ end