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 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
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "duoruby/setup/backend"
5
+ require "duoruby/cli"
6
+
7
+ exit DuoRuby::CLI.new(ARGV, input: $stdin, output: $stdout).call
@@ -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