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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +169 -0
- data/exe/duoruby +7 -0
- data/lib/duoruby/boot.rb +95 -0
- data/lib/duoruby/channel/handler_methods.rb +86 -0
- data/lib/duoruby/channel/namespace.rb +40 -0
- data/lib/duoruby/channel.rb +156 -0
- data/lib/duoruby/cli.rb +165 -0
- data/lib/duoruby/client.rb +137 -0
- data/lib/duoruby/config.rb +45 -0
- data/lib/duoruby/group.rb +121 -0
- data/lib/duoruby/launcher.rb +92 -0
- data/lib/duoruby/message.rb +80 -0
- data/lib/duoruby/reply_error.rb +14 -0
- data/lib/duoruby/reply_promise.rb +67 -0
- data/lib/duoruby/server/frontend_compiler.rb +42 -0
- data/lib/duoruby/server.rb +245 -0
- data/lib/duoruby/setup/backend.rb +24 -0
- data/lib/duoruby/setup/frontend.rb +33 -0
- data/lib/duoruby/socket/test_promise.rb +42 -0
- data/lib/duoruby/socket/transport.rb +65 -0
- data/lib/duoruby/socket.rb +154 -0
- data/lib/duoruby/testing.rb +20 -0
- data/lib/duoruby/version.rb +5 -0
- data/lib/duoruby.rb +24 -0
- metadata +223 -0
|
@@ -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
|