legs 0.6.2
Sign up to get free protection for your applications and to get access to all the features.
- data/README.rdoc +62 -0
- data/examples/chat-server.rb +111 -0
- data/examples/echo-server.rb +24 -0
- data/examples/shoes-chat-client.rb +159 -0
- data/legs.gemspec +13 -0
- data/lib/legs.rb +404 -0
- data/test/test_legs.rb +206 -0
- metadata +86 -0
data/README.rdoc
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
= About Legs
|
2
|
+
|
3
|
+
This is Legs, the little networking doodad somewhat inspired by Camping but not all that much really. It uses a lot of ruby trickery to give you one class, Legs, which can act as a server, a client, or a peer depending on how you use it. Oh, and the protocol it uses is {JSON-RPC 1.0}[http://json-rpc.org/wiki/specification], so quite platform and language agnostic. :)
|
4
|
+
|
5
|
+
If you give Legs some methods, then anyone you connect to will be able to make calls back to those methods to notify you of things. Otherwise, your instance of legs will only be able to connect out to a legs server and call methods in that server, but not the other way around. If that's all you need, learning Camping[http://github.com/why/camping/tree/master] and using http might be a better way to go, as http is a more popular and more standard protocol. :)
|
6
|
+
|
7
|
+
== To connect to a Legs server running on your own computer
|
8
|
+
|
9
|
+
server = Legs.new
|
10
|
+
- OR -
|
11
|
+
server = Legs.new('localhost')
|
12
|
+
|
13
|
+
== To connect to a remote server
|
14
|
+
|
15
|
+
server = Legs.new('web.address.to.server')
|
16
|
+
|
17
|
+
== To connect to a server on port 1234
|
18
|
+
|
19
|
+
server = Legs.new('web.address', 1234)
|
20
|
+
|
21
|
+
== To create a legs server
|
22
|
+
|
23
|
+
Legs.start do
|
24
|
+
def callable_method(a, b, c = 'default')
|
25
|
+
return "You called with params #{a}, #{b}, and #{c}"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
sleep # Stops ruby from closing as soon as Legs starts.
|
30
|
+
# don't put sleep if you're embedding the server in a shoes UI or something.
|
31
|
+
|
32
|
+
== To add methods to legs, without creating a server
|
33
|
+
|
34
|
+
Legs.start(false) do
|
35
|
+
def callable_method(a, b, c = 'default')
|
36
|
+
return "You called with params #{a}, #{b}, and #{c}"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
You should do this before creating instances of Legs with Legs.new. Adding methods like this is useful, because the "TCP" network connections Legs makes work in both directions, so if you have some methods, server's you connect to will be able to call your methods to notify you of events and stuff. If you don't give it +false+ on that first line, it will start up a server as well, so other instances of Legs can connect in to you, deliciously peery. It's important to know, though, that the <tt>Legs.users</tt> only includes connections who connected to you, not outgoing connections you make with Legs.new syntax. You could make your own array of outgoing connections to keep track ot them if you want. :)
|
41
|
+
|
42
|
+
Inside of your Legs.start block, there are two magic methods you can use in your code, these are +server+ and +caller+. +server+ references the Legs server object, which has methods like +users+ and some other stuff you can use to your advantage. The +caller+ method gives you the Legs instance representing that network connection (doesn't matter if they connected to you or vice versa).
|
43
|
+
|
44
|
+
Legs instances, like the one you get from +caller+, and the array of them you get from <tt>server.users</tt> have a useful method called +meta+ which gives you a hash you can store whatever you like inside of. Things you might put in there include the user's name, or if they have any special powers, so you can control which methods they can use and what they can do. Here's an example: :)
|
45
|
+
|
46
|
+
Legs.start do
|
47
|
+
def count
|
48
|
+
caller.meta[:counter] ||= 0
|
49
|
+
caller.meta[:counter] += 1
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
sleep
|
54
|
+
|
55
|
+
When you connect to this server, you can call the +count+ method. Each time you call it, it will give you a number, starting with 1 and growing higher each time. Because it stores the 'counter' in the caller's meta hash, if another person connects, they will start out with the number 1 as well. You could connect to this with :)
|
56
|
+
|
57
|
+
server = Legs.new('localhost')
|
58
|
+
puts "#{server.count}, #{server.count}, #{server.count}"
|
59
|
+
|
60
|
+
And if you run that script, you should see in your terminal: "1, 2, 3". That's pretty much how Legs works. An instance of legs has a few of it's own special methods: <tt>close!</tt>, <tt>notify!</tt>, <tt>send!</tt>, <tt>send_async!</tt>, <tt>connected?</tt>, +socket+, +parent+, +meta+, and <tt>send_data!</tt>. If you need to actually run a method on your server with one of these names, do: <tt>server.send!(:connected?, a_param, another_param)</tt> or whatever, and it'll run that method on the server for you. If you want to let the server know something, but don't care about the method's response, or any errors, you can do the same thing with <tt>legs.notify!</tt>, which will make your program run faster, especially if it's running through the web. :)
|
61
|
+
|
62
|
+
Finally, if you're making a program for running over the internet, and want to make your app more responsive, you can call methods asyncronously. What this means is that the method won't return immidiately, but instead will send the request out to your network, and your program will continue to run, and then when the server responds, a block you provide will be run. The block is passed an object, call that object's +result+ or +value+ method to get the server response, or it will raise an error if the server had an error, so you can use it as though it were a syncronous response. Error is only raised the first time you call it. To do this, just give the thing a block like this: <tt>server.some_method { |result| ... do stuff with result.value ... }</tt>.
|
@@ -0,0 +1,111 @@
|
|
1
|
+
require '../lib/legs'
|
2
|
+
|
3
|
+
# this is a work in progress, api's will change and break, one day there will be a functional matching
|
4
|
+
# client in shoes or something
|
5
|
+
|
6
|
+
Legs.start do
|
7
|
+
def initialize
|
8
|
+
@rooms = Hash.new { {'users' => [], 'messages' => [], 'topic' => 'No topic set'} }
|
9
|
+
@rooms['Lobby'] = {'topic' => 'General Chit Chat', 'messages' => [], 'users' => []}
|
10
|
+
end
|
11
|
+
|
12
|
+
# returns a list of available rooms
|
13
|
+
def rooms
|
14
|
+
room_list = Hash.new
|
15
|
+
@rooms.keys.each { |rn| room_list[rn] = room_object rn, :remote, :topic, :users, :messages }
|
16
|
+
room_list
|
17
|
+
end
|
18
|
+
|
19
|
+
# joins/creates a room
|
20
|
+
def join(room_name)
|
21
|
+
unless @rooms.keys.include?(room_name)
|
22
|
+
@rooms[room_name.to_s] = @rooms[room_name]
|
23
|
+
broadcast :room_created, room_name
|
24
|
+
end
|
25
|
+
|
26
|
+
# room = room_object(room_name)
|
27
|
+
#
|
28
|
+
# unless room['users'].include?(caller)
|
29
|
+
# broadcast room['users'], 'user_joined', room_name, user_object(caller)
|
30
|
+
# room['users'].push(caller)
|
31
|
+
# end
|
32
|
+
|
33
|
+
room_object room_name, :remote
|
34
|
+
end
|
35
|
+
|
36
|
+
# leaves a room
|
37
|
+
def leave(room_name)
|
38
|
+
room = @rooms[room_name.to_s]
|
39
|
+
room['users'].delete(caller)
|
40
|
+
broadcast room['users'], 'user_left', room_name, user_object(caller)
|
41
|
+
true
|
42
|
+
end
|
43
|
+
|
44
|
+
# sets the room topic message
|
45
|
+
def topic=(room, message)
|
46
|
+
room = @rooms[room.to_s]
|
47
|
+
room['topic'] = message.to_s
|
48
|
+
broadcast room['users'], 'room_changed', room_object(room, :remote, :name, :topic)
|
49
|
+
end
|
50
|
+
|
51
|
+
# sets the user's name
|
52
|
+
def name=(name)
|
53
|
+
caller.meta[:name] = name.to_s
|
54
|
+
user_rooms(caller).each do |room_name|
|
55
|
+
broadcast @rooms[room_name]['users'], 'user_changed', user_object(caller)
|
56
|
+
end
|
57
|
+
true
|
58
|
+
end
|
59
|
+
|
60
|
+
# returns information about ones self, clients thusly can find out their user 'id' number
|
61
|
+
def user(object_id = nil)
|
62
|
+
user = user_object( object_id.nil? ? caller : find_user_by_object_id(object_id) }.first )
|
63
|
+
user['rooms'] = user_rooms(user)
|
64
|
+
return user
|
65
|
+
end
|
66
|
+
|
67
|
+
# posts a message to a room
|
68
|
+
def post_message(room_name, message)
|
69
|
+
room = room_object(room_name)
|
70
|
+
room['messages'].push(msg = {'user' => user_object(caller), 'time' => Time.now.to_i, 'message' => message.to_s} )
|
71
|
+
trim_messages room
|
72
|
+
broadcast room['users'], 'message', room_name.to_s, msg
|
73
|
+
return msg
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
# trims the message backlog
|
79
|
+
def trim_messages room
|
80
|
+
room = room_object(room) if room.is_a?(String)
|
81
|
+
while room['messages'].length > 250
|
82
|
+
room['messages'].shift
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# makes a user object suitable for sending back with meta info and stuff
|
87
|
+
def user_object user
|
88
|
+
object = {'id' => user.object_id}
|
89
|
+
user.meta.each_pair do |key, value|
|
90
|
+
object[key.to_s] = value
|
91
|
+
end
|
92
|
+
return object
|
93
|
+
end
|
94
|
+
|
95
|
+
def room_object room_name, target = :local, *only
|
96
|
+
object = @rooms[room_name.to_s].dup
|
97
|
+
object['users'].delete_if {|user| user.connected? == false }
|
98
|
+
object['users'] = object['users'].map { |user| user_object(user) } if target == :remote
|
99
|
+
object['topic'] = 'No topic set' if object['topic'].nil? or object['topic'].empty?
|
100
|
+
object['name'] = room_name.to_s if target == :remote
|
101
|
+
object.delete_if { |key, value| only.include?(key.to_sym) == false } unless only.empty?
|
102
|
+
object
|
103
|
+
end
|
104
|
+
|
105
|
+
# returns all the room names the user is in.
|
106
|
+
def user_rooms user
|
107
|
+
@rooms.values.select { |room| room['users'].include?(user) }.map { |room| @rooms.index(room) }
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
sleep
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require '../lib/legs'
|
2
|
+
|
3
|
+
# This is how simple a Legs server can look.
|
4
|
+
|
5
|
+
Legs.start do
|
6
|
+
def echo(text)
|
7
|
+
return text
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
sleep
|
12
|
+
|
13
|
+
# To test this server, first, run it in ruby, and then open another terminal, telnet localhost 30274
|
14
|
+
# Then enter:
|
15
|
+
# {"method":"echo","params":["Hello World"],"id":1}
|
16
|
+
# result should be:
|
17
|
+
# {"result":"Hello World","id":1}
|
18
|
+
# however the properties may appear in a different order, this makes no difference.
|
19
|
+
|
20
|
+
# Test to ensure there are no security flaws...
|
21
|
+
# Try {"method":"object_id","params":[],"id":1}
|
22
|
+
# Should return: {"error":"Cannot run 'object_id' because it is not defined in this server","id":1}
|
23
|
+
# And try: {"method":"caller","params":[],"id":1}
|
24
|
+
# Should recieve a similar error response. :)
|
@@ -0,0 +1,159 @@
|
|
1
|
+
# Shoes Chat Client is a generic irc-like chat system, made in Legs and Shoes, as a way to find
|
2
|
+
# any troublesome parts in Legs and learn stuff about Shoes. :)
|
3
|
+
|
4
|
+
#HOSTNAME = 'localhost'
|
5
|
+
HOSTNAME = 'bluebie.creativepony.com'
|
6
|
+
|
7
|
+
|
8
|
+
Shoes.setup do
|
9
|
+
gem 'json_pure >= 1.1.1'
|
10
|
+
end
|
11
|
+
|
12
|
+
require 'json/pure.rb'
|
13
|
+
require '../lib/legs'
|
14
|
+
|
15
|
+
# This add's some methods the server can call on to notify us of events like a new message
|
16
|
+
Legs.start false do
|
17
|
+
attr_accessor :app, :rooms
|
18
|
+
|
19
|
+
def user_left room_name, user
|
20
|
+
@rooms[room_name]['users'].delete_if { |u| u['id'] == user['id'] }
|
21
|
+
end
|
22
|
+
|
23
|
+
def user_joined room_name, user
|
24
|
+
@rooms[room_name]['users'].push user unless @rooms[room_name]['users'].include?(user)
|
25
|
+
end
|
26
|
+
|
27
|
+
def room_created room
|
28
|
+
@app.add_room room
|
29
|
+
end
|
30
|
+
|
31
|
+
def room_changed room
|
32
|
+
@app.update_room room.delete('name'), room
|
33
|
+
end
|
34
|
+
|
35
|
+
def user_changed user
|
36
|
+
@rooms.each do |room|
|
37
|
+
room['users'].map! do |room_user|
|
38
|
+
if user['id'] == room_user['id']
|
39
|
+
user
|
40
|
+
else
|
41
|
+
room_user
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
#@app.update_user_list
|
46
|
+
end
|
47
|
+
|
48
|
+
def room_message room, message
|
49
|
+
@app.add_message message
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
|
54
|
+
class LegsChat < Shoes
|
55
|
+
url '/', :index
|
56
|
+
url '/room/(\w+)', :room
|
57
|
+
|
58
|
+
Chat = Legs.new(HOSTNAME)
|
59
|
+
|
60
|
+
def index
|
61
|
+
#Legs.server_object.app = self
|
62
|
+
#name = ask("What's your name?")
|
63
|
+
#Chat.notify! :set_name, name
|
64
|
+
|
65
|
+
@@room_data = Chat.rooms
|
66
|
+
@@joined_rooms = []
|
67
|
+
@@available_rooms = @@room_data.keys
|
68
|
+
visit('/room/Lobby')
|
69
|
+
end
|
70
|
+
|
71
|
+
def room name
|
72
|
+
unless @@joined_rooms.include? name
|
73
|
+
@@room_data[name] = Chat.join(name)
|
74
|
+
@@joined_rooms.push name
|
75
|
+
end
|
76
|
+
|
77
|
+
@room = name
|
78
|
+
timer(0.5) { layout } # don't know why I need this, but layout goes all weird without delay
|
79
|
+
end
|
80
|
+
|
81
|
+
def layout
|
82
|
+
clear do
|
83
|
+
background white
|
84
|
+
|
85
|
+
@rooms_list = stack :width => 180, :height => 1.0
|
86
|
+
stack do
|
87
|
+
@log = stack :width => -360, :height => -30, :scroll => true, :margin_right => gutter
|
88
|
+
|
89
|
+
flow :margin_right => gutter, :height => 30 do
|
90
|
+
@msg_input = edit_line(:width => -100)
|
91
|
+
|
92
|
+
submit = Proc.new do
|
93
|
+
add_message @room, Chat.post_message(@room, @msg_input.text)
|
94
|
+
@msg_input.text = ''
|
95
|
+
end
|
96
|
+
|
97
|
+
button("Send", :width => 100, &submit)
|
98
|
+
end
|
99
|
+
|
100
|
+
end
|
101
|
+
@users_list = stack :width => 180, :height => 1.0
|
102
|
+
end
|
103
|
+
|
104
|
+
@@available_rooms.each { |r| add_room r }
|
105
|
+
@@room_data[@room]['messages'].each { |m| add_message @room, m }
|
106
|
+
end
|
107
|
+
|
108
|
+
|
109
|
+
# adds a message to the display
|
110
|
+
def add_message room_name, message
|
111
|
+
unless @@room_data[room_name]['messages'].include? message
|
112
|
+
messages = @@room_data[room_name]['messages']
|
113
|
+
messages.push message
|
114
|
+
@@room_data[room_name]['messages'] = messages[-500,500] if messages.length > 500
|
115
|
+
end
|
116
|
+
|
117
|
+
return if room_name != @room
|
118
|
+
scroll_down = @log.scroll_top >= @log.scroll_max - 10
|
119
|
+
@log.append do
|
120
|
+
flow do
|
121
|
+
para strong(message['user']['name'] || message ['user']['id']), ': ', message['message']
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
while @log.contents.length > 500
|
126
|
+
@log.contents.first.remove
|
127
|
+
end
|
128
|
+
|
129
|
+
@log.scroll_top = @log.scroll_max
|
130
|
+
end
|
131
|
+
|
132
|
+
|
133
|
+
# adds a room to the sidebar
|
134
|
+
def add_room room_name
|
135
|
+
@@available_rooms.push room_name unless @@available_rooms.include? room_name
|
136
|
+
@rooms_list.append do
|
137
|
+
flow :margin => 5 do
|
138
|
+
# _why assures me the :checked style will work in the next release
|
139
|
+
joined = check :checked => @@joined_rooms.include?(room_name) do |chk|
|
140
|
+
@@joined_rooms.push(room_name) and @@room_data[room_name].merge! Chat.join(room_name) if chk.checked?
|
141
|
+
Chat.leave(room_name) and @@joined_rooms.delete(room_name) unless chk.checked?
|
142
|
+
end
|
143
|
+
|
144
|
+
para room_name, :underline => (@room == room_name ? :one : false)
|
145
|
+
|
146
|
+
click do
|
147
|
+
visit("/room/#{room_name}")
|
148
|
+
end unless @room == room_name
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def update_room room_name, data
|
154
|
+
@@room_data[room_name].merge! data
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
Shoes.app(:title => "Legs Chat", :width => 700, :height => 350)
|
159
|
+
|
data/legs.gemspec
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = "legs"
|
3
|
+
s.version = "0.6.2"
|
4
|
+
s.date = "2008-07-17"
|
5
|
+
s.summary = "Simple fun open networking for newbies and quick hacks"
|
6
|
+
s.email = "a@creativepony.com"
|
7
|
+
s.homepage = "http://github.com/Bluebie/legs"
|
8
|
+
s.description = "Legs is a really simple fun networking library that uses 'json-rpc' formated messages over a tcp connection to really easily built peery or server-clienty sorts of apps, for ruby newbies and hackers to build fun little things."
|
9
|
+
s.has_rdoc = false
|
10
|
+
s.authors = ["Jenna Fox"]
|
11
|
+
s.files = ["README.rdoc", "legs.gemspec", "lib/legs.rb", "examples/echo-server.rb", "examples/chat-server.rb", "examples/shoes-chat-client.rb", "test/test_legs.rb"]
|
12
|
+
s.add_dependency("json_pure", ["> 1.1.0"])
|
13
|
+
end
|
data/lib/legs.rb
ADDED
@@ -0,0 +1,404 @@
|
|
1
|
+
# Legs take you places, a networking companion
|
2
|
+
['rubygems', 'socket', 'thread'].each { |i| require i }
|
3
|
+
require 'json' unless self.class.const_defined? 'JSON'
|
4
|
+
|
5
|
+
class Legs
|
6
|
+
# general getters
|
7
|
+
attr_reader :socket, :parent, :meta
|
8
|
+
def inspect; "<Legs:#{object_id} Meta: #{@meta.inspect}>"; end
|
9
|
+
|
10
|
+
# Legs.new for a client, subclass to make a server, .new then makes server and client!
|
11
|
+
def initialize(host = 'localhost', port = 30274)
|
12
|
+
self.class.start(port) if self.class != Legs && !self.class.started?
|
13
|
+
ObjectSpace.define_finalizer(self) { self.close! }
|
14
|
+
@parent = false; @responses = Hash.new; @meta = {}; @disconnected = false
|
15
|
+
@responses_mutex = Mutex.new; @socket_mutex = Mutex.new
|
16
|
+
|
17
|
+
if host.instance_of?(TCPSocket)
|
18
|
+
@socket = host
|
19
|
+
@parent = port unless port.instance_of?(Numeric)
|
20
|
+
elsif host.instance_of?(String)
|
21
|
+
@socket = TCPSocket.new(host, port)
|
22
|
+
self.class.outgoing_mutex.synchronize { self.class.outgoing.push self }
|
23
|
+
else
|
24
|
+
raise "First argument needs to be a hostname, ip, or socket"
|
25
|
+
end
|
26
|
+
|
27
|
+
|
28
|
+
@handle_data = Proc.new do |data|
|
29
|
+
data = json_restore(JSON.parse(data))
|
30
|
+
|
31
|
+
if data['method']
|
32
|
+
(@parent || self.class).__data!(data, self)
|
33
|
+
elsif data['error'] and data['id'].nil?
|
34
|
+
raise data['error']
|
35
|
+
else
|
36
|
+
@responses_mutex.synchronize { @responses[data['id']] = data }
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
@thread = Thread.new do
|
41
|
+
until @socket.closed?
|
42
|
+
begin
|
43
|
+
close! if @socket.eof?
|
44
|
+
data = nil
|
45
|
+
@socket_mutex.synchronize { data = @socket.gets(self.class.terminator) rescue nil }
|
46
|
+
if data.nil?
|
47
|
+
close!
|
48
|
+
else
|
49
|
+
@handle_data[data]
|
50
|
+
end
|
51
|
+
rescue JSON::ParserError => e
|
52
|
+
send_data!({"error" => "JSON provided is invalid. See http://json.org/ to see how to format correctly."})
|
53
|
+
rescue IOError => e
|
54
|
+
close!
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# I think you can guess this one
|
61
|
+
def connected?; self.class.connections.include?(self); end
|
62
|
+
|
63
|
+
# closes the connection and the threads and stuff for this user
|
64
|
+
def close!
|
65
|
+
return if @disconnected == true
|
66
|
+
|
67
|
+
@disconnected = true
|
68
|
+
puts "User #{inspect} disconnecting" if self.class.log?
|
69
|
+
|
70
|
+
# notify the remote side
|
71
|
+
notify!('**remote__disconnecting**') rescue nil
|
72
|
+
|
73
|
+
if @parent
|
74
|
+
@parent.event(:disconnect, self)
|
75
|
+
@parent.incoming_mutex.synchronize { @parent.incoming.delete(self) }
|
76
|
+
else
|
77
|
+
self.class.outgoing_mutex.synchronize { self.class.outgoing.delete(self) }
|
78
|
+
end
|
79
|
+
|
80
|
+
Thread.new { sleep(1); @socket.close rescue nil }
|
81
|
+
end
|
82
|
+
|
83
|
+
# send a notification to this user
|
84
|
+
def notify!(method, *args, &blk)
|
85
|
+
puts "Notify #{inspect}: #{method}(#{args.map(&:inspect).join(', ')})" if self.class.log?
|
86
|
+
send_data!({'method' => method.to_s, 'params' => args, 'id' => nil})
|
87
|
+
end
|
88
|
+
|
89
|
+
# sends a normal RPC request that has a response
|
90
|
+
def send!(method, *args, &blk)
|
91
|
+
puts "Call #{inspect}: #{method}(#{args.map(&:inspect).join(', ')})" if self.class.log?
|
92
|
+
id = get_unique_number
|
93
|
+
send_data! 'method' => method.to_s, 'params' => args, 'id' => id
|
94
|
+
|
95
|
+
worker = Proc.new do
|
96
|
+
sleep 0.1 until @responses_mutex.synchronize { @responses.keys.include?(id) }
|
97
|
+
|
98
|
+
result = Legs::Result.new(@responses_mutex.synchronize { @responses.delete(id) })
|
99
|
+
puts ">> #{method} #=> #{result.data['result'].inspect}" if self.class.log?
|
100
|
+
result
|
101
|
+
end
|
102
|
+
|
103
|
+
if blk.respond_to?(:call); Thread.new { blk[worker.call] }
|
104
|
+
else; worker.call.value; end
|
105
|
+
end
|
106
|
+
|
107
|
+
# catch all the rogue calls and make them work niftily
|
108
|
+
alias_method :method_missing, :send!
|
109
|
+
|
110
|
+
# sends raw object over the socket
|
111
|
+
def send_data!(data)
|
112
|
+
raise "Lost remote connection" unless connected?
|
113
|
+
raw = JSON.generate(json_marshal(data)) + self.class.terminator
|
114
|
+
@socket_mutex.synchronize { @socket.write(raw) }
|
115
|
+
end
|
116
|
+
|
117
|
+
|
118
|
+
private
|
119
|
+
|
120
|
+
# takes a ruby object, and converts it if needed in to marshalled hashes
|
121
|
+
def json_marshal(object)
|
122
|
+
case object
|
123
|
+
when Bignum, Fixnum, Integer, Float, TrueClass, FalseClass, String, NilClass
|
124
|
+
return object
|
125
|
+
when Hash
|
126
|
+
out = Hash.new
|
127
|
+
object.each_pair { |k,v| out[k.to_s] = json_marshal(v) }
|
128
|
+
return out
|
129
|
+
when Array
|
130
|
+
return object.map { |v| json_marshal(v) }
|
131
|
+
when Symbol
|
132
|
+
return {'__jsonclass__' => ['Legs', '__make_symbol', object.to_s]}
|
133
|
+
when Exception
|
134
|
+
return {'__jsonclass__' => ['Legs::RemoteError', 'new', "<#{object.class.name}> #{object.message}", object.backtrace]}
|
135
|
+
else
|
136
|
+
return {'__jsonclass__' => [object.class.name, '_load', object._dump]} if object.respond_to?(:_dump)
|
137
|
+
|
138
|
+
# the default marshalling behaviour
|
139
|
+
instance_vars = {}
|
140
|
+
object.instance_variables.each do |var_name|
|
141
|
+
instance_vars[var_name.to_s.sub(/@/, '')] = json_marshal(object.instance_variable_get(var_name))
|
142
|
+
end
|
143
|
+
|
144
|
+
return {'__jsonclass__' => [object.class.name, 'new']}.merge(instance_vars)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
SAFE_CONSTRUCTORS = ['new', 'allocate', '_load']
|
149
|
+
|
150
|
+
# takes an object from the network, and decodes any marshalled hashes back in to ruby objects
|
151
|
+
def json_restore(object)
|
152
|
+
case object
|
153
|
+
when Hash
|
154
|
+
if object.keys.include? '__jsonclass__'
|
155
|
+
constructor = object.delete('__jsonclass__')
|
156
|
+
class_name = constructor.shift.to_s
|
157
|
+
|
158
|
+
# find the constant through the heirachy
|
159
|
+
object_class = Module
|
160
|
+
class_name.split(/::/).each { |piece_of_const| object_class = object_class.const_get(piece_of_const) } rescue false
|
161
|
+
|
162
|
+
if object_class
|
163
|
+
unless constructor.empty?
|
164
|
+
raise "Unsafe marshaling constructor method: #{constructor.first}" unless (object_class == Legs and constructor.first =~ /^__make_/) or SAFE_CONSTRUCTORS.include?(constructor.first)
|
165
|
+
raise "#{class_name} doesn't support the #{constructor.first} constructor" unless object_class.respond_to?(constructor.first)
|
166
|
+
instance = object_class.__send__(*constructor)
|
167
|
+
else
|
168
|
+
instance = object_class.allocate
|
169
|
+
end
|
170
|
+
|
171
|
+
object.each_pair do |key, value|
|
172
|
+
instance.instance_variable_set("@#{key}", json_restore(value))
|
173
|
+
end
|
174
|
+
return instance
|
175
|
+
else
|
176
|
+
raise "Response contains a #{class_name} but that class is not loaded locally, it needs to be."
|
177
|
+
end
|
178
|
+
else
|
179
|
+
hash = Hash.new
|
180
|
+
object.each_pair { |k,v| hash[k] = json_restore(v) }
|
181
|
+
return hash
|
182
|
+
end
|
183
|
+
when Array
|
184
|
+
return object.map { |i| json_restore(i) }
|
185
|
+
else
|
186
|
+
return object
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
# gets a unique number that we can use to match requests to responses
|
191
|
+
def get_unique_number; @unique_id ||= 0; @unique_id += 1; end
|
192
|
+
end
|
193
|
+
|
194
|
+
# undef's the superclass's methods so they won't get in the way
|
195
|
+
removal_list = Legs.instance_methods(true)
|
196
|
+
removal_list -= %w{JSON new class object_id send __send__ __id__ < <= <=> => > == === yield raise}
|
197
|
+
removal_list -= Legs.instance_methods(false)
|
198
|
+
Legs.class_eval { removal_list.each { |m| undef_method m } }
|
199
|
+
|
200
|
+
|
201
|
+
# the server is started by subclassing Legs, then SubclassName.start
|
202
|
+
class << Legs
|
203
|
+
attr_accessor :terminator, :log
|
204
|
+
attr_reader :incoming, :outgoing, :server_object, :incoming_mutex, :outgoing_mutex, :messages_mutex
|
205
|
+
alias_method :log?, :log
|
206
|
+
alias_method :users, :incoming
|
207
|
+
def started?; @started; end
|
208
|
+
|
209
|
+
def initializer
|
210
|
+
ObjectSpace.define_finalizer(self) { self.stop! }
|
211
|
+
@incoming = []; @outgoing = []; @messages = Queue.new; @terminator = "\n"; @log = false
|
212
|
+
@incoming_mutex = Mutex.new; @outgoing_mutex = Mutex.new; @started = false
|
213
|
+
end
|
214
|
+
|
215
|
+
|
216
|
+
# starts the server, pass nil for port to make a 'server' that doesn't actually accept connections
|
217
|
+
# This is useful for adding methods to Legs so that systems you connect to can call methods back on you
|
218
|
+
def start(port=30274, &blk)
|
219
|
+
return @server_class.module_eval(&blk) if started? and blk.respond_to? :call
|
220
|
+
@started = true
|
221
|
+
|
222
|
+
# makes a nice clean class to hold all the server methods.
|
223
|
+
if @server_class.nil?
|
224
|
+
@server_class = Class.new
|
225
|
+
@server_class.module_eval do
|
226
|
+
private
|
227
|
+
attr_reader :server, :caller
|
228
|
+
|
229
|
+
# sends a notification message to all connected clients
|
230
|
+
def broadcast(*args)
|
231
|
+
if args.first.is_a?(Array)
|
232
|
+
list = args.shift
|
233
|
+
method = args.shift
|
234
|
+
elsif args.first.is_a?(String) or args.first.is_a?(Symbol)
|
235
|
+
list = server.incoming
|
236
|
+
method = args.shift
|
237
|
+
else
|
238
|
+
raise "You need to specify a 'method' to broadcast out to"
|
239
|
+
end
|
240
|
+
|
241
|
+
list.each { |user| user.notify!(method, *args) }
|
242
|
+
end
|
243
|
+
|
244
|
+
# Finds a user by the value of a certain property... like find_user_by :object_id, 12345
|
245
|
+
def find_user_by_object_id value
|
246
|
+
server.incoming.find { |user| user.object_id == value }
|
247
|
+
end
|
248
|
+
|
249
|
+
# finds user's with the specified meta keys matching the specified values, can use regexps and stuff, like a case block
|
250
|
+
def find_users_by_meta hash = nil
|
251
|
+
raise "You need to give find_users_by_meta a hash to check the user's meta hash against" if hash.nil?
|
252
|
+
server.incoming.select do |user|
|
253
|
+
hash.all? { |key, value| value === user.meta[key] }
|
254
|
+
end
|
255
|
+
end
|
256
|
+
|
257
|
+
public # makes it public again for the user code
|
258
|
+
end
|
259
|
+
end
|
260
|
+
|
261
|
+
@server_class.module_eval(&blk) if blk.respond_to?(:call)
|
262
|
+
|
263
|
+
if @server_object.nil?
|
264
|
+
@server_object = @server_class.allocate
|
265
|
+
@server_object.instance_variable_set(:@server, self)
|
266
|
+
@server_object.instance_eval { initialize }
|
267
|
+
end
|
268
|
+
|
269
|
+
@message_processor = Thread.new do
|
270
|
+
while started?
|
271
|
+
sleep 0.01 while @messages.empty?
|
272
|
+
data, from = @messages.deq
|
273
|
+
method = data['method']; params = data['params']
|
274
|
+
methods = @server_object.public_methods(false)
|
275
|
+
|
276
|
+
# close dead connections
|
277
|
+
if data['method'] == '**remote__disconnecting**'
|
278
|
+
from.close!
|
279
|
+
next
|
280
|
+
else
|
281
|
+
begin
|
282
|
+
raise "Supplied method is not a String" unless method.is_a?(String)
|
283
|
+
raise "Supplied params object is not an Array" unless params.is_a?(Array)
|
284
|
+
raise "Cannot run '#{method}' because it is not defined in this server" unless methods.include?(method.to_s) or methods.include? :method_missing
|
285
|
+
|
286
|
+
puts "Call #{method}(#{params.map(&:inspect).join(', ')})" if log?
|
287
|
+
|
288
|
+
@server_object.instance_variable_set(:@caller, from)
|
289
|
+
|
290
|
+
result = nil
|
291
|
+
|
292
|
+
@incoming_mutex.synchronize do
|
293
|
+
if methods.include?(method.to_s)
|
294
|
+
result = @server_object.__send__(method.to_s, *params)
|
295
|
+
else
|
296
|
+
result = @server_object.method_missing(method.to_s, *params)
|
297
|
+
end
|
298
|
+
end
|
299
|
+
|
300
|
+
puts ">> #{method} #=> #{result.inspect}" if log?
|
301
|
+
|
302
|
+
from.send_data!({'id' => data['id'], 'result' => result}) unless data['id'].nil?
|
303
|
+
|
304
|
+
rescue Exception => e
|
305
|
+
from.send_data!({'error' => e, 'id' => data['id']}) unless data['id'].nil?
|
306
|
+
puts "Error: #{e}\nBacktrace: " + e.backtrace.join("\n ") if log?
|
307
|
+
end
|
308
|
+
end
|
309
|
+
end
|
310
|
+
end unless @message_processor and @message_processor.alive?
|
311
|
+
|
312
|
+
if ( port.nil? or port == false ) == false and @listener.nil?
|
313
|
+
@listener = TCPServer.new(port)
|
314
|
+
|
315
|
+
@acceptor_thread = Thread.new do
|
316
|
+
while started?
|
317
|
+
user = Legs.new(@listener.accept, self)
|
318
|
+
@incoming_mutex.synchronize { @incoming.push user }
|
319
|
+
puts "User #{user.object_id} connected, number of users: #{@incoming.length}" if log?
|
320
|
+
self.event :connect, user
|
321
|
+
end
|
322
|
+
end
|
323
|
+
end
|
324
|
+
end
|
325
|
+
|
326
|
+
# stops the server, disconnects the clients
|
327
|
+
def stop
|
328
|
+
@started = false
|
329
|
+
@incoming.each { |user| user.close! }
|
330
|
+
end
|
331
|
+
|
332
|
+
# returns an array of all connections
|
333
|
+
def connections
|
334
|
+
@incoming + @outgoing
|
335
|
+
end
|
336
|
+
|
337
|
+
# add an event call to the server's message queue
|
338
|
+
def event(name, sender, *extras)
|
339
|
+
return unless @server_object.respond_to?("on_#{name}")
|
340
|
+
__data!({'method' => "on_#{name}", 'params' => extras.to_a, 'id' => nil}, sender)
|
341
|
+
end
|
342
|
+
|
343
|
+
# gets called to handle all incoming messages (RPC requests)
|
344
|
+
def __data!(data, from)
|
345
|
+
@messages.enq [data, from]
|
346
|
+
end
|
347
|
+
|
348
|
+
# People say this syntax is too funny not to have... whatever. Works like IO and File and what have you
|
349
|
+
def open(*args)
|
350
|
+
client = Legs.new(*args)
|
351
|
+
yield(client)
|
352
|
+
client.close!
|
353
|
+
end
|
354
|
+
|
355
|
+
# add's a method to the 'server' class, bound in to that class
|
356
|
+
def define_method(name, &blk); @server_class.class_eval { define_method(name, &blk) }; end
|
357
|
+
|
358
|
+
# add's a block to the 'server' class in a way that retains it's old bindings.
|
359
|
+
# the block will be passed the caller object, followed by the args.
|
360
|
+
def add_block(name, &block)
|
361
|
+
@server_class.class_eval do
|
362
|
+
define_method(name) do |*args|
|
363
|
+
block.call caller, *args
|
364
|
+
end
|
365
|
+
end
|
366
|
+
end
|
367
|
+
|
368
|
+
# lets the marshaler transport symbols
|
369
|
+
def __make_symbol(name); name.to_sym; end
|
370
|
+
|
371
|
+
# hooks up these methods so you can use them off the main object too!
|
372
|
+
[:broadcast, :find_user_by_object_id, :find_users_by_meta].each do |name|
|
373
|
+
define_method name do |*args|
|
374
|
+
@incoming_mutex.synchronize do; @outgoing_mutex.synchronize do
|
375
|
+
@server_object.__send__(name, *args)
|
376
|
+
end; end
|
377
|
+
end
|
378
|
+
end
|
379
|
+
end
|
380
|
+
|
381
|
+
Legs.initializer
|
382
|
+
|
383
|
+
# represents the data response, handles throwing of errors and stuff
|
384
|
+
class Legs::Result
|
385
|
+
attr_reader :data
|
386
|
+
def initialize(data); @data = data; end
|
387
|
+
def result
|
388
|
+
unless @data['error'].nil? or @errored
|
389
|
+
@errored = true
|
390
|
+
raise @data['error']
|
391
|
+
end
|
392
|
+
@data['result']
|
393
|
+
end
|
394
|
+
alias_method :value, :result
|
395
|
+
end
|
396
|
+
|
397
|
+
class Legs::StartBlockError < StandardError; end
|
398
|
+
class Legs::RequestError < StandardError; end
|
399
|
+
class Legs::RemoteError < StandardError
|
400
|
+
def initialize(msg, backtrace)
|
401
|
+
super(msg)
|
402
|
+
set_backtrace(backtrace)
|
403
|
+
end
|
404
|
+
end
|
data/test/test_legs.rb
ADDED
@@ -0,0 +1,206 @@
|
|
1
|
+
# Makes use of ZenTest. Install the 'ZenTest' gem, then run this ruby script in a terminal to see the results!
|
2
|
+
require 'test/unit' unless defined? $ZENTEST and $ZENTEST
|
3
|
+
require '../lib/legs'
|
4
|
+
|
5
|
+
# want to see errors, don't want to see excessively verbose logging normally
|
6
|
+
Thread.abort_on_exception = true
|
7
|
+
#Legs.log = true
|
8
|
+
|
9
|
+
# class to test the marshaling
|
10
|
+
class Marshal::TesterClass; attr_accessor :a, :b, :c; end
|
11
|
+
|
12
|
+
# a simple server to test with
|
13
|
+
Legs.start(6425) do
|
14
|
+
def echo(text)
|
15
|
+
return text
|
16
|
+
end
|
17
|
+
|
18
|
+
def count
|
19
|
+
caller.meta[:counter] ||= 0
|
20
|
+
caller.meta[:counter] += 1
|
21
|
+
end
|
22
|
+
|
23
|
+
def methods
|
24
|
+
'overridden'
|
25
|
+
end
|
26
|
+
|
27
|
+
def error
|
28
|
+
raise "This is a fake error"
|
29
|
+
end
|
30
|
+
|
31
|
+
def notified
|
32
|
+
$notified = true
|
33
|
+
end
|
34
|
+
|
35
|
+
def marshal
|
36
|
+
obj = Marshal::TesterClass.new
|
37
|
+
obj.a = 1; obj.b = 2; obj.c = 3
|
38
|
+
return obj
|
39
|
+
end
|
40
|
+
|
41
|
+
def on_connect
|
42
|
+
$server_instance = caller
|
43
|
+
end
|
44
|
+
|
45
|
+
def on_some_event
|
46
|
+
$some_event_ran = true
|
47
|
+
end
|
48
|
+
|
49
|
+
# tests that we can call stuff back over the caller's socket
|
50
|
+
def bidirectional; caller.notify!(:bidirectional_test_reciever); end
|
51
|
+
def bidirectional_test_reciever; $bidirectional_worked = true; end
|
52
|
+
end
|
53
|
+
|
54
|
+
Remote = Legs.new('localhost', 6425)
|
55
|
+
|
56
|
+
|
57
|
+
class TestLegsObject < Test::Unit::TestCase
|
58
|
+
def test_class_broadcast
|
59
|
+
$notified = false
|
60
|
+
Legs.broadcast(:notified)
|
61
|
+
sleep 0.5
|
62
|
+
assert_equal(true, $notified)
|
63
|
+
end
|
64
|
+
|
65
|
+
def test_class_connections
|
66
|
+
assert_equal(2, Legs.connections.length)
|
67
|
+
end
|
68
|
+
|
69
|
+
def test_class_event
|
70
|
+
Legs.event :some_event, nil
|
71
|
+
sleep 0.1
|
72
|
+
assert_equal(true, $some_event_ran)
|
73
|
+
end
|
74
|
+
|
75
|
+
def test_class_find_user_by_object_id
|
76
|
+
assert_equal(Legs.incoming.first, Legs.find_user_by_object_id(Legs.incoming.first.object_id))
|
77
|
+
end
|
78
|
+
|
79
|
+
def test_class_find_users_by_meta
|
80
|
+
Legs.incoming.first.meta[:id] = 'This is the incoming legs instance'
|
81
|
+
assert_equal(true, Legs.find_users_by_meta(:id => /incoming legs/).include?(Legs.incoming.first))
|
82
|
+
end
|
83
|
+
|
84
|
+
def test_class_incoming
|
85
|
+
assert_equal(true, Legs.incoming.length > 0)
|
86
|
+
end
|
87
|
+
|
88
|
+
def test_class_outgoing
|
89
|
+
assert_equal(true, Legs.outgoing.is_a?(Array))
|
90
|
+
assert_equal(1, Legs.outgoing.length)
|
91
|
+
end
|
92
|
+
|
93
|
+
def test_class_open
|
94
|
+
instance = nil
|
95
|
+
Legs.open('localhost', 6425) do |i|
|
96
|
+
instance = i
|
97
|
+
end
|
98
|
+
assert_equal(Legs, instance.class)
|
99
|
+
end
|
100
|
+
|
101
|
+
def test_class_started_eh
|
102
|
+
assert_equal(true, Legs.started?)
|
103
|
+
end
|
104
|
+
|
105
|
+
def test_connected_eh
|
106
|
+
assert_equal(true, Remote.connected?)
|
107
|
+
end
|
108
|
+
|
109
|
+
def test_close_bang
|
110
|
+
ii = Legs.new('localhost', 6425)
|
111
|
+
assert_equal(2, Legs.outgoing.length)
|
112
|
+
ii.close!
|
113
|
+
assert_equal(1, Legs.outgoing.length)
|
114
|
+
end
|
115
|
+
|
116
|
+
def test_meta
|
117
|
+
assert_equal(true, Remote.meta.is_a?(Hash))
|
118
|
+
Remote.meta[:yada] = 'Yada'
|
119
|
+
assert_equal('Yada', Remote.meta[:yada])
|
120
|
+
end
|
121
|
+
|
122
|
+
def test_notify_bang
|
123
|
+
$notified = false
|
124
|
+
Remote.notify! :notified
|
125
|
+
sleep(0.2)
|
126
|
+
assert_equal(true, $notified)
|
127
|
+
end
|
128
|
+
|
129
|
+
def test_parent
|
130
|
+
assert_equal(false, Remote.parent)
|
131
|
+
assert_equal(Legs, Legs.incoming.first.parent)
|
132
|
+
end
|
133
|
+
|
134
|
+
def test_send_bang
|
135
|
+
assert_equal(123, Remote.echo(123))
|
136
|
+
assert_equal(123, Remote.send!(:echo, 123))
|
137
|
+
|
138
|
+
# check async
|
139
|
+
abc = 0
|
140
|
+
Remote.echo(123) { |r| abc = r.value }
|
141
|
+
sleep 0.2
|
142
|
+
assert_equal(123, abc)
|
143
|
+
|
144
|
+
# check it catches ancestor method calls
|
145
|
+
assert_equal('overridden', Remote.methods)
|
146
|
+
end
|
147
|
+
|
148
|
+
def test_symbol_marshaling
|
149
|
+
assert_equal(Symbol, Remote.echo(:test).class)
|
150
|
+
end
|
151
|
+
|
152
|
+
def test_socket
|
153
|
+
assert_equal(true, Remote.socket.is_a?(BasicSocket))
|
154
|
+
end
|
155
|
+
|
156
|
+
def test_marshaling
|
157
|
+
object = Remote.marshal
|
158
|
+
assert_equal(1, object.a)
|
159
|
+
assert_equal(2, object.b)
|
160
|
+
assert_equal(3, object.c)
|
161
|
+
end
|
162
|
+
|
163
|
+
def test_bidirectional
|
164
|
+
$bidirectional_worked = false; Remote.bidirectional; sleep 0.2
|
165
|
+
assert_equal(true, $bidirectional_worked)
|
166
|
+
end
|
167
|
+
|
168
|
+
# makes sure the block adding thingos work
|
169
|
+
def test_adding_block
|
170
|
+
@bound_var = bound_var = 'Ladedadedah'
|
171
|
+
Legs.define_method(:defined_meth) { bound_var }
|
172
|
+
assert_equal(bound_var, Remote.defined_meth)
|
173
|
+
|
174
|
+
Legs.add_block(:unbound_meth) { @bound_var }
|
175
|
+
assert_equal(bound_var, Remote.unbound_meth)
|
176
|
+
end
|
177
|
+
|
178
|
+
# this is to make sure we can run the start method a ton of times without bad side effects
|
179
|
+
def test_start_again_and_again
|
180
|
+
Legs.start
|
181
|
+
Legs.start { def adding_another_method; true; end }
|
182
|
+
assert_equal(true, Remote.adding_another_method)
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
module TestLegs
|
187
|
+
class TestResult < Test::Unit::TestCase
|
188
|
+
def test_data
|
189
|
+
result = nil
|
190
|
+
Remote.echo(123) { |r| result = r }
|
191
|
+
sleep(0.2)
|
192
|
+
|
193
|
+
assert_equal(123, result.data['result'])
|
194
|
+
end
|
195
|
+
|
196
|
+
def test_result
|
197
|
+
normal_result = Legs::Result.new({'id'=>1, 'result' => 'Hello World'})
|
198
|
+
error_result = Legs::Result.new({'id'=>2, 'error' => 'Uh oh Spagetti-o\'s'})
|
199
|
+
|
200
|
+
assert_equal(normal_result.value, 'Hello World')
|
201
|
+
assert_equal(normal_result.result, 'Hello World')
|
202
|
+
assert_equal((error_result.value rescue :good), :good)
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
metadata
ADDED
@@ -0,0 +1,86 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: legs
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 3
|
5
|
+
prerelease:
|
6
|
+
segments:
|
7
|
+
- 0
|
8
|
+
- 6
|
9
|
+
- 2
|
10
|
+
version: 0.6.2
|
11
|
+
platform: ruby
|
12
|
+
authors:
|
13
|
+
- Jenna Fox
|
14
|
+
autorequire:
|
15
|
+
bindir: bin
|
16
|
+
cert_chain: []
|
17
|
+
|
18
|
+
date: 2008-07-17 00:00:00 Z
|
19
|
+
dependencies:
|
20
|
+
- !ruby/object:Gem::Dependency
|
21
|
+
name: json_pure
|
22
|
+
prerelease: false
|
23
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
24
|
+
none: false
|
25
|
+
requirements:
|
26
|
+
- - ">"
|
27
|
+
- !ruby/object:Gem::Version
|
28
|
+
hash: 19
|
29
|
+
segments:
|
30
|
+
- 1
|
31
|
+
- 1
|
32
|
+
- 0
|
33
|
+
version: 1.1.0
|
34
|
+
type: :runtime
|
35
|
+
version_requirements: *id001
|
36
|
+
description: Legs is a really simple fun networking library that uses 'json-rpc' formated messages over a tcp connection to really easily built peery or server-clienty sorts of apps, for ruby newbies and hackers to build fun little things.
|
37
|
+
email: a@creativepony.com
|
38
|
+
executables: []
|
39
|
+
|
40
|
+
extensions: []
|
41
|
+
|
42
|
+
extra_rdoc_files: []
|
43
|
+
|
44
|
+
files:
|
45
|
+
- README.rdoc
|
46
|
+
- legs.gemspec
|
47
|
+
- lib/legs.rb
|
48
|
+
- examples/echo-server.rb
|
49
|
+
- examples/chat-server.rb
|
50
|
+
- examples/shoes-chat-client.rb
|
51
|
+
- test/test_legs.rb
|
52
|
+
homepage: http://github.com/Bluebie/legs
|
53
|
+
licenses: []
|
54
|
+
|
55
|
+
post_install_message:
|
56
|
+
rdoc_options: []
|
57
|
+
|
58
|
+
require_paths:
|
59
|
+
- lib
|
60
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
61
|
+
none: false
|
62
|
+
requirements:
|
63
|
+
- - ">="
|
64
|
+
- !ruby/object:Gem::Version
|
65
|
+
hash: 3
|
66
|
+
segments:
|
67
|
+
- 0
|
68
|
+
version: "0"
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
70
|
+
none: false
|
71
|
+
requirements:
|
72
|
+
- - ">="
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
hash: 3
|
75
|
+
segments:
|
76
|
+
- 0
|
77
|
+
version: "0"
|
78
|
+
requirements: []
|
79
|
+
|
80
|
+
rubyforge_project:
|
81
|
+
rubygems_version: 1.8.5
|
82
|
+
signing_key:
|
83
|
+
specification_version: 3
|
84
|
+
summary: Simple fun open networking for newbies and quick hacks
|
85
|
+
test_files: []
|
86
|
+
|