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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +581 -0
- data/LICENSE.txt +21 -0
- data/README.md +88 -0
- data/exe/monk +116 -0
- data/lib/monk/assets.rb +197 -0
- data/lib/monk/auth/errors.rb +13 -0
- data/lib/monk/auth/helpers.rb +76 -0
- data/lib/monk/auth/login_token.rb +11 -0
- data/lib/monk/auth/rate_limiter.rb +46 -0
- data/lib/monk/auth/session.rb +11 -0
- data/lib/monk/auth.rb +301 -0
- data/lib/monk/base.rb +323 -0
- data/lib/monk/context.rb +78 -0
- data/lib/monk/environment.rb +50 -0
- data/lib/monk/errors.rb +37 -0
- data/lib/monk/freeze_hooks.rb +23 -0
- data/lib/monk/live/client/idiomorph.LICENSE +13 -0
- data/lib/monk/live/client/idiomorph.js +4 -0
- data/lib/monk/live/client/monk_live.js +204 -0
- data/lib/monk/live/client/protocol.js +87 -0
- data/lib/monk/live/envelope.rb +51 -0
- data/lib/monk/live/errors.rb +9 -0
- data/lib/monk/live/helpers.rb +22 -0
- data/lib/monk/live/policy.rb +58 -0
- data/lib/monk/live/publisher.rb +91 -0
- data/lib/monk/live/renderer.rb +47 -0
- data/lib/monk/live/session.rb +121 -0
- data/lib/monk/live.rb +96 -0
- data/lib/monk/log.rb +130 -0
- data/lib/monk/persistence/errors.rb +7 -0
- data/lib/monk/persistence/model.rb +41 -0
- data/lib/monk/persistence/pg/errors.rb +4 -0
- data/lib/monk/persistence/pg/migrator.rb +165 -0
- data/lib/monk/persistence/pg/model.rb +233 -0
- data/lib/monk/persistence/pg.rb +34 -0
- data/lib/monk/persistence.rb +113 -0
- data/lib/monk/scaffold.rb +606 -0
- data/lib/monk/settings.rb +151 -0
- data/lib/monk/state_ractor.rb +45 -0
- data/lib/monk/templates/auth/config/auth.rb +28 -0
- data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.down.sql +2 -0
- data/lib/monk/templates/auth/db/migrate/00000000000001_create_auth_tables.up.sql +18 -0
- data/lib/monk/templates/base/.dockerignore +5 -0
- data/lib/monk/templates/base/.gitignore +4 -0
- data/lib/monk/templates/base/.ruby-version +1 -0
- data/lib/monk/templates/base/Dockerfile +28 -0
- data/lib/monk/templates/base/Gemfile +7 -0
- data/lib/monk/templates/base/bin/server +5 -0
- data/lib/monk/templates/base/bin/websocket_server +62 -0
- data/lib/monk/templates/base/config/settings.rb +30 -0
- data/lib/monk/templates/base/config.ru +13 -0
- data/lib/monk/templates/base/public/css/app.css +17 -0
- data/lib/monk/templates/base/public/js/app.js +5 -0
- data/lib/monk/templates/base/views/index.erb +6 -0
- data/lib/monk/templates/base/views/layouts/app.erb +18 -0
- data/lib/monk/templates/live/bin/websocket_server +30 -0
- data/lib/monk/templates/live/config/live.rb +47 -0
- data/lib/monk/templates/live/config.ru +27 -0
- data/lib/monk/templates/live/views/index.erb +18 -0
- data/lib/monk/templates/live/views/live/_hits.erb +1 -0
- data/lib/monk/templates/postgres/Dockerfile +30 -0
- data/lib/monk/templates/postgres/Gemfile.extra +2 -0
- data/lib/monk/templates/postgres/bin/console +7 -0
- data/lib/monk/templates/postgres/bin/migrate +22 -0
- data/lib/monk/templates/postgres/bin/setup_db +9 -0
- data/lib/monk/templates/postgres/config/persistence.rb +10 -0
- data/lib/monk/templates/redis/Gemfile.extra +1 -0
- data/lib/monk/version.rb +9 -0
- data/lib/monk/views.rb +175 -0
- data/lib/monk/websocket/connection.rb +226 -0
- data/lib/monk/websocket/errors.rb +9 -0
- data/lib/monk/websocket/frame.rb +71 -0
- data/lib/monk/websocket/handshake.rb +77 -0
- data/lib/monk/websocket/redis_fanout.rb +103 -0
- data/lib/monk/websocket/registry.rb +92 -0
- data/lib/monk/websocket/server.rb +234 -0
- data/lib/monk/websocket.rb +19 -0
- data/lib/monk.rb +45 -0
- 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"
|
data/lib/monk/version.rb
ADDED
|
@@ -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,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
|