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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6f3074e802c24846379df300d0012092cd00a18513fe87bf1a62d757bb0d1fe4
|
|
4
|
+
data.tar.gz: 3603e0dbc7808566d8cec775a0513068438fe9456fd322aa6f53ca534d5c0142
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f0cad5cb7f45d9801398bb6d3e2f945f35e3178407ebcf6a781aab081ca0d5b73ede700ce27ab656005b935e11c8ba782b130dc08eb7578daf0278bf1b2218f9
|
|
7
|
+
data.tar.gz: b5e8e428393c969a3aaf2e161ab0307efc965364230de19fb095c4cf93ba92adff574b2b861bdceaec1cd6d4326245b26e2d0ea83848635e7e87bd994f606638
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DuoRuby contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# DuoRuby
|
|
2
|
+
|
|
3
|
+
DuoRuby is a lightweight Ruby framework for WebSocket-first applications with a CRuby server and an Opal browser socket.
|
|
4
|
+
|
|
5
|
+
It gives Ruby applications a compact message DSL that works on both sides of the connection: browser sockets, server-side clients, and groups all use `send :event, **params`, while handlers use `on :event` with keyword parameters.
|
|
6
|
+
|
|
7
|
+
The main use case is building web-based desktop applications: run Ruby on the local machine, write the frontend in Ruby through Opal, open it with `duoruby launch`, and still keep the same app loadable remotely through `duoruby serve`.
|
|
8
|
+
|
|
9
|
+
The API and CLI are still evolving and should be considered unstable until version 1.0.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Add this line to your application's Gemfile:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "duoruby"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
And then execute:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
bundle install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
For local development from this checkout:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
bundle install
|
|
29
|
+
bundle exec rake
|
|
30
|
+
bundle exec rake opal_spec
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## How It Works
|
|
34
|
+
|
|
35
|
+
DuoRuby is organized around one server object and one browser socket object:
|
|
36
|
+
|
|
37
|
+
- `DuoRuby::Server` owns HTTP serving, WebSocket upgrades, connected clients, groups, authentication hooks, and message handlers.
|
|
38
|
+
- `DuoRuby::Socket` runs in the browser through Opal and owns the client-side WebSocket transport.
|
|
39
|
+
- `require "duoruby"` loads the server setup on CRuby and the browser setup on Opal.
|
|
40
|
+
- Application boot files live at `app/setup/backend.rb` and `app/setup/frontend.rb`.
|
|
41
|
+
- `duoruby serve` starts the Falcon-backed development server, serves `/`, compiles Opal frontend code to `/duoruby/app.js`, and bridges `/duoruby/socket` to the server.
|
|
42
|
+
- `duoruby launch` starts the same server and opens it in a native webview window for a desktop-app feel.
|
|
43
|
+
- Because launched apps are still served over HTTP/WebSocket, the same project can also be loaded from another browser when you expose the host/port intentionally.
|
|
44
|
+
|
|
45
|
+
Rack is not part of the default boot path.
|
|
46
|
+
|
|
47
|
+
## Quick Start
|
|
48
|
+
|
|
49
|
+
Server-side application code:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
class Chat::Server < DuoRuby::Server
|
|
53
|
+
on :join do |client, name:|
|
|
54
|
+
client[:name] = name
|
|
55
|
+
group(:lobby) << client
|
|
56
|
+
group(:lobby).send(:joined, name: name)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
on :message do |client, text:|
|
|
60
|
+
group(:lobby).send(:message, name: client[:name], text: text)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
on :name? do |client|
|
|
64
|
+
client[:name]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Browser-side application code:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
class Chat::Socket < DuoRuby::Socket
|
|
73
|
+
on :joined do |name:|
|
|
74
|
+
puts "#{name} joined"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
on :message do |name:, text:|
|
|
78
|
+
puts "#{name}: #{text}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
socket = Chat::Socket.new
|
|
83
|
+
socket.connect
|
|
84
|
+
socket.send(:join, name: "Ada")
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Events ending in `?` are request/reply questions. They return a promise that can be awaited:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
# await: true
|
|
91
|
+
|
|
92
|
+
name = socket.send(:name?).__await__
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Handlers reply to questions by returning a value. If a handler raises, DuoRuby sends a structured error reply.
|
|
96
|
+
|
|
97
|
+
## Examples
|
|
98
|
+
|
|
99
|
+
Run the chat example:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
cd examples/chat
|
|
103
|
+
bundle install
|
|
104
|
+
bundle exec duoruby serve
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Open `http://127.0.0.1:9292` in two browser windows. The sample app supports named rooms, presence lists, recent room history, room switching, leave, and validation errors.
|
|
108
|
+
|
|
109
|
+
To open it in a native webview window instead:
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
bundle exec duoruby launch
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Run the Glimmer counter example:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
cd examples/glimmer_counter
|
|
119
|
+
bundle install
|
|
120
|
+
bundle exec duoruby serve
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Run the Ready Room game example:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
cd examples/ready_room
|
|
127
|
+
bundle install
|
|
128
|
+
bundle exec duoruby serve
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Ready Room demonstrates namespaced game events, browser-to-server questions, server-to-browser questions, group question collections, structured reply errors, and reconnect state sync.
|
|
132
|
+
|
|
133
|
+
## API
|
|
134
|
+
|
|
135
|
+
- `DuoRuby::Server#on(event, &block)` registers server-side message handlers.
|
|
136
|
+
- `DuoRuby::Server#group(name)` returns a broadcast group.
|
|
137
|
+
- `DuoRuby::Server#broadcast(event, **params)` sends an event to all connected clients.
|
|
138
|
+
- `DuoRuby::Client#channel(name)` and `DuoRuby::Group#channel(name)` send namespaced events without spelling raw colon-prefixed event names.
|
|
139
|
+
- `DuoRuby::Socket#connect` opens the default `/duoruby/socket` transport.
|
|
140
|
+
- `DuoRuby::Socket#send(event, **params)` sends fire-and-forget events or promise-returning `?` questions.
|
|
141
|
+
- `DuoRuby::Client#send(event, **params)` sends from the server to a browser socket with the same `?` question convention.
|
|
142
|
+
- `DuoRuby::Testing.connect` wires a server and socket together in memory for specs.
|
|
143
|
+
- `duoruby launch [--host HOST] [--port PORT] [--title TITLE]` runs the app server and opens a native webview window.
|
|
144
|
+
|
|
145
|
+
Lifecycle events use `$`-prefixed names:
|
|
146
|
+
|
|
147
|
+
- `:$connect`
|
|
148
|
+
- `:$disconnect`
|
|
149
|
+
- `:$reconnect`
|
|
150
|
+
|
|
151
|
+
## Development
|
|
152
|
+
|
|
153
|
+
Run from the repository root:
|
|
154
|
+
|
|
155
|
+
```sh
|
|
156
|
+
bundle install
|
|
157
|
+
bundle exec rake
|
|
158
|
+
bundle exec rake opal_spec
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The default Rake task runs the CRuby RSpec suite. `bundle exec rake opal_spec` runs the Opal browser-side specs.
|
|
162
|
+
|
|
163
|
+
## Contributing
|
|
164
|
+
|
|
165
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/rbutils/duoruby.
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
|
data/exe/duoruby
ADDED
data/lib/duoruby/boot.rb
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "duoruby/config"
|
|
4
|
+
|
|
5
|
+
module DuoRuby
|
|
6
|
+
# Application bootstrapping helpers.
|
|
7
|
+
#
|
|
8
|
+
# These methods locate and load the two conventional application files for a
|
|
9
|
+
# given side (+:backend+ or +:frontend+):
|
|
10
|
+
#
|
|
11
|
+
# <root>/duoruby.rb # optional shared config
|
|
12
|
+
# <root>/app/setup/<side>.rb # side-specific entry point
|
|
13
|
+
#
|
|
14
|
+
# They also ensure the application's root and +app/+ directories are on
|
|
15
|
+
# +$LOAD_PATH+ so application code can +require+ its own files without
|
|
16
|
+
# specifying full paths.
|
|
17
|
+
|
|
18
|
+
# Loads the application files for +side+ from +root+, adding the root and
|
|
19
|
+
# +app/+ directories to +$LOAD_PATH+ first.
|
|
20
|
+
#
|
|
21
|
+
# Uses +Kernel#load+ for both files so they are always re-evaluated (rather
|
|
22
|
+
# than skipped if previously required). Both files are optional — only those
|
|
23
|
+
# that exist are loaded.
|
|
24
|
+
#
|
|
25
|
+
# @param side [:backend, :frontend] which side to boot
|
|
26
|
+
# @param root [String] the application root directory (defaults to +Dir.pwd+)
|
|
27
|
+
# @return [Array<String>] absolute paths of the files that were loaded
|
|
28
|
+
def self.boot(side = :backend, root: Dir.pwd)
|
|
29
|
+
root = prepare_root(root)
|
|
30
|
+
paths = app_paths(side, root: root)
|
|
31
|
+
|
|
32
|
+
paths = paths.select { |path| File.file?(path) }
|
|
33
|
+
paths.each { |path| load path }
|
|
34
|
+
paths
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Loads the application files for +side+ and returns the registered app object.
|
|
38
|
+
#
|
|
39
|
+
# This is a CRuby/server-side method — it is never run inside an Opal
|
|
40
|
+
# compiled bundle. Both files are loaded with +Kernel#load+ (a runtime file
|
|
41
|
+
# loader that accepts a computed path) rather than +require+.
|
|
42
|
+
#
|
|
43
|
+
# The setup file is expected to call +DuoRuby.app = <instance>+ to register
|
|
44
|
+
# the backend it creates. {.load_app} returns that registered value and clears
|
|
45
|
+
# the slot so successive calls in the same process behave independently.
|
|
46
|
+
#
|
|
47
|
+
# @param side [:backend, :frontend] which side to load
|
|
48
|
+
# @param root [String] the application root directory
|
|
49
|
+
# @return [Object, nil] the value passed to +DuoRuby.app=+, or +nil+ if the
|
|
50
|
+
# setup file does not exist or no app was registered
|
|
51
|
+
def self.load_app(side = :backend, root: Dir.pwd)
|
|
52
|
+
root = prepare_root(root)
|
|
53
|
+
config_path = File.join(root, "duoruby.rb")
|
|
54
|
+
load config_path if File.file?(config_path)
|
|
55
|
+
|
|
56
|
+
setup_path = File.join(root, "app", "setup", "#{side}.rb")
|
|
57
|
+
return unless File.file?(setup_path)
|
|
58
|
+
|
|
59
|
+
load setup_path
|
|
60
|
+
@app.tap { @app = nil }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Registers the application instance produced by a setup file.
|
|
64
|
+
# Called from within +app/setup/<side>.rb+; the registered value is read
|
|
65
|
+
# back by {.load_app} and then cleared.
|
|
66
|
+
#
|
|
67
|
+
# @param instance [Object] the backend or frontend instance
|
|
68
|
+
def self.app=(instance)
|
|
69
|
+
@app = instance
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Expands +root+ to an absolute path and prepends both +<root>+ and
|
|
73
|
+
# +<root>/app+ to +$LOAD_PATH+ (unless already present).
|
|
74
|
+
#
|
|
75
|
+
# @param root [String] the raw root path
|
|
76
|
+
# @return [String] the expanded absolute root path
|
|
77
|
+
def self.prepare_root(root)
|
|
78
|
+
root = File.expand_path(root)
|
|
79
|
+
[File.join(root, "app"), root].each do |path|
|
|
80
|
+
$LOAD_PATH.unshift(path) unless $LOAD_PATH.include?(path)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
root
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Returns the canonical pair of application file paths for +side+.
|
|
87
|
+
#
|
|
88
|
+
# @param side [:backend, :frontend]
|
|
89
|
+
# @param root [String] the application root directory
|
|
90
|
+
# @return [Array<String, String>] +[config_path, setup_path]+
|
|
91
|
+
def self.app_paths(side, root: Dir.pwd)
|
|
92
|
+
root = File.expand_path(root)
|
|
93
|
+
[File.join(root, "duoruby.rb"), File.join(root, "app", "setup", "#{side}.rb")]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DuoRuby
|
|
4
|
+
class Channel
|
|
5
|
+
module HandlerMethods
|
|
6
|
+
def self.included(receiver)
|
|
7
|
+
receiver.extend(self)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def handlers
|
|
11
|
+
@handlers ||= {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def on(event, &handler)
|
|
15
|
+
add_handler(event, false, &handler)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def one(event, &handler)
|
|
19
|
+
add_handler(event, true, &handler)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def off(event = nil, handler = nil, &block)
|
|
23
|
+
remove_handler(handlers, event, handler || block)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def channel(name)
|
|
27
|
+
Namespace.new(self, name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def included(receiver)
|
|
31
|
+
receiver.__send__(:merge_handlers, handlers)
|
|
32
|
+
super if defined?(super)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
protected
|
|
36
|
+
|
|
37
|
+
def add_handler(event, once, &handler)
|
|
38
|
+
raise ArgumentError, "handler required" unless handler
|
|
39
|
+
|
|
40
|
+
event = normalize_event(event)
|
|
41
|
+
handlers[event] ||= []
|
|
42
|
+
Handler.new(event, handler, once).tap { |registered| handlers[event] << registered }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def merge_handlers(other_handlers)
|
|
46
|
+
@handlers = clone_handlers(handlers)
|
|
47
|
+
other_handlers.each do |event, event_handlers|
|
|
48
|
+
@handlers[event] ||= []
|
|
49
|
+
@handlers[event].concat(event_handlers.map { |h| Handler.new(h.event, h.block, h.once) })
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def clone_handlers(source)
|
|
54
|
+
source.each_with_object({}) do |(event, event_handlers), cloned|
|
|
55
|
+
cloned[event] = event_handlers.map { |h| Handler.new(h.event, h.block, h.once) }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def normalize_event(event)
|
|
60
|
+
event.to_s
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def remove_handler(source, event = nil, handler = nil)
|
|
64
|
+
return source.clear unless event
|
|
65
|
+
|
|
66
|
+
if event.is_a?(Handler)
|
|
67
|
+
source[event.event]&.delete_if { |registered| same_handler?(registered, event) }
|
|
68
|
+
source.delete(event.event) if source[event.event]&.empty?
|
|
69
|
+
return
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
event = normalize_event(event)
|
|
73
|
+
return source.delete(event) unless handler
|
|
74
|
+
|
|
75
|
+
source[event]&.delete_if { |registered| same_handler?(registered, handler) }
|
|
76
|
+
source.delete(event) if source[event]&.empty?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def same_handler?(registered, handler)
|
|
80
|
+
return registered.equal?(handler) || registered.block.equal?(handler.block) if handler.is_a?(Handler)
|
|
81
|
+
|
|
82
|
+
registered.block.equal?(handler)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DuoRuby
|
|
4
|
+
class Channel
|
|
5
|
+
class Namespace
|
|
6
|
+
def initialize(target, name)
|
|
7
|
+
@target = target
|
|
8
|
+
@name = name.to_s
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def on(event, &handler)
|
|
12
|
+
@target.on(namespaced(event), &handler)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def one(event, &handler)
|
|
16
|
+
@target.one(namespaced(event), &handler)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def off(event = nil, handler = nil, &block)
|
|
20
|
+
return @target.off unless event
|
|
21
|
+
|
|
22
|
+
@target.off(namespaced(event), handler, &block)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def trigger(event, *args, **params)
|
|
26
|
+
@target.trigger(namespaced(event), *args, **params)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def send(event, **params)
|
|
30
|
+
@target.send(namespaced(event), **params)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def namespaced(event)
|
|
36
|
+
"#{@name}:#{event}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "duoruby/channel/namespace"
|
|
4
|
+
require "duoruby/channel/handler_methods"
|
|
5
|
+
|
|
6
|
+
module DuoRuby
|
|
7
|
+
# Base class for event-driven components in DuoRuby.
|
|
8
|
+
#
|
|
9
|
+
# Channel provides a flexible pub/sub event system with support for:
|
|
10
|
+
# - Class-level handler declarations that are inherited by subclasses
|
|
11
|
+
# - Module-level handler declarations that merge into including classes
|
|
12
|
+
# - Per-instance handler isolation (class handlers are deep-cloned on initialize)
|
|
13
|
+
# - One-shot handlers via {#one}
|
|
14
|
+
# - Wildcard handlers that run on every event via {#on} with the +*+ event name
|
|
15
|
+
# - Removal by event name, proc reference, or Handler token
|
|
16
|
+
#
|
|
17
|
+
# {Server} and {Socket} both inherit from Channel.
|
|
18
|
+
#
|
|
19
|
+
# @example Declaring handlers at the class level (inherited by instances)
|
|
20
|
+
# class MyServer < DuoRuby::Server
|
|
21
|
+
# on(:join) { |client, room:| group(room) << client }
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# @example Adding handlers on an instance
|
|
25
|
+
# server = MyServer.new
|
|
26
|
+
# token = server.on(:join) { |client, room:| puts "#{client.id} joined #{room}" }
|
|
27
|
+
# server.off(token) # remove by token
|
|
28
|
+
class Channel
|
|
29
|
+
# @return [Hash{String => Array<Handler>}] the event-to-handlers map for this object
|
|
30
|
+
attr_reader :handlers
|
|
31
|
+
|
|
32
|
+
# Internal record tying a block to its event and once-flag.
|
|
33
|
+
# The value returned by {#on} and {#one} is a Handler; pass it to {#off}
|
|
34
|
+
# to remove that specific registration.
|
|
35
|
+
Handler = Struct.new(:event, :block, :once)
|
|
36
|
+
|
|
37
|
+
extend HandlerMethods
|
|
38
|
+
|
|
39
|
+
# Copies the parent class's handlers into the subclass when a subclass is defined.
|
|
40
|
+
# @private
|
|
41
|
+
def self.inherited(subclass)
|
|
42
|
+
subclass.__send__(:merge_handlers, handlers)
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Deep-clones the class-level handlers into this instance so that instance-level
|
|
47
|
+
# {#on}/{#off} calls do not affect the class or other instances.
|
|
48
|
+
def initialize
|
|
49
|
+
@handlers = self.class.__send__(:clone_handlers, self.class.handlers)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Registers a persistent instance-level handler for +event+.
|
|
53
|
+
# Instance handlers stack on top of any class-level handlers that were
|
|
54
|
+
# copied in at initialize time.
|
|
55
|
+
#
|
|
56
|
+
# @param event [String, Symbol] event name; use +"*"+ to match every event
|
|
57
|
+
# @return [Handler] removal token
|
|
58
|
+
def on(event, &handler)
|
|
59
|
+
raise ArgumentError, "handler required" unless handler
|
|
60
|
+
|
|
61
|
+
event = event.to_s
|
|
62
|
+
handlers[event] ||= []
|
|
63
|
+
Handler.new(event, handler, false).tap { |registered| handlers[event] << registered }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Registers a one-shot instance-level handler for +event+.
|
|
67
|
+
# Automatically removed after the first dispatch.
|
|
68
|
+
#
|
|
69
|
+
# @param event [String, Symbol] event name
|
|
70
|
+
# @return [Handler] removal token
|
|
71
|
+
def one(event, &handler)
|
|
72
|
+
raise ArgumentError, "handler required" unless handler
|
|
73
|
+
|
|
74
|
+
event = event.to_s
|
|
75
|
+
handlers[event] ||= []
|
|
76
|
+
Handler.new(event, handler, true).tap { |registered| handlers[event] << registered }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Removes instance-level handlers. See {HandlerMethods#off} for the full
|
|
80
|
+
# removal semantics — behaviour is identical at the instance level.
|
|
81
|
+
def off(event = nil, handler = nil, &block)
|
|
82
|
+
target = handler || block
|
|
83
|
+
return handlers.clear unless event
|
|
84
|
+
|
|
85
|
+
if event.is_a?(Handler)
|
|
86
|
+
handlers[event.event]&.delete_if { |r| handler_identity?(r, event) }
|
|
87
|
+
handlers.delete(event.event) if handlers[event.event]&.empty?
|
|
88
|
+
return
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
event = event.to_s
|
|
92
|
+
return handlers.delete(event) unless target
|
|
93
|
+
|
|
94
|
+
handlers[event]&.delete_if { |r| handler_identity?(r, target) }
|
|
95
|
+
handlers.delete(event) if handlers[event]&.empty?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def channel(name)
|
|
99
|
+
Namespace.new(self, name)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Returns a Proc wrapping the first registered handler for +event+, bound
|
|
103
|
+
# to this instance via +instance_exec+. Returns +nil+ if no handler exists.
|
|
104
|
+
# Used internally; may be useful for adapter integrations.
|
|
105
|
+
#
|
|
106
|
+
# @param event [String, Symbol]
|
|
107
|
+
# @return [Proc, nil]
|
|
108
|
+
def handler_for(event)
|
|
109
|
+
event = event.to_s
|
|
110
|
+
handler = handlers[event]&.first
|
|
111
|
+
proc { |*args, **params| instance_exec(*args, **params, &handler.block) } if handler
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Dispatches +event+ to all registered handlers, then to any wildcard (+*+) handlers.
|
|
115
|
+
#
|
|
116
|
+
# All handlers are invoked via +instance_exec+ so they run in the context of
|
|
117
|
+
# this Channel instance. Positional +args+ and keyword +params+ are forwarded
|
|
118
|
+
# as-is. One-shot handlers are removed immediately after firing.
|
|
119
|
+
#
|
|
120
|
+
# @param event [String, Symbol] the event name
|
|
121
|
+
# @param args positional arguments to forward (e.g. the client in Server handlers)
|
|
122
|
+
# @param params keyword arguments to forward (e.g. message params)
|
|
123
|
+
# @return [nil]
|
|
124
|
+
def dispatch(event, *args, **params)
|
|
125
|
+
event = event.to_s
|
|
126
|
+
results = dispatch_handlers(event, handlers[event], *args, **params)
|
|
127
|
+
dispatch_handlers("*", handlers["*"], event, *args, **params)
|
|
128
|
+
results
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Alias for {#dispatch}.
|
|
132
|
+
alias trigger dispatch
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
# Checks handler identity by object identity (for Handler tokens) or
|
|
137
|
+
# block proc identity (for raw Proc arguments).
|
|
138
|
+
def handler_identity?(registered, handler)
|
|
139
|
+
return registered.equal?(handler) || registered.block.equal?(handler.block) if handler.is_a?(Handler)
|
|
140
|
+
|
|
141
|
+
registered.block.equal?(handler)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Iterates over a snapshot of +event_handlers+, invoking each via +instance_exec+.
|
|
145
|
+
# Removes one-shot handlers after they fire.
|
|
146
|
+
def dispatch_handlers(event, event_handlers, *args, **params)
|
|
147
|
+
return [] unless event_handlers
|
|
148
|
+
|
|
149
|
+
event_handlers.dup.map do |handler|
|
|
150
|
+
result = instance_exec(*args, **params, &handler.block)
|
|
151
|
+
off(event, handler.block) if handler.once
|
|
152
|
+
result
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|