easy-serve 0.3 → 0.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/examples/remote-drb.rb +55 -0
- data/examples/remote-eval.rb +56 -0
- data/examples/{remote.rb → remote-manual.rb} +2 -1
- data/examples/remote-run-script.rb +19 -0
- data/examples/remote-run.rb +53 -0
- data/lib/easy-serve.rb +33 -12
- data/lib/easy-serve/remote-drb.rb +94 -0
- data/lib/easy-serve/remote-eval.rb +63 -0
- data/lib/easy-serve/remote-run.rb +61 -0
- data/lib/easy-serve/remote.rb +25 -0
- metadata +11 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: fa7e317b0835fcccb71074755f7c440a36eb97c5
|
4
|
+
data.tar.gz: f1473351a5081398d0c1a8dc4fe424555cbe7a8b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3f5ee396c21115e4d691c15409aa47f07ca9947a82753e33db9663f21e58ba0930f53541a193095b9705a44d14d96018ad6c03c38a187c407379d90196c7c1ac
|
7
|
+
data.tar.gz: b4027e51407e2070ec4039f70fda510cc5b8a22e5aae5396de86c360e2c2366840f9436bd84d313fc0b2ead5df016c4ff7cd4c83276d719512da868a82a64123
|
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'easy-serve/remote'
|
2
|
+
|
3
|
+
addr_there = ARGV.shift
|
4
|
+
|
5
|
+
unless addr_there
|
6
|
+
abort <<-END
|
7
|
+
|
8
|
+
Usage: #$0 addr_there
|
9
|
+
|
10
|
+
The 'addr_there' is the remote address on which client code will run.
|
11
|
+
It must be a destination accepted by ssh, optionally including a user name:
|
12
|
+
|
13
|
+
[user@]hostname
|
14
|
+
|
15
|
+
The 'hostname' must be a valid hostname (not just an ssh alias), since it
|
16
|
+
will be used for the drb connection as well.
|
17
|
+
|
18
|
+
END
|
19
|
+
end
|
20
|
+
|
21
|
+
EasyServe.start do |ez|
|
22
|
+
log = ez.log
|
23
|
+
log.level = Logger::INFO
|
24
|
+
log.formatter = nil if $VERBOSE
|
25
|
+
|
26
|
+
ez.start_servers do
|
27
|
+
ez.server "simple-server", :tcp, nil, 0 do |svr|
|
28
|
+
Thread.new do
|
29
|
+
loop do
|
30
|
+
Thread.new(svr.accept) do |conn|
|
31
|
+
log.info "accepted connection from #{conn.inspect}"
|
32
|
+
conn.write "hello from #{log.progname}"
|
33
|
+
log.info "wrote greeting"
|
34
|
+
conn.close_write
|
35
|
+
log.info "trying to read from #{conn.inspect}"
|
36
|
+
log.info "received: #{conn.read}"
|
37
|
+
conn.close
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
ez.remote "simple-server", host: addr_there do |conn|
|
45
|
+
# this block runs locally, but calls methods on the remote using drb
|
46
|
+
log.progname = "druby remote on #{addr_there}"
|
47
|
+
log.info "trying to read from #{conn.inspect}"
|
48
|
+
log.info "received: #{conn.read}"
|
49
|
+
# note: conn is drb proxy to real conn on remote host, so after the
|
50
|
+
# string is read from the socket in the remote, it is then serialized
|
51
|
+
# by drb back to this (local) process. Don't do this in production!
|
52
|
+
conn.write "hello from #{log.progname}"
|
53
|
+
conn.close
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'easy-serve/remote'
|
2
|
+
|
3
|
+
addr_there = ARGV.shift
|
4
|
+
|
5
|
+
unless addr_there
|
6
|
+
abort <<-END
|
7
|
+
|
8
|
+
Usage: #$0 addr_there
|
9
|
+
|
10
|
+
The 'addr_there' is the remote address on which client code will run.
|
11
|
+
It must be a destination accepted by ssh, optionally including a user name:
|
12
|
+
|
13
|
+
[user@]hostname
|
14
|
+
|
15
|
+
The 'hostname' may by any valid hostname or ssh alias.
|
16
|
+
|
17
|
+
END
|
18
|
+
end
|
19
|
+
|
20
|
+
EasyServe.start do |ez|
|
21
|
+
log = ez.log
|
22
|
+
log.level = Logger::INFO
|
23
|
+
log.formatter = nil if $VERBOSE
|
24
|
+
|
25
|
+
ez.start_servers do
|
26
|
+
ez.server "simple-server", :tcp, nil, 0 do |svr|
|
27
|
+
Thread.new do
|
28
|
+
loop do
|
29
|
+
Thread.new(svr.accept) do |conn|
|
30
|
+
log.info "accepted connection from #{conn.inspect}"
|
31
|
+
conn.write "hello from #{log.progname}"
|
32
|
+
log.info "wrote greeting"
|
33
|
+
conn.close_write
|
34
|
+
log.info "trying to read from #{conn.inspect}"
|
35
|
+
log.info "received: #{conn.read}"
|
36
|
+
conn.close
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
ez.remote "simple-server", host: addr_there, eval: %{
|
44
|
+
conn = conns[0]
|
45
|
+
# this code is executed on the remote host, connected by conn, not drb
|
46
|
+
log.progname = "eval remote on \#{host}"
|
47
|
+
log.info "trying to read from \#{conn.inspect}"
|
48
|
+
log.info "received: \#{conn.read}"
|
49
|
+
conn.write "hello from \#{log.progname}"
|
50
|
+
conn.close
|
51
|
+
}
|
52
|
+
# Note use of \#{} to interpolate variables that are only available
|
53
|
+
# in the binding where the code is eval-ed. Alternately, use
|
54
|
+
# eval: %Q{...}
|
55
|
+
# but then interpolation from this script is not posssible.
|
56
|
+
end
|
@@ -4,7 +4,8 @@ servers_file = ARGV.shift
|
|
4
4
|
unless servers_file
|
5
5
|
abort <<-END
|
6
6
|
Usage: #$0 servers_file
|
7
|
-
For the client, copy the generated servers_file to the client host
|
7
|
+
For the client, copy the generated servers_file to the client host, and
|
8
|
+
run with the same command.
|
8
9
|
END
|
9
10
|
end
|
10
11
|
|
@@ -0,0 +1,19 @@
|
|
1
|
+
class RemoteRunScript
|
2
|
+
attr_reader :conns, :host, :log, :args
|
3
|
+
|
4
|
+
def initialize conns, host, log, *args
|
5
|
+
@conns = conns
|
6
|
+
@host = host
|
7
|
+
@log = log
|
8
|
+
@args = args
|
9
|
+
end
|
10
|
+
|
11
|
+
def run
|
12
|
+
conn = conns[0]
|
13
|
+
log.progname = "run remote on #{host}"
|
14
|
+
log.info "trying to read from #{conn.inspect}"
|
15
|
+
log.info "received: #{conn.read}"
|
16
|
+
conn.write "hello from #{log.progname}"
|
17
|
+
conn.close
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
require 'easy-serve/remote'
|
2
|
+
|
3
|
+
addr_there = ARGV.shift
|
4
|
+
|
5
|
+
unless addr_there
|
6
|
+
abort <<-END
|
7
|
+
|
8
|
+
Usage: #$0 addr_there
|
9
|
+
|
10
|
+
The 'addr_there' is the remote address on which client code will run.
|
11
|
+
It must be a destination accepted by ssh, optionally including a user name:
|
12
|
+
|
13
|
+
[user@]hostname
|
14
|
+
|
15
|
+
The 'hostname' may by any valid hostname or ssh alias.
|
16
|
+
|
17
|
+
Note: you must set up the remote by doing
|
18
|
+
|
19
|
+
scp examples/remote-run-script.rb addr_there:/tmp/
|
20
|
+
|
21
|
+
END
|
22
|
+
end
|
23
|
+
|
24
|
+
EasyServe.start do |ez|
|
25
|
+
log = ez.log
|
26
|
+
log.level = Logger::INFO
|
27
|
+
log.formatter = nil if $VERBOSE
|
28
|
+
|
29
|
+
ez.start_servers do
|
30
|
+
ez.server "simple-server", :tcp, nil, 0 do |svr|
|
31
|
+
Thread.new do
|
32
|
+
loop do
|
33
|
+
Thread.new(svr.accept) do |conn|
|
34
|
+
log.info "accepted connection from #{conn.inspect}"
|
35
|
+
conn.write "hello from #{log.progname}"
|
36
|
+
log.info "wrote greeting"
|
37
|
+
conn.close_write
|
38
|
+
log.info "trying to read from #{conn.inspect}"
|
39
|
+
log.info "received: #{conn.read}"
|
40
|
+
conn.close
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
ez.remote "simple-server", host: addr_there,
|
48
|
+
dir: "/tmp",
|
49
|
+
file: "remote-run-script.rb",
|
50
|
+
# 'file' passed to load, so can be rel to dir or ruby's $LOAD_PATH
|
51
|
+
class_name: "RemoteRunScript",
|
52
|
+
args: []
|
53
|
+
end
|
data/lib/easy-serve.rb
CHANGED
@@ -4,7 +4,7 @@ require 'yaml'
|
|
4
4
|
require 'fileutils'
|
5
5
|
|
6
6
|
class EasyServe
|
7
|
-
VERSION = "0.
|
7
|
+
VERSION = "0.4"
|
8
8
|
|
9
9
|
class Server
|
10
10
|
attr_reader :name, :pid, :addr
|
@@ -58,17 +58,19 @@ class EasyServe
|
|
58
58
|
@clients = [] # pid
|
59
59
|
@passive_clients = [] # pid
|
60
60
|
@owner = false
|
61
|
-
@servers =
|
61
|
+
@servers = opts[:servers] # name => Server
|
62
62
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
63
|
+
unless servers
|
64
|
+
if servers_file
|
65
|
+
@servers =
|
66
|
+
begin
|
67
|
+
load_server_table
|
68
|
+
rescue Errno::ENOENT
|
69
|
+
init_server_table
|
70
|
+
end
|
71
|
+
else
|
72
|
+
init_server_table
|
73
|
+
end
|
72
74
|
end
|
73
75
|
end
|
74
76
|
|
@@ -167,11 +169,27 @@ class EasyServe
|
|
167
169
|
name =~ /-\d+\z/ ? name.succ : name + "-0"
|
168
170
|
end
|
169
171
|
|
172
|
+
def host_name
|
173
|
+
@host_name ||= begin
|
174
|
+
hn = Socket.gethostname
|
175
|
+
begin
|
176
|
+
official_hostname = Socket.gethostbyname(hn)[0]
|
177
|
+
if /\./ =~ official_hostname
|
178
|
+
official_hostname
|
179
|
+
else
|
180
|
+
official_hostname + ".local"
|
181
|
+
end
|
182
|
+
rescue
|
183
|
+
'localhost'
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
170
188
|
def server name, proto = :unix, host = nil, port = nil
|
171
189
|
server_class, *server_addr =
|
172
190
|
case proto
|
173
191
|
when /unix/i; [UNIXServer, choose_socket_filename(name, base: host)]
|
174
|
-
when /tcp/i; [TCPServer, host ||
|
192
|
+
when /tcp/i; [TCPServer, host || host_name, port || 0]
|
175
193
|
else raise ArgumentError, "Unknown socket protocol: #{proto.inspect}"
|
176
194
|
end
|
177
195
|
|
@@ -264,6 +282,9 @@ class EasyServe
|
|
264
282
|
else TCPSocket
|
265
283
|
end
|
266
284
|
socket_class.new(*addr)
|
285
|
+
rescue => ex
|
286
|
+
ex.message << " addr=#{addr.inspect}"
|
287
|
+
raise
|
267
288
|
end
|
268
289
|
|
269
290
|
# ^C in the irb session (parent process) should not kill the
|
@@ -0,0 +1,94 @@
|
|
1
|
+
require 'drb'
|
2
|
+
|
3
|
+
class EasyServe
|
4
|
+
# useful for testing only -- use _eval or _run for production
|
5
|
+
def remote_drb *server_names, host: nil
|
6
|
+
## passive option?
|
7
|
+
## remote logfile option?
|
8
|
+
|
9
|
+
DRb.start_service("druby://#{host_name}:0", nil)
|
10
|
+
|
11
|
+
hostname = host.sub(/.*@/,"")
|
12
|
+
host_uri = "druby://#{hostname}:0"
|
13
|
+
|
14
|
+
log.progname = "remote_drb #{host}"
|
15
|
+
|
16
|
+
IO.popen ["ssh", host, "ruby"], "w+" do |ssh|
|
17
|
+
ssh.puts %Q{
|
18
|
+
$stdout.sync = true
|
19
|
+
begin
|
20
|
+
require 'drb'
|
21
|
+
require 'yaml'
|
22
|
+
require 'easy-serve'
|
23
|
+
|
24
|
+
server_names = #{server_names.inspect}
|
25
|
+
servers = YAML.load(#{YAML.dump(servers).inspect})
|
26
|
+
log_level = #{log.level}
|
27
|
+
host_uri = #{host_uri.inspect}
|
28
|
+
|
29
|
+
EasyServe.start servers: servers do |ez|
|
30
|
+
log = ez.log
|
31
|
+
log.level = log_level
|
32
|
+
log.formatter = nil if $VERBOSE
|
33
|
+
|
34
|
+
ez.local *server_names do |*conns|
|
35
|
+
begin
|
36
|
+
DRb.start_service(host_uri, {conns: conns})
|
37
|
+
puts DRb.uri
|
38
|
+
|
39
|
+
Thread.new do
|
40
|
+
loop do
|
41
|
+
sleep 1
|
42
|
+
begin
|
43
|
+
puts "."
|
44
|
+
rescue
|
45
|
+
exit
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
DRb.thread.join
|
51
|
+
|
52
|
+
rescue => ex
|
53
|
+
puts "ez error", ex, ex.backtrace
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
rescue => ex
|
58
|
+
puts "ez error", ex, ex.backtrace
|
59
|
+
end
|
60
|
+
}
|
61
|
+
|
62
|
+
ssh.close_write
|
63
|
+
result = ssh.gets
|
64
|
+
|
65
|
+
if !result
|
66
|
+
raise RemoteError, "problem with ssh connection to remote"
|
67
|
+
else
|
68
|
+
error = result[/ez error/]
|
69
|
+
if error
|
70
|
+
raise RemoteError, "error raised in remote: #{ssh.read}"
|
71
|
+
else
|
72
|
+
uri = result[/druby:\/\/\S+/]
|
73
|
+
if uri
|
74
|
+
Thread.new do
|
75
|
+
loop do
|
76
|
+
ssh.gets # consume the "."
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
log.debug "remote is at #{uri}"
|
81
|
+
ro = DRbObject.new_with_uri(uri)
|
82
|
+
conns = ro[:conns]
|
83
|
+
conns_ary = []
|
84
|
+
conns.each {|c| conns_ary << c} # needed because it's a DRbObject
|
85
|
+
yield(*conns_ary) if block_given?
|
86
|
+
else
|
87
|
+
raise RemoteError,
|
88
|
+
"no druby uri in string from remote: #{result.inspect}"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
class EasyServe
|
2
|
+
# useful simple cases in testing and in production, but long eval strings
|
3
|
+
# can be hard to debug -- use _run instead
|
4
|
+
def remote_eval *server_names, host: nil, **opts
|
5
|
+
## passive option?
|
6
|
+
## remote logfile option?
|
7
|
+
|
8
|
+
log.progname = "remote_eval #{host}"
|
9
|
+
|
10
|
+
IO.popen ["ssh", host, "ruby"], "w+" do |ssh|
|
11
|
+
ssh.puts %Q{
|
12
|
+
$stdout.sync = true
|
13
|
+
begin
|
14
|
+
require 'yaml'
|
15
|
+
require 'easy-serve'
|
16
|
+
|
17
|
+
class EasyServe
|
18
|
+
def binding_for_remote_eval conns, host, log
|
19
|
+
binding
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
server_names = #{server_names.inspect}
|
24
|
+
servers = YAML.load(#{YAML.dump(servers).inspect})
|
25
|
+
log_level = #{log.level}
|
26
|
+
eval_string = #{opts[:eval].inspect}
|
27
|
+
host = #{host.inspect}
|
28
|
+
|
29
|
+
EasyServe.start servers: servers do |ez|
|
30
|
+
log = ez.log
|
31
|
+
log.level = log_level
|
32
|
+
log.formatter = nil if $VERBOSE
|
33
|
+
|
34
|
+
ez.local *server_names do |*conns|
|
35
|
+
begin
|
36
|
+
eval eval_string, ez.binding_for_remote_eval(conns, host, log)
|
37
|
+
rescue => ex
|
38
|
+
puts "ez error", ex, ex.backtrace
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
rescue => ex
|
43
|
+
puts "ez error", ex, ex.backtrace
|
44
|
+
end
|
45
|
+
}
|
46
|
+
|
47
|
+
ssh.close_write
|
48
|
+
result = ssh.gets
|
49
|
+
|
50
|
+
if result
|
51
|
+
error = result[/ez error/]
|
52
|
+
if error
|
53
|
+
raise RemoteError, "error raised in remote: #{ssh.read}"
|
54
|
+
else
|
55
|
+
puts result
|
56
|
+
while s = ssh.gets
|
57
|
+
puts s
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
class EasyServe
|
2
|
+
# useful in production, though it requires remote lib files to be set up
|
3
|
+
def remote_run *server_names, host: nil, **opts
|
4
|
+
## passive option?
|
5
|
+
## remote logfile option?
|
6
|
+
|
7
|
+
log.progname = "remote_run #{host}"
|
8
|
+
|
9
|
+
IO.popen ["ssh", host, "ruby"], "w+" do |ssh|
|
10
|
+
ssh.puts %Q{
|
11
|
+
$stdout.sync = true
|
12
|
+
begin
|
13
|
+
require 'yaml'
|
14
|
+
require 'easy-serve'
|
15
|
+
|
16
|
+
server_names = #{server_names.inspect}
|
17
|
+
servers = YAML.load(#{YAML.dump(servers).inspect})
|
18
|
+
log_level = #{log.level}
|
19
|
+
host = #{host.inspect}
|
20
|
+
args = YAML.load(#{YAML.dump(opts[:args]).inspect})
|
21
|
+
|
22
|
+
#{opts[:dir] && "Dir.chdir #{opts[:dir].inspect}"}
|
23
|
+
load #{opts[:file].inspect}
|
24
|
+
|
25
|
+
EasyServe.start servers: servers do |ez|
|
26
|
+
log = ez.log
|
27
|
+
log.level = log_level
|
28
|
+
log.formatter = nil if $VERBOSE
|
29
|
+
|
30
|
+
ez.local *server_names do |*conns|
|
31
|
+
begin
|
32
|
+
cl = Object.const_get(#{opts[:class_name].inspect})
|
33
|
+
ro = cl.new(conns, host, log, *args)
|
34
|
+
ro.run
|
35
|
+
rescue => ex
|
36
|
+
puts "ez error", ex, ex.backtrace
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
rescue => ex
|
41
|
+
puts "ez error", ex, ex.backtrace
|
42
|
+
end
|
43
|
+
}
|
44
|
+
|
45
|
+
ssh.close_write
|
46
|
+
result = ssh.gets
|
47
|
+
|
48
|
+
if result
|
49
|
+
error = result[/ez error/]
|
50
|
+
if error
|
51
|
+
raise RemoteError, "error raised in remote: #{ssh.read}"
|
52
|
+
else
|
53
|
+
puts result
|
54
|
+
while s = ssh.gets
|
55
|
+
puts s
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'easy-serve'
|
2
|
+
|
3
|
+
class EasyServe
|
4
|
+
class RemoteError < RuntimeError; end
|
5
|
+
|
6
|
+
def remote *server_names, host: nil, **opts
|
7
|
+
raise ArgumentError, "no host specified" unless host
|
8
|
+
|
9
|
+
if opts[:eval]
|
10
|
+
require 'easy-serve/remote-eval'
|
11
|
+
remote_eval *server_names, host: host, **opts
|
12
|
+
|
13
|
+
elsif opts[:file]
|
14
|
+
require 'easy-serve/remote-run'
|
15
|
+
remote_run *server_names, host: host, **opts
|
16
|
+
|
17
|
+
elsif block_given?
|
18
|
+
require 'easy-serve/remote-drb'
|
19
|
+
remote_drb *server_names, host: host, **opts, &Proc.new
|
20
|
+
|
21
|
+
else
|
22
|
+
raise ArgumentError, "cannot select remote mode based on arguments"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: easy-serve
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: '0.
|
4
|
+
version: '0.4'
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Joel VanderWerf
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2013-
|
11
|
+
date: 2013-08-26 00:00:00.000000000 Z
|
12
12
|
dependencies: []
|
13
13
|
description: Framework for starting tcp/unix servers and connected clients under one
|
14
14
|
parent process.
|
@@ -22,9 +22,17 @@ files:
|
|
22
22
|
- README.md
|
23
23
|
- COPYING
|
24
24
|
- lib/easy-serve.rb
|
25
|
+
- lib/easy-serve/remote-run.rb
|
26
|
+
- lib/easy-serve/remote-drb.rb
|
27
|
+
- lib/easy-serve/remote.rb
|
28
|
+
- lib/easy-serve/remote-eval.rb
|
25
29
|
- examples/simple.rb
|
30
|
+
- examples/remote-manual.rb
|
26
31
|
- examples/multi.rb
|
27
|
-
- examples/remote.rb
|
32
|
+
- examples/remote-run.rb
|
33
|
+
- examples/remote-drb.rb
|
34
|
+
- examples/remote-run-script.rb
|
35
|
+
- examples/remote-eval.rb
|
28
36
|
- examples/passive.rb
|
29
37
|
homepage: https://github.com/vjoel/easy-serve
|
30
38
|
licenses:
|