duoruby 0.1.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.
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/promise"
5
+ require "duoruby/reply_error"
6
+
7
+ module DuoRuby
8
+ # Async-backed server-side reply promise.
9
+ #
10
+ # It keeps Async::Promise's native #wait API and adds the small PromiseV2-like
11
+ # surface DuoRuby exposes across runtimes.
12
+ class ReplyPromise < Async::Promise
13
+ def await(...)
14
+ wait(...)
15
+ end
16
+
17
+ alias_method :__await__, :await
18
+
19
+ def then(&handler)
20
+ return self unless handler
21
+
22
+ if resolved?
23
+ handler.call(wait) if completed?
24
+ else
25
+ schedule { call_success(handler) }
26
+ end
27
+
28
+ self
29
+ end
30
+
31
+ def fail(&handler)
32
+ return self unless handler
33
+
34
+ if resolved?
35
+ call_failure(handler)
36
+ else
37
+ schedule { call_failure(handler) }
38
+ end
39
+
40
+ self
41
+ end
42
+
43
+ alias_method :rescue, :fail
44
+
45
+ private
46
+
47
+ def call_failure(handler)
48
+ wait
49
+ rescue StandardError => error
50
+ handler.call(error)
51
+ end
52
+
53
+ def call_success(handler)
54
+ handler.call(wait)
55
+ rescue StandardError
56
+ nil
57
+ end
58
+
59
+ def schedule(&block)
60
+ if (task = Async::Task.current?)
61
+ task.async(&block)
62
+ else
63
+ Thread.new(&block)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opal"
4
+ require "opal/builder"
5
+ require "opal-browser"
6
+ require "duoruby/config"
7
+
8
+ module DuoRuby
9
+ class Server
10
+ class FrontendCompiler
11
+ def initialize(root)
12
+ @root = root
13
+ end
14
+
15
+ def call
16
+ Opal.reset_paths!
17
+ Opal.use_gem("opal-browser")
18
+ Opal.use_gem("paggio")
19
+ Opal.append_path(File.join(Gem::Specification.find_by_name("opal-browser").gem_dir, "opal"))
20
+ append_frontend_gems
21
+
22
+ builder = Opal::Builder.new
23
+ builder.stubs.concat(DuoRuby.config.frontend_stubs)
24
+ builder.append_paths(File.join(@root, "app"), File.expand_path("../..", __dir__))
25
+ builder.build("opal")
26
+ builder.build("setup/frontend")
27
+ builder.to_s
28
+ end
29
+
30
+ private
31
+
32
+ def append_frontend_gems
33
+ DuoRuby.config.frontend_gems.each do |gem_name|
34
+ Opal.use_gem(gem_name)
35
+ spec = Gem::Specification.find_by_name(gem_name)
36
+ opal_dir = File.join(spec.gem_dir, "opal")
37
+ Opal.append_path(opal_dir) if File.directory?(opal_dir)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require "async"
6
+ require "async/http/endpoint"
7
+ require "async/websocket/adapters/http"
8
+ require "falcon/server"
9
+ require "protocol/http/response"
10
+ require "duoruby/boot"
11
+ require "duoruby/message"
12
+ require "duoruby/channel"
13
+ require "duoruby/client"
14
+ require "duoruby/group"
15
+
16
+ module DuoRuby
17
+ # Application server built on Falcon and Async.
18
+ #
19
+ # Server handles three HTTP routes:
20
+ # - +GET /+ — serves an HTML shell page that loads the frontend script
21
+ # - +GET /duoruby/app.js+ — compiles and serves the Opal frontend on demand
22
+ # - +GET /duoruby/socket+ — upgrades to a WebSocket and drives message handlers
23
+ #
24
+ # Subclasses can declare message handlers with +on+ and can override +#call+ for
25
+ # custom HTTP routes before delegating to +super+.
26
+ #
27
+ # @example Starting the server from application code
28
+ # DuoRuby::Server.build(root: __dir__, port: 3000).run
29
+ class Server < Channel
30
+ require "duoruby/server/frontend_compiler"
31
+
32
+ # Path that the browser WebSocket connects to.
33
+ SOCKET_PATH = "/duoruby/socket"
34
+
35
+ # Path from which the compiled frontend JavaScript is served.
36
+ SCRIPT_PATH = "/duoruby/app.js"
37
+
38
+ # @return [String] the expanded application root directory
39
+ attr_reader :root
40
+
41
+ # @return [String] the bind host
42
+ attr_reader :host
43
+
44
+ # @return [Integer] the bind port
45
+ attr_reader :port
46
+
47
+ # @return [Hash{Symbol => Group}] all groups that have been accessed on this server
48
+ attr_reader :groups
49
+
50
+ # @param root [String] the application root directory
51
+ # @param host [String] the hostname or IP to bind to (default: +"127.0.0.1"+)
52
+ # @param port [Integer, String] the port to listen on (default: +9292+)
53
+ def initialize(root: Dir.pwd, host: "127.0.0.1", port: 9292)
54
+ super()
55
+ configure(root: root, host: host, port: port)
56
+ @groups = {}
57
+ @next_client_id = 0
58
+ end
59
+
60
+ def self.build(root: Dir.pwd, host: "127.0.0.1", port: 9292)
61
+ root = File.expand_path(root)
62
+ server = DuoRuby.load_app(:backend, root: root) || new(root: root, host: host, port: port)
63
+ server.configure(root: root, host: host, port: port)
64
+ server
65
+ end
66
+
67
+ def configure(root:, host:, port:)
68
+ @root = File.expand_path(root)
69
+ config_path = File.join(@root, "duoruby.rb")
70
+ load config_path if File.file?(config_path)
71
+ @host = host
72
+ @port = Integer(port)
73
+ DuoRuby.config.host = @host
74
+ DuoRuby.config.port = @port
75
+ self
76
+ end
77
+
78
+ # Rack-compatible request handler. Routes to the appropriate private handler
79
+ # or returns a 404. Catches +StandardError+ and responds with a 500.
80
+ #
81
+ # @param request [Protocol::HTTP::Request]
82
+ # @return [Protocol::HTTP::Response]
83
+ def call(request)
84
+ path = request.path.to_s.split("?", 2).first
85
+
86
+ case path
87
+ when SOCKET_PATH
88
+ websocket(request) || not_found("websocket endpoint")
89
+ when SCRIPT_PATH
90
+ javascript
91
+ when "/", ""
92
+ html
93
+ else
94
+ not_found(path)
95
+ end
96
+ rescue StandardError => error
97
+ text(500, "#{error.class}: #{error.message}\n")
98
+ end
99
+
100
+ # Starts the Falcon server and blocks until it exits.
101
+ #
102
+ # @param output [IO] where to print the "serving …" banner (default: +$stdout+)
103
+ def run(output: $stdout)
104
+ endpoint = Async::HTTP::Endpoint.parse("http://#{host}:#{port}")
105
+
106
+ Sync do
107
+ task = Falcon::Server.new(self, endpoint).run
108
+ output.puts "serving http://#{host}:#{port}"
109
+ task.wait
110
+ ensure
111
+ task&.stop
112
+ end
113
+ end
114
+
115
+ # Compiles the Opal frontend to a JavaScript string.
116
+ #
117
+ # Resets Opal's global path state, adds configured frontend gems, then
118
+ # builds the +opal+ runtime followed by +setup/frontend+.
119
+ #
120
+ # Note: this method mutates global Opal state (+Opal.reset_paths!+) and
121
+ # is not safe to call concurrently.
122
+ #
123
+ # @return [String] the concatenated JavaScript
124
+ def frontend_javascript
125
+ FrontendCompiler.new(root).call
126
+ end
127
+
128
+ def connect(id:, writer: nil, metadata: {}, &writer_block)
129
+ client = Client.new(id: id, writer: writer, metadata: metadata, &writer_block)
130
+ return client.reject unless authenticate(client)
131
+
132
+ dispatch(:$connect, client)
133
+ client
134
+ end
135
+
136
+ def authenticate(_client)
137
+ true
138
+ end
139
+
140
+ def disconnect(client)
141
+ dispatch(:$disconnect, client)
142
+ client.cancel_pending_calls
143
+ client.groups.values.each { |group| group.remove(client) }
144
+ client
145
+ end
146
+
147
+ def group(name)
148
+ groups[name.to_sym] ||= Group.new(name)
149
+ end
150
+
151
+ def broadcast(group_name, event, **params)
152
+ group(group_name).send(event, **params)
153
+ end
154
+
155
+ def receive(client, message)
156
+ message = Message.coerce(message)
157
+ return client.resolve_call(message) if message.event == Message::REPLY_EVENT
158
+ return client.reject_call(message) if message.event == Message::ERROR_EVENT && message.reply_to
159
+
160
+ results = dispatch(message.event, client, **message.params)
161
+ client.deliver(Message.reply(message.id, results.last)) if message.id
162
+ results
163
+ rescue StandardError => error
164
+ raise unless message&.id
165
+
166
+ client.deliver(Message.error(code: error.class.name, message: error.message, reply_to: message.id))
167
+ end
168
+
169
+ private
170
+
171
+ # Upgrades the request to a WebSocket, creates a Client, and loops over
172
+ # inbound frames until the connection closes.
173
+ def websocket(request)
174
+ Async::WebSocket::Adapters::HTTP.open(request) do |connection|
175
+ client = connect(id: next_client_id, metadata: connection_metadata(request)) do |message|
176
+ connection.send_text(JSON.generate(message))
177
+ connection.flush
178
+ end
179
+ return unless client.accepted?
180
+
181
+ while (text = connection.read)
182
+ receive(client, JSON.parse(text))
183
+ end
184
+ ensure
185
+ disconnect(client) if client
186
+ end
187
+ end
188
+
189
+ # Returns a new sequential client ID string.
190
+ def next_client_id
191
+ @next_client_id += 1
192
+ "client-#{@next_client_id}"
193
+ end
194
+
195
+ def connection_metadata(request)
196
+ query = request.path.to_s.split("?", 2)[1]
197
+ {
198
+ path: request.path.to_s.split("?", 2).first,
199
+ query: query ? URI.decode_www_form(query).to_h : {},
200
+ headers: request.headers.each.to_h
201
+ }
202
+ end
203
+
204
+ # Returns the HTML shell response.
205
+ def html
206
+ body = <<~HTML
207
+ <!doctype html>
208
+ <html>
209
+ <head>
210
+ <meta charset="utf-8">
211
+ <meta name="viewport" content="width=device-width, initial-scale=1">
212
+ <title>DuoRuby</title>
213
+ </head>
214
+ <body>
215
+ <div id="duoruby-root"></div>
216
+ <script src="#{SCRIPT_PATH}?#{Time.now.to_i}"></script>
217
+ </body>
218
+ </html>
219
+ HTML
220
+
221
+ response(200, body, "text/html")
222
+ end
223
+
224
+ # Returns the compiled JavaScript response.
225
+ def javascript
226
+ js = Thread.new { frontend_javascript }.value
227
+ response(200, js, "application/javascript")
228
+ end
229
+
230
+ # Returns a plain-text 404 response.
231
+ def not_found(path)
232
+ text(404, "not found: #{path}\n")
233
+ end
234
+
235
+ # Returns a plain-text response with the given status code.
236
+ def text(status, body)
237
+ response(status, body, "text/plain")
238
+ end
239
+
240
+ # Constructs a Protocol::HTTP::Response.
241
+ def response(status, body, content_type)
242
+ Protocol::HTTP::Response[status, {"content-type" => content_type}, [body]]
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "duoruby/server"
4
+ require "duoruby/boot"
5
+
6
+ module DuoRuby
7
+ # Creates a new application {Server} instance, optionally configuring it via a block.
8
+ #
9
+ # The block is evaluated in the context of the server instance, so handler
10
+ # registration methods (+on+, +one+, +off+) are available directly.
11
+ #
12
+ # @example
13
+ # server = DuoRuby.server do
14
+ # on(:join) { |client, room:| group(room) << client }
15
+ # end
16
+ #
17
+ # @yieldparam — (block is instance_eval'd on the server; no explicit param)
18
+ # @return [Server]
19
+ def self.server(&block)
20
+ Server.new.tap do |server|
21
+ server.instance_eval(&block) if block
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "duoruby/socket"
4
+
5
+ # Load Opal/browser dependencies when running inside the compiled JavaScript bundle.
6
+ if RUBY_ENGINE == "opal"
7
+ require "native"
8
+ require "promise/v2"
9
+ require "browser/setup/mini"
10
+ require "browser/location"
11
+ require "browser/socket"
12
+ end
13
+
14
+ module DuoRuby
15
+ # Creates a new browser {Socket} instance, optionally configuring it via a block.
16
+ #
17
+ # The block is evaluated in the context of the socket instance, so handler
18
+ # registration methods (+on+, +one+, +off+) are available directly.
19
+ #
20
+ # @example
21
+ # socket = DuoRuby.socket do
22
+ # on(:snapshot) { |rooms:, **| puts rooms.inspect }
23
+ # end
24
+ #
25
+ # @param transport [Proc, nil] optional transport callable (see {Socket#initialize})
26
+ # @yieldparam — (block is instance_eval'd on the socket; no explicit param)
27
+ # @return [Socket]
28
+ def self.socket(transport: nil, &block)
29
+ Socket.new(transport: transport).tap do |socket|
30
+ socket.instance_eval(&block) if block
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DuoRuby
4
+ class Socket
5
+ class TestPromise
6
+ attr_reader :value, :error
7
+
8
+ def initialize
9
+ @resolved = false
10
+ @rejected = false
11
+ @then_handlers = []
12
+ @fail_handlers = []
13
+ end
14
+
15
+ def resolve(value = nil)
16
+ @resolved = true
17
+ @value = value
18
+ @then_handlers.each { |handler| handler.call(value) }
19
+ self
20
+ end
21
+
22
+ def reject(error = nil)
23
+ @rejected = true
24
+ @error = error
25
+ @fail_handlers.each { |handler| handler.call(error) }
26
+ self
27
+ end
28
+
29
+ def then(&handler)
30
+ @resolved ? handler.call(value) : @then_handlers << handler
31
+ self
32
+ end
33
+
34
+ def fail(&handler)
35
+ @rejected ? handler.call(error) : @fail_handlers << handler
36
+ self
37
+ end
38
+
39
+ alias_method :rescue, :fail
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DuoRuby
4
+ class Socket
5
+ module Transport
6
+ def self.included(receiver)
7
+ receiver.extend(ClassMethods)
8
+ end
9
+
10
+ def connect(url: nil, path: "/duoruby/socket", reconnect: false, backoff: 1)
11
+ raise "already connected" if @socket
12
+
13
+ @connect_url = url || self.class.default_socket_url(path)
14
+ @reconnect = reconnect
15
+ @reconnect_backoff = backoff
16
+ open_socket
17
+ self
18
+ end
19
+
20
+ def reconnect
21
+ @socket = nil
22
+ open_socket
23
+ trigger(:$reconnect)
24
+ self
25
+ end
26
+
27
+ def open_socket
28
+ @socket = self.class.socket_class.new(@connect_url)
29
+ @transport = proc { |message| socket.write(JSON.generate(message)) }
30
+
31
+ socket.on(:open) { trigger(:$connect) }
32
+ socket.on(:message) { |event| receive(JSON.parse(event.data)) }
33
+ socket.on(:close) do
34
+ trigger(:$disconnect)
35
+ cancel_pending_calls
36
+ schedule_reconnect if @reconnect
37
+ end
38
+ end
39
+
40
+ module ClassMethods
41
+ def default_socket_url(path = "/duoruby/socket")
42
+ raise "default socket transport is only available under Opal" unless RUBY_ENGINE == "opal"
43
+
44
+ location = $window.location
45
+ protocol = location.scheme == "https:" ? "wss:" : "ws:"
46
+ "#{protocol}//#{location.host}#{path}"
47
+ end
48
+
49
+ def socket_class
50
+ raise "default socket transport is only available under Opal" unless RUBY_ENGINE == "opal"
51
+
52
+ ::Browser::Socket
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def schedule_reconnect
59
+ if RUBY_ENGINE == "opal"
60
+ $window.set_timeout(proc { reconnect }, @reconnect_backoff * 1000)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "duoruby/message"
5
+ require "duoruby/channel"
6
+ require "duoruby/reply_error"
7
+ require "promise/v2" if RUBY_ENGINE == "opal"
8
+
9
+ module DuoRuby
10
+ # Browser-side event hub. Manages the WebSocket connection and message dispatch.
11
+ #
12
+ # Socket inherits the full {Channel} event system. Declare handlers at the
13
+ # class level (inherited by subclasses) or add them at runtime on an instance.
14
+ #
15
+ # Unlike server handlers, Socket event handlers receive *only* the message
16
+ # params as keyword arguments — there is no client positional argument because
17
+ # there is exactly one connection per browser socket instance.
18
+ #
19
+ # A transport callable (proc or block) is responsible for delivering outbound
20
+ # messages. Under Opal it is set automatically by {#connect}; in tests you can
21
+ # supply any callable at construction time.
22
+ #
23
+ # @example Inline transport for testing
24
+ # delivered = []
25
+ # socket = DuoRuby::Socket.new { |msg| delivered << msg }
26
+ # socket.send(:join, room: "lobby")
27
+ #
28
+ # @example Subclass with class-level handlers
29
+ # class MySocket < DuoRuby::Socket
30
+ # on(:snapshot) { |rooms:, **| puts "rooms: #{rooms.join(', ')}" }
31
+ # end
32
+ class Socket < Channel
33
+ require "duoruby/socket/test_promise"
34
+ require "duoruby/socket/transport"
35
+
36
+ include Transport
37
+
38
+ # @return [Array<Hash>] every message sent through this socket, for inspection
39
+ attr_reader :sent
40
+
41
+ # @return [Browser::Socket, nil] the active WebSocket, or nil before {#connect}
42
+ attr_reader :socket
43
+
44
+ # @param transport [Proc, nil] callable that delivers outbound messages;
45
+ # mutually exclusive with the block form. May be omitted and set later
46
+ # by {#connect}.
47
+ # @yieldparam message [Hash] the serialized message to deliver
48
+ def initialize(transport: nil, &transport_block)
49
+ super()
50
+ @transport = transport || transport_block
51
+ @sent = []
52
+ @pending_calls = {}
53
+ @next_call_id = 0
54
+ end
55
+
56
+ # Sends +event+ with +params+ to the server.
57
+ #
58
+ # Events ending with +?+ are questions: they include a request id and return
59
+ # a promise that resolves when the server replies. Other events are
60
+ # fire-and-forget and return the serialized message hash.
61
+ #
62
+ # @param event [String, Symbol] the event name
63
+ # @param params keyword arguments that become the message params
64
+ # @return [Hash, PromiseV2] the serialized message or reply promise
65
+ def send(event, **params)
66
+ return send_question(event, **params) if question_event?(event)
67
+
68
+ deliver(Message.new(event, **params).to_h)
69
+ end
70
+
71
+ def transport=(transport)
72
+ @transport = transport
73
+ end
74
+
75
+ # Opens the WebSocket connection and wires socket lifecycle events.
76
+ #
77
+ # Only available under Opal (requires +Browser::Socket+). Raises immediately
78
+ # on CRuby. Raises if already connected.
79
+ #
80
+ # Sets up a JSON-serialising transport and forwards:
81
+ # - socket +:open+ → triggers +:$connect+
82
+ # - socket +:message+ → calls {#receive} with the parsed JSON payload
83
+ # - socket +:close+ → triggers +:$disconnect+
84
+ #
85
+ # @param url [String, nil] the full WebSocket URL; defaults to the value
86
+ # returned by {.default_socket_url}
87
+ # @param path [String] the socket path used when +url+ is not given
88
+ # @return [self]
89
+ # @raise [RuntimeError] if called outside Opal, or if already connected
90
+ # Coerces +message+ and dispatches it to the appropriate event handlers.
91
+ # Params are forwarded as keyword arguments only (no positional client arg).
92
+ #
93
+ # @param message [Message, Hash] the inbound message (raw parsed JSON or a Message)
94
+ def receive(message)
95
+ message = Message.coerce(message)
96
+ return resolve_call(message) if message.event == Message::REPLY_EVENT
97
+ return reject_call(message) if message.event == Message::ERROR_EVENT && message.reply_to
98
+
99
+ results = dispatch(message.event, **message.params)
100
+ deliver(Message.reply(message.id, results.last).to_h) if message.id
101
+ results
102
+ rescue StandardError => error
103
+ raise unless message&.id
104
+
105
+ deliver(Message.error(code: error.class.name, message: error.message, reply_to: message.id).to_h)
106
+ end
107
+
108
+ def deliver(message)
109
+ sent << message
110
+ @transport.call(message) if @transport
111
+ message
112
+ end
113
+
114
+ def cancel_pending_calls(code: :disconnect, message: "connection closed", details: nil)
115
+ error = ReplyError.new(code: code, message: message, details: details)
116
+ @pending_calls.each_value { |promise| promise.reject(error) }
117
+ @pending_calls.clear
118
+ self
119
+ end
120
+
121
+ def self.promise_class
122
+ defined?(::PromiseV2) ? ::PromiseV2 : TestPromise
123
+ end
124
+
125
+ private
126
+
127
+ def next_call_id
128
+ @next_call_id += 1
129
+ "call-#{@next_call_id}"
130
+ end
131
+
132
+ def question_event?(event)
133
+ event.to_s.end_with?("?")
134
+ end
135
+
136
+ def send_question(event, **params)
137
+ id = next_call_id
138
+ promise = self.class.promise_class.new
139
+ @pending_calls[id] = promise
140
+ deliver(Message.request(event, id, **params).to_h)
141
+ promise
142
+ end
143
+
144
+ def resolve_call(message)
145
+ promise = @pending_calls.delete(message.reply_to)
146
+ promise&.resolve(message.params[:result])
147
+ end
148
+
149
+ def reject_call(message)
150
+ promise = @pending_calls.delete(message.reply_to)
151
+ promise&.reject(ReplyError.new(message.params))
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "duoruby/setup/backend"
4
+ require "duoruby/setup/frontend"
5
+
6
+ module DuoRuby
7
+ module Testing
8
+ Connection = Struct.new(:server, :socket, :client, keyword_init: true)
9
+
10
+ def self.connect(server: Server.new, socket: Socket.new, id: "client-1", metadata: {})
11
+ socket = socket.class.new if socket.is_a?(Class)
12
+ client = nil
13
+ socket_transport = proc { |message| server.receive(client, message) }
14
+
15
+ socket.transport = socket_transport
16
+ client = server.connect(id: id, metadata: metadata) { |message| socket.receive(message) }
17
+ Connection.new(server: server, socket: socket, client: client)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DuoRuby
4
+ VERSION = "0.1.0"
5
+ end