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
data/lib/duoruby/cli.rb
ADDED
|
@@ -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
|