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,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "duoruby/version"
4
+
5
+ module DuoRuby
6
+ # Command-line interface for the +duoruby+ executable.
7
+ #
8
+ # Commands:
9
+ # - +help+ — prints usage information
10
+ # - +version+ — prints the gem version
11
+ # - +serve+ — starts the HTTP/WebSocket server (accepts +--host+ and +--port+ options)
12
+ # - +launch+ — starts the server and opens a native webview window
13
+ #
14
+ # All commands return an integer exit code. +duoruby/server+ is required lazily
15
+ # by +serve+ to avoid loading Falcon/Async for other commands.
16
+ #
17
+ # @example Programmatic use (mirrors the +exe/duoruby+ entry point)
18
+ # exit DuoRuby::CLI.new(ARGV, input: $stdin, output: $stdout).call
19
+ class CLI
20
+ # @param args [Array<String>] the command-line arguments (typically +ARGV+)
21
+ # @param input [IO] standard input (reserved for future interactive use)
22
+ # @param output [IO] standard output where all messages are printed
23
+ def initialize(args, input:, output:)
24
+ @args = args
25
+ @input = input
26
+ @output = output
27
+ end
28
+
29
+ # Dispatches to the appropriate command handler.
30
+ #
31
+ # @return [Integer] 0 on success, 1 on error
32
+ def call
33
+ case @args.first
34
+ when nil, "help", "--help", "-h"
35
+ help
36
+ when "version", "--version", "-v"
37
+ @output.puts VERSION
38
+ 0
39
+ when "serve"
40
+ serve
41
+ when "launch"
42
+ launch
43
+ else
44
+ @output.puts "unknown command: #{@args.first}"
45
+ 1
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ # Prints usage summary.
52
+ # @return [Integer] 0
53
+ def help
54
+ @output.puts "duoruby help"
55
+ @output.puts "duoruby version"
56
+ @output.puts "duoruby serve [--host HOST] [--port PORT]"
57
+ @output.puts "duoruby launch [--host HOST] [--port PORT] [--title TITLE]"
58
+ 0
59
+ end
60
+
61
+ # Parses +--host+ and +--port+ options, then starts the server.
62
+ #
63
+ # @return [Integer] 0 on success, 1 on option parse failure
64
+ def serve
65
+ options = serve_options
66
+ return 1 unless options
67
+
68
+ require "duoruby/server"
69
+
70
+ DuoRuby::Server.build(**options).run(output: @output)
71
+ 0
72
+ end
73
+
74
+ # Parses options and opens a native webview window backed by the server.
75
+ #
76
+ # @return [Integer] 0 on success, 1 on option parse failure
77
+ def launch
78
+ options = launch_options
79
+ return 1 unless options
80
+
81
+ require "duoruby/launcher"
82
+
83
+ DuoRuby::Launcher.new(**options).run(output: @output)
84
+ 0
85
+ end
86
+
87
+ # Parses the options that follow the +launch+ command.
88
+ #
89
+ # @return [Hash, nil]
90
+ def launch_options
91
+ options = {root: Dir.pwd}
92
+ args = @args.drop(1)
93
+
94
+ until args.empty?
95
+ case (arg = args.shift)
96
+ when "--host"
97
+ return missing_option_value if args.empty?
98
+
99
+ options[:host] = args.shift
100
+ when "--port"
101
+ return missing_option_value if args.empty?
102
+
103
+ options[:port] = parse_port(args.shift)
104
+ return unless options[:port]
105
+ when "--title"
106
+ return missing_option_value if args.empty?
107
+
108
+ options[:title] = args.shift
109
+ else
110
+ @output.puts "unknown launch option: #{arg}"
111
+ return
112
+ end
113
+ end
114
+
115
+ options
116
+ end
117
+
118
+ # Parses the options that follow the +serve+ command.
119
+ #
120
+ # @return [Hash, nil] option hash on success, +nil+ on parse failure
121
+ def serve_options
122
+ options = {root: Dir.pwd}
123
+ args = @args.drop(1)
124
+
125
+ until args.empty?
126
+ case (arg = args.shift)
127
+ when "--host"
128
+ return missing_option_value if args.empty?
129
+
130
+ options[:host] = args.shift
131
+ when "--port"
132
+ return missing_option_value if args.empty?
133
+
134
+ options[:port] = parse_port(args.shift)
135
+ return unless options[:port]
136
+ else
137
+ @output.puts "unknown serve option: #{arg}"
138
+ return
139
+ end
140
+ end
141
+
142
+ options
143
+ end
144
+
145
+ # Prints a "missing value" error and returns +nil+.
146
+ def missing_option_value
147
+ @output.puts "missing option value"
148
+ nil
149
+ end
150
+
151
+ # Parses +value+ as a TCP port number (1–65535).
152
+ # Prints an error and returns +nil+ for anything invalid.
153
+ #
154
+ # @param value [String] the raw port string
155
+ # @return [Integer, nil]
156
+ def parse_port(value)
157
+ port = Integer(value)
158
+ return port if port.between?(1, 65_535)
159
+ rescue ArgumentError, TypeError
160
+ nil
161
+ ensure
162
+ @output.puts "invalid serve port: #{value}" unless port&.between?(1, 65_535)
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "duoruby/message"
4
+ require "duoruby/reply_promise"
5
+ require "duoruby/channel/namespace"
6
+
7
+ module DuoRuby
8
+ # Represents a single connected WebSocket client.
9
+ #
10
+ # Client wraps the low-level connection writer and acts as a typed
11
+ # key-value store for per-connection application state (e.g. current
12
+ # room, display name, authentication status).
13
+ #
14
+ # Instances are created by {Server#connect} and passed as the first
15
+ # argument to every server event handler.
16
+ #
17
+ # @example Sending a message to a client
18
+ # client.send(:snapshot, rooms: ["lobby"], users: ["Alice"])
19
+ #
20
+ # @example Storing and reading application state
21
+ # client[:name] = "Alice"
22
+ # client[:name] # => "Alice"
23
+ class Client
24
+ # @return [String] the unique connection identifier assigned by the server
25
+ attr_reader :id
26
+
27
+ # @return [Hash{Symbol => Object}] per-client application state
28
+ attr_reader :attributes
29
+
30
+ # @return [Hash{Symbol => Group}] groups this client currently belongs to
31
+ attr_reader :groups
32
+
33
+ attr_reader :metadata
34
+
35
+ # @param id [String] a unique identifier for this connection
36
+ # @param writer [Proc, nil] callable that accepts a serialized message Hash;
37
+ # mutually exclusive with the block form
38
+ # @yieldparam message [Hash] the serialized message to deliver
39
+ def initialize(id:, writer: nil, metadata: {}, &writer_block)
40
+ @id = id
41
+ @writer = writer || writer_block
42
+ @metadata = metadata
43
+ @attributes = {}
44
+ @groups = {}
45
+ @accepted = true
46
+ @pending_calls = {}
47
+ @next_call_id = 0
48
+ end
49
+
50
+ # Reads an application attribute by symbol key.
51
+ # @param key [String, Symbol]
52
+ # @return [Object, nil]
53
+ def [](key)
54
+ attributes[key.to_sym]
55
+ end
56
+
57
+ # Writes an application attribute. Keys are always stored as symbols.
58
+ # @param key [String, Symbol]
59
+ # @param value [Object]
60
+ def []=(key, value)
61
+ attributes[key.to_sym] = value
62
+ end
63
+
64
+ # Sends a message to this client over the WebSocket connection.
65
+ #
66
+ # @param event [String, Symbol] the event name
67
+ # @param params keyword arguments that become the message params
68
+ def send(event, **params)
69
+ return send_question(event, **params) if question_event?(event)
70
+
71
+ @writer.call(Message.new(event, **params).to_h)
72
+ end
73
+
74
+ def deliver(message)
75
+ @writer.call(Message.coerce(message).to_h)
76
+ end
77
+
78
+ def channel(name)
79
+ Channel::Namespace.new(self, name)
80
+ end
81
+
82
+ def resolve_call(message)
83
+ promise = @pending_calls.delete(message.reply_to)
84
+ promise&.resolve(message.params[:result])
85
+ end
86
+
87
+ def reject_call(message)
88
+ promise = @pending_calls.delete(message.reply_to)
89
+ promise&.reject(ReplyError.new(message.params))
90
+ end
91
+
92
+ def cancel_pending_calls(code: :disconnect, message: "connection closed", details: nil)
93
+ error = ReplyError.new(code: code, message: message, details: details)
94
+ @pending_calls.each_value { |promise| promise.reject(error) }
95
+ @pending_calls.clear
96
+ self
97
+ end
98
+
99
+ def join(group)
100
+ group.add(self)
101
+ end
102
+
103
+ def leave(group)
104
+ group.remove(self)
105
+ end
106
+
107
+ def accepted?
108
+ @accepted
109
+ end
110
+
111
+ def reject(code: :unauthorized, message: "connection rejected", details: nil)
112
+ @accepted = false
113
+ cancel_pending_calls(code: code, message: message, details: details)
114
+ deliver(Message.error(code: code, message: message, details: details))
115
+ self
116
+ end
117
+
118
+ private
119
+
120
+ def question_event?(event)
121
+ event.to_s.end_with?("?")
122
+ end
123
+
124
+ def send_question(event, **params)
125
+ id = next_call_id
126
+ promise = ReplyPromise.new
127
+ @pending_calls[id] = promise
128
+ @writer.call(Message.request(event, id, **params).to_h)
129
+ promise
130
+ end
131
+
132
+ def next_call_id
133
+ @next_call_id += 1
134
+ "call-#{@next_call_id}"
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DuoRuby
4
+ # Holds framework-level configuration set by the application's +duoruby.rb+.
5
+ #
6
+ # @example In <root>/duoruby.rb
7
+ # DuoRuby.configure do |c|
8
+ # c.title = "My App"
9
+ # end
10
+ class Config
11
+ # @return [String] the window title used by +duoruby launch+
12
+ attr_accessor :title
13
+
14
+ # @return [String, nil] the server host, set by the framework before loading the app
15
+ attr_accessor :host
16
+
17
+ # @return [Integer, nil] the server port, set by the framework before loading the app
18
+ attr_accessor :port
19
+
20
+ # @return [Array<String>] extra gems whose Opal sources are added to the frontend build
21
+ attr_accessor :frontend_gems
22
+
23
+ # @return [Array<String>] paths to stub (compile as empty) in the frontend Opal build
24
+ attr_accessor :frontend_stubs
25
+
26
+ def initialize
27
+ @title = "DuoRuby"
28
+ @frontend_gems = []
29
+ @frontend_stubs = []
30
+ end
31
+ end
32
+
33
+ # Returns the current framework configuration object.
34
+ # @return [Config]
35
+ def self.config
36
+ @config ||= Config.new
37
+ end
38
+
39
+ # Yields the configuration object for mutation.
40
+ #
41
+ # @yieldparam config [Config]
42
+ def self.configure
43
+ yield config
44
+ end
45
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "duoruby/channel/namespace"
4
+
5
+ module DuoRuby
6
+ # A named set of clients that can be messaged as a unit.
7
+ #
8
+ # Groups provide the broadcast primitive for server-side pub/sub. Membership
9
+ # is bidirectional: the group tracks its members, and each {Client} tracks
10
+ # which groups it belongs to. This makes it cheap to remove a client from
11
+ # all its groups on disconnect without iterating every group.
12
+ #
13
+ # Groups are created lazily by {Server#group} and are keyed by symbol name.
14
+ #
15
+ # @example Adding a client to a group and broadcasting
16
+ # server.group(:lobby) << client
17
+ # server.group(:lobby).send(:announcement, text: "Server restart in 5 min")
18
+ class Group
19
+ class Selection
20
+ def initialize(members)
21
+ @members = members
22
+ end
23
+
24
+ def send(event, **params)
25
+ replies = @members.map { |client| client.send(event, **params) }
26
+ return replies if question_event?(event)
27
+
28
+ self
29
+ end
30
+
31
+ def channel(name)
32
+ Channel::Namespace.new(self, name)
33
+ end
34
+
35
+ private
36
+
37
+ def question_event?(event)
38
+ event.to_s.end_with?("?")
39
+ end
40
+ end
41
+
42
+ # @return [Symbol] the group's name
43
+ attr_reader :name
44
+
45
+ # @return [Array<Client>] current members, in the order they joined
46
+ attr_reader :members
47
+
48
+ # @param name [String, Symbol] the group name; stored as a Symbol
49
+ def initialize(name)
50
+ @name = name.to_sym
51
+ @members = []
52
+ end
53
+
54
+ # Adds +client+ to the group (no-op if already a member).
55
+ # Also registers this group in +client.groups+.
56
+ #
57
+ # @param client [Client]
58
+ # @return [self]
59
+ def add(client)
60
+ members << client unless members.include?(client)
61
+ client.groups[name] = self
62
+ self
63
+ end
64
+
65
+ # Shovel operator — same as {#add}.
66
+ alias << add
67
+
68
+ # Removes +client+ from the group and unregisters the group from +client.groups+.
69
+ #
70
+ # @param client [Client]
71
+ # @return [Client] the removed client
72
+ def remove(client)
73
+ members.delete(client)
74
+ client.groups.delete(name)
75
+ client
76
+ end
77
+
78
+ def include?(client)
79
+ members.include?(client)
80
+ end
81
+
82
+ def size
83
+ members.size
84
+ end
85
+
86
+ def empty?
87
+ members.empty?
88
+ end
89
+
90
+ def except(*clients)
91
+ Selection.new(members - clients)
92
+ end
93
+
94
+ def send_to_others(client, event, **params)
95
+ except(client).send(event, **params)
96
+ self
97
+ end
98
+
99
+ def channel(name)
100
+ Channel::Namespace.new(self, name)
101
+ end
102
+
103
+ # Sends +event+ with +params+ to every current member.
104
+ #
105
+ # @param event [String, Symbol] the event name
106
+ # @param params keyword arguments forwarded to each {Client#send}
107
+ # @return [self]
108
+ def send(event, **params)
109
+ replies = members.map { |client| client.send(event, **params) }
110
+ return replies if question_event?(event)
111
+
112
+ self
113
+ end
114
+
115
+ private
116
+
117
+ def question_event?(event)
118
+ event.to_s.end_with?("?")
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "duoruby/config"
5
+ require "webview_util"
6
+
7
+ module DuoRuby
8
+ # Starts the server in a child process and opens a native webview window
9
+ # on the main process's main thread.
10
+ #
11
+ # GTK requires all calls on the thread that called gtk_init (the OS main
12
+ # thread). The Async/Falcon server runs in a forked child process so each
13
+ # has full ownership of its own event loop. When the window is closed the
14
+ # child is terminated; when the child dies unexpectedly the window closes.
15
+ #
16
+ # The window title defaults to +DuoRuby.config.title+, which the application
17
+ # can set in its +duoruby.rb+ config file:
18
+ #
19
+ # DuoRuby.configure { |c| c.title = "My App" }
20
+ #
21
+ # @example
22
+ # DuoRuby::Launcher.new(root: __dir__).run
23
+ class Launcher
24
+ # @param root [String] application root directory
25
+ # @param host [String] server host (default: +"127.0.0.1"+)
26
+ # @param port [Integer, nil] server port; +nil+ picks a free port automatically
27
+ # @param title [String, nil] window title; +nil+ uses +DuoRuby.config.title+
28
+ # @param width [Integer] window width in pixels (default: +1280+)
29
+ # @param height [Integer] window height in pixels (default: +800+)
30
+ def initialize(root: Dir.pwd, host: "127.0.0.1", port: nil,
31
+ title: nil, width: 1280, height: 800)
32
+ @root = File.expand_path(root)
33
+ @host = host
34
+ @port = port || free_port
35
+ @title = title
36
+ @width = width
37
+ @height = height
38
+ end
39
+
40
+ # Forks the Async server into a child process, then opens the native
41
+ # window on the main thread. Blocks until the window is closed, then
42
+ # terminates the server child.
43
+ #
44
+ # @param output [IO] where to print the launch banner (default: +$stdout+)
45
+ def run(output: $stdout)
46
+ output.puts "launching http://#{@host}:#{@port}"
47
+
48
+ server_pid = fork { run_server }
49
+
50
+ wait_for_server
51
+
52
+ title = @title || DuoRuby.config.title
53
+ window = WebviewUtil::Window.new(title: title, width: @width, height: @height)
54
+ window.navigate("http://#{@host}:#{@port}")
55
+ window.run
56
+ ensure
57
+ if server_pid
58
+ Process.kill(:TERM, server_pid) rescue nil
59
+ Process.waitpid(server_pid) rescue nil
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ # Runs the server in the child process.
66
+ def run_server
67
+ require "console"
68
+ require "duoruby/server"
69
+ Console.logger.fatal!
70
+ Server.build(root: @root, host: @host, port: @port)
71
+ .run(output: File.open(File::NULL, "w"))
72
+ end
73
+
74
+ # Allocates a free TCP port by binding to port 0 and reading the assigned port.
75
+ def free_port
76
+ server = TCPServer.new(@host, 0)
77
+ server.addr[1]
78
+ ensure
79
+ server&.close
80
+ end
81
+
82
+ # Polls until the server is accepting TCP connections.
83
+ def wait_for_server
84
+ loop do
85
+ TCPSocket.new(@host, @port).close
86
+ break
87
+ rescue Errno::ECONNREFUSED
88
+ sleep 0.05
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "duoruby/version"
4
+
5
+ module DuoRuby
6
+ # Represents a single WebSocket message: an event name and a keyword params hash.
7
+ #
8
+ # Messages are the protocol unit shared between backend and frontend. They
9
+ # serialize to a plain Hash for JSON transport and can be coerced back from
10
+ # that same Hash (with either string or symbol keys).
11
+ #
12
+ # @example Creating a message
13
+ # msg = Message.new("chat", text: "hello")
14
+ # msg.event # => "chat"
15
+ # msg.params # => {text: "hello"}
16
+ # msg.to_h # => {"event" => "chat", "params" => {"text" => "hello"}}
17
+ #
18
+ # @example Coercing from a parsed JSON hash
19
+ # Message.coerce("event" => "chat", "params" => {"text" => "hello"})
20
+ class Message
21
+ REPLY_EVENT = "$reply"
22
+ ERROR_EVENT = "$error"
23
+
24
+ attr_reader :event, :params, :id, :reply_to
25
+
26
+ def self.request(event, request_id, **params)
27
+ new(event, params, id: request_id)
28
+ end
29
+
30
+ def self.reply(reply_to, result)
31
+ new(REPLY_EVENT, {result: result}, reply_to: reply_to)
32
+ end
33
+
34
+ def self.error(code:, message:, details: nil, reply_to: nil)
35
+ params = {code: code.to_s, message: message.to_s}
36
+ params[:details] = details if details
37
+ new(ERROR_EVENT, params, reply_to: reply_to)
38
+ end
39
+
40
+ # Coerces +value+ into a Message.
41
+ #
42
+ # If +value+ is already a Message it is returned unchanged.
43
+ # Otherwise +value+ is treated as a Hash with string or symbol keys
44
+ # containing an +event+ key and an optional +params+ key.
45
+ #
46
+ # @param value [Message, Hash] the value to coerce
47
+ # @return [Message]
48
+ def self.coerce(value)
49
+ return value if value.is_a?(Message)
50
+
51
+ params = value.fetch("params") { value.fetch(:params, {}) }
52
+ new(
53
+ value.fetch("event") { value.fetch(:event) },
54
+ params.transform_keys(&:to_sym),
55
+ id: value.fetch("id") { value.fetch(:id, nil) },
56
+ reply_to: value.fetch("reply_to") { value.fetch(:reply_to, nil) }
57
+ )
58
+ end
59
+
60
+ # @param event [String, Symbol] the event name; stored as a String
61
+ # @param params [Hash] keyword params accompanying the event
62
+ def initialize(event, params = nil, id: nil, reply_to: nil, **keyword_params)
63
+ @event = event.to_s
64
+ @params = params || keyword_params
65
+ @id = id
66
+ @reply_to = reply_to
67
+ end
68
+
69
+ # Serializes the message to a plain Hash suitable for JSON encoding.
70
+ # Both the +event+ key and all +params+ keys are strings.
71
+ #
72
+ # @return [Hash]
73
+ def to_h
74
+ {"event" => event, "params" => params.transform_keys(&:to_s)}.tap do |message|
75
+ message["id"] = id if id
76
+ message["reply_to"] = reply_to if reply_to
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DuoRuby
4
+ class ReplyError < StandardError
5
+ attr_reader :code, :details, :params
6
+
7
+ def initialize(params)
8
+ @params = params.transform_keys(&:to_sym)
9
+ @code = @params[:code].to_s
10
+ @details = @params[:details]
11
+ super(@params[:message].to_s)
12
+ end
13
+ end
14
+ end