Bluebie-legs 0.6
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 +119 -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 +351 -0
- data/test/tester.rb +108 -0
- metadata +67 -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
|
@@ -0,0 +1,119 @@
|
|
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
|
+
class User; attr_accessor :id, :name; end
|
6
|
+
|
7
|
+
Legs.start do
|
8
|
+
def initialize
|
9
|
+
@rooms = Hash.new { {'users' => [], 'messages' => [], 'topic' => 'No topic set'} }
|
10
|
+
@rooms['Lobby'] = {'topic' => 'General Chit Chat', 'messages' => [], 'users' => []}
|
11
|
+
end
|
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
|
+
server.broadcast :room_created, room_name
|
24
|
+
end
|
25
|
+
|
26
|
+
# room = room_object(room_name)
|
27
|
+
#
|
28
|
+
# unless room['users'].include?(caller)
|
29
|
+
# broadcast_to room, '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_to room, 'user_left', room_name, user_object(caller)
|
41
|
+
true
|
42
|
+
end
|
43
|
+
|
44
|
+
# sets the room topic message
|
45
|
+
def set_topic(room, message)
|
46
|
+
@rooms[room.to_s]['topic'] = message.to_s
|
47
|
+
broadcast_to room, 'room_changed', room_object(room, :remote, :name, :topic)
|
48
|
+
end
|
49
|
+
|
50
|
+
# sets the user's name
|
51
|
+
def set_name(name)
|
52
|
+
caller.meta[:name] = name.to_s
|
53
|
+
user_rooms(caller).each do |room_name|
|
54
|
+
broadcast_to room_name, 'user_changed', user_object(caller)
|
55
|
+
end
|
56
|
+
true
|
57
|
+
end
|
58
|
+
|
59
|
+
# returns information about ones self, clients thusly can find out their user 'id' number
|
60
|
+
def get_user(object_id = nil)
|
61
|
+
user = user_object( object_id.nil? ? caller : users.select { |u| u.object_id == object_id.to_i }.first )
|
62
|
+
user['rooms'] = user_rooms(user)
|
63
|
+
return user
|
64
|
+
end
|
65
|
+
|
66
|
+
# posts a message to a room
|
67
|
+
def post_message(room_name, message)
|
68
|
+
room = room_object(room_name)
|
69
|
+
room['messages'].push(msg = {'user' => user_object(caller), 'time' => Time.now.to_i, 'message' => message.to_s} )
|
70
|
+
trim_messages room
|
71
|
+
broadcast_to room, 'message', room_name.to_s, msg
|
72
|
+
return msg
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
# trims the message backlog
|
78
|
+
def trim_messages room
|
79
|
+
room = room_object(room) if room.is_a?(String)
|
80
|
+
while room['messages'].length > 250
|
81
|
+
room['messages'].shift
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# sends a notification to members of a room
|
86
|
+
def broadcast_to room, *args
|
87
|
+
room = @rooms[room.to_s] if room.is_a? String
|
88
|
+
room['users'].each do |user|
|
89
|
+
user.notify! *args
|
90
|
+
end
|
91
|
+
return true
|
92
|
+
end
|
93
|
+
|
94
|
+
# makes a user object suitable for sending back with meta info and stuff
|
95
|
+
def user_object user
|
96
|
+
object = {'id' => user.object_id}
|
97
|
+
user.meta.each_pair do |key, value|
|
98
|
+
object[key.to_s] = value
|
99
|
+
end
|
100
|
+
return object
|
101
|
+
end
|
102
|
+
|
103
|
+
def room_object room_name, target = :local, *only
|
104
|
+
object = @rooms[room_name.to_s].dup
|
105
|
+
object['users'].delete_if {|user| user.connected? == false }
|
106
|
+
object['users'] = object['users'].map { |user| user_object(user) } if target == :remote
|
107
|
+
object['topic'] = 'No topic set' if object['topic'].nil? or object['topic'].empty?
|
108
|
+
object['name'] = room_name.to_s if target == :remote
|
109
|
+
object.delete_if { |key, value| only.include?(key.to_sym) == false } unless only.empty?
|
110
|
+
object
|
111
|
+
end
|
112
|
+
|
113
|
+
# returns all the room names the user is in.
|
114
|
+
def user_rooms user
|
115
|
+
@rooms.values.select { |room| room['users'].include?(user) }.map { |room| @rooms.index(room) }
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
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"
|
4
|
+
s.date = "2008-07-12"
|
5
|
+
s.summary = "Simple fun open networking for newbies and quick hacks"
|
6
|
+
s.email = "blue@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 'Bluebie' 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/tester.rb"]
|
12
|
+
s.add_dependency("json_pure", ["> 1.1.0"])
|
13
|
+
end
|
data/lib/legs.rb
ADDED
@@ -0,0 +1,351 @@
|
|
1
|
+
# Legs take you places, a networking companion to Shoes
|
2
|
+
require 'rubygems'
|
3
|
+
require 'json' unless self.class.const_defined? 'JSON'
|
4
|
+
require 'socket'
|
5
|
+
require 'thread'
|
6
|
+
|
7
|
+
Thread.abort_on_exception = true # Should be able to run without this, hopefully. Helps with debugging though
|
8
|
+
|
9
|
+
class Legs
|
10
|
+
attr_reader :socket, :parent, :meta
|
11
|
+
|
12
|
+
# Legs.new for a client, subclass to make a server, .new then makes server and client!
|
13
|
+
def initialize(host = 'localhost', port = 30274)
|
14
|
+
self.class.start(port) if self.class != Legs && !self.class.started?
|
15
|
+
ObjectSpace.define_finalizer(self) { self.close! }
|
16
|
+
@socket = TCPSocket.new(host, port) and @parent = false if host.instance_of?(String)
|
17
|
+
@socket = host and @parent = port if host.instance_of?(TCPSocket)
|
18
|
+
@responses = Hash.new; @meta = {}; @closed = false
|
19
|
+
@responses_mutex = Mutex.new; @socket_mutex = Mutex.new
|
20
|
+
|
21
|
+
@handle_data = Proc.new do |data|
|
22
|
+
data = self.__json_restore(JSON.parse(data))
|
23
|
+
|
24
|
+
if data['method'] == '**remote__disconnecting**'
|
25
|
+
self.close!
|
26
|
+
elsif @parent and data['method']
|
27
|
+
@parent.__data!(data, self)
|
28
|
+
elsif data['error'] and data['id'].nil?
|
29
|
+
raise data['error']
|
30
|
+
else
|
31
|
+
@responses_mutex.synchronize { @responses[data['id']] = data }
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
@thread = Thread.new do
|
36
|
+
while connected?
|
37
|
+
begin
|
38
|
+
self.close! if @socket.eof?
|
39
|
+
data = nil
|
40
|
+
@socket_mutex.synchronize { data = @socket.gets(self.class.terminator) }
|
41
|
+
if data.nil?
|
42
|
+
self.close!
|
43
|
+
else
|
44
|
+
@handle_data[data]
|
45
|
+
end
|
46
|
+
rescue JSON::ParserError => e
|
47
|
+
self.send_data!({"error" => "JSON provided is invalid. See http://json.org/ to see how to format correctly."})
|
48
|
+
rescue IOError => e
|
49
|
+
self.close!
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# I think you can guess this one
|
56
|
+
def connected?; @socket.closed? == false and @closed == false; end
|
57
|
+
|
58
|
+
# closes the connection and the threads and stuff for this user
|
59
|
+
def close!
|
60
|
+
@closed = true
|
61
|
+
puts "User #{self.inspect} disconnecting" if self.class.log?
|
62
|
+
@parent.event(:disconnect, self) if @parent
|
63
|
+
|
64
|
+
# notify the remote side
|
65
|
+
notify!('**remote__disconnecting**') rescue nil
|
66
|
+
|
67
|
+
@parent.users_mutex.synchronize { @parent.users.delete(self) } if @parent
|
68
|
+
|
69
|
+
@socket.close rescue nil
|
70
|
+
end
|
71
|
+
|
72
|
+
# send a notification to this user
|
73
|
+
def notify!(method, *args)
|
74
|
+
puts "Notify #{self.__inspect}: #{method}(#{args.map { |i| i.inspect }.join(', ')})" if self.__class.log?
|
75
|
+
self.__send_data!({'method' => method.to_s, 'params' => args, 'id' => nil})
|
76
|
+
end
|
77
|
+
|
78
|
+
# sends a normal RPC request that has a response
|
79
|
+
def send!(method, *args)
|
80
|
+
puts "Call #{self.__inspect}: #{method}(#{args.map { |i| i.inspect }.join(', ')})" if self.__class.log?
|
81
|
+
id = self.__get_unique_number
|
82
|
+
self.send_data! 'method' => method.to_s, 'params' => args, 'id' => id
|
83
|
+
|
84
|
+
while @responses_mutex.synchronize { @responses.keys.include?(id) } == false
|
85
|
+
sleep(0.01)
|
86
|
+
end
|
87
|
+
|
88
|
+
data = @responses_mutex.synchronize { @responses.delete(id) }
|
89
|
+
|
90
|
+
error = data['error']
|
91
|
+
raise error unless error.nil?
|
92
|
+
|
93
|
+
puts ">> #{method} #=> #{data['result'].inspect}" if self.__class.log?
|
94
|
+
|
95
|
+
return data['result']
|
96
|
+
end
|
97
|
+
|
98
|
+
def inspect
|
99
|
+
"<Legs:#{__object_id} Meta: #{@meta.inspect}>"
|
100
|
+
end
|
101
|
+
|
102
|
+
# does an async request which calls a block when response arrives
|
103
|
+
def send_async!(method, *args, &blk)
|
104
|
+
puts "Call #{self.__inspect}: #{method}(#{args.map { |i| i.inspect }.join(', ')})" if self.__class.log?
|
105
|
+
id = self.__get_unique_number
|
106
|
+
self.send_data! 'method' => method.to_s, 'params' => args, 'id' => id
|
107
|
+
|
108
|
+
Thread.new do
|
109
|
+
while @responses_mutex.synchronize { @responses.keys.include?(id) } == false
|
110
|
+
sleep(0.05)
|
111
|
+
end
|
112
|
+
|
113
|
+
data = @responses_mutex.synchronize { @responses.delete(id) }
|
114
|
+
puts ">> #{method} #=> #{data['result'].inspect}" if self.__class.log? unless data['error']
|
115
|
+
blk[Legs::AsyncData.new(data)]
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
# maps undefined methods in to rpc calls
|
120
|
+
def method_missing(method, *args)
|
121
|
+
return self.send(method, *args) if method.to_s =~ /^__/
|
122
|
+
send!(method, *args)
|
123
|
+
end
|
124
|
+
|
125
|
+
# hacks the send method so ancestor methods instead become rpc calls too
|
126
|
+
# if you want to use a method in a Legs superclass, prefix with __
|
127
|
+
def send(method, *args)
|
128
|
+
return super(method.to_s.sub(/^__/, ''), *args) if method.to_s =~ /^__/
|
129
|
+
return super(method, *args) if self.__public_methods(false).include?(method)
|
130
|
+
super('send!', method.to_s, *args)
|
131
|
+
end
|
132
|
+
|
133
|
+
# sends raw object over the socket
|
134
|
+
def send_data!(data)
|
135
|
+
raise "Lost remote connection" unless connected?
|
136
|
+
@socket_mutex.synchronize { @socket.write(JSON.generate(__json_marshal(data)) + self.__class.terminator) }
|
137
|
+
end
|
138
|
+
|
139
|
+
|
140
|
+
private
|
141
|
+
|
142
|
+
# takes a ruby object, and converts it if needed in to marshalled hashes
|
143
|
+
def json_marshal(object)
|
144
|
+
case object
|
145
|
+
when Bignum, Fixnum, Integer, Float, TrueClass, FalseClass, String, NilClass
|
146
|
+
return object
|
147
|
+
when Hash
|
148
|
+
out = Hash.new
|
149
|
+
object.each_pair { |k,v| out[k.to_s] = __json_marshal(v) }
|
150
|
+
return out
|
151
|
+
when Array
|
152
|
+
return object.map { |v| __json_marshal(v) }
|
153
|
+
else
|
154
|
+
return {'__jsonclass__' => [object.class.name, object._dump]} if object.respond_to?(:_dump)
|
155
|
+
|
156
|
+
# the default marshalling behaviour
|
157
|
+
instance_vars = {}
|
158
|
+
object.instance_variables.each do |var_name|
|
159
|
+
instance_vars[var_name.to_s.sub(/@/, '')] = self.__json_marshal(object.instance_variable_get(var_name))
|
160
|
+
end
|
161
|
+
|
162
|
+
return {'__jsonclass__' => [object.class.name]}.merge(instance_vars)
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
# takes an object from the network, and decodes any marshalled hashes back in to ruby objects
|
167
|
+
def json_restore(object)
|
168
|
+
case object
|
169
|
+
when Hash
|
170
|
+
if object.keys.include? '__jsonclass__'
|
171
|
+
constructor = object.delete('__jsonclass__')
|
172
|
+
class_name = constructor.shift.to_s
|
173
|
+
object_class = Module.const_get(class_name) rescue false
|
174
|
+
|
175
|
+
if object_class.name == class_name
|
176
|
+
return object_class._load(*constructor) if object_class.respond_to?(:_load) unless constructor.empty?
|
177
|
+
|
178
|
+
instance = object_class.allocate
|
179
|
+
object.each_pair do |key, value|
|
180
|
+
instance.instance_variable_set("@#{key}", self.__json_restore(value))
|
181
|
+
end
|
182
|
+
return instance
|
183
|
+
else
|
184
|
+
raise "Response contains a #{class_name} but that class is not loaded locally."
|
185
|
+
end
|
186
|
+
else
|
187
|
+
hash = Hash.new
|
188
|
+
object.each_pair { |k,v| hash[k] = self.__json_restore(v) }
|
189
|
+
return hash
|
190
|
+
end
|
191
|
+
when Array
|
192
|
+
return object.map { |i| self.__json_restore(i) }
|
193
|
+
else
|
194
|
+
return object
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
# gets a unique number that we can use to match requests to responses
|
199
|
+
def get_unique_number; @unique_id ||= 0; @unique_id += 1; end
|
200
|
+
end
|
201
|
+
|
202
|
+
|
203
|
+
# the server is started by subclassing Legs, then SubclassName.start
|
204
|
+
class << Legs
|
205
|
+
attr_accessor :terminator, :log
|
206
|
+
attr_reader :users, :server_object, :users_mutex, :messages_mutex
|
207
|
+
alias_method :log?, :log
|
208
|
+
|
209
|
+
def initializer
|
210
|
+
ObjectSpace.define_finalizer(self) { self.stop! }
|
211
|
+
@users = []; @messages = Queue.new; @terminator = "\n"; @log = true
|
212
|
+
@users_mutex = Mutex.new
|
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 if started?
|
220
|
+
raise "Legs.start requires a block" unless blk
|
221
|
+
@started = true
|
222
|
+
|
223
|
+
# make the fake class
|
224
|
+
@server_class = Class.new
|
225
|
+
@server_class.module_eval { private; attr_reader :server, :caller; public }
|
226
|
+
@server_class.module_eval(&blk)
|
227
|
+
@server_object = @server_class.allocate
|
228
|
+
@server_object.instance_variable_set(:@server, self)
|
229
|
+
@server_object.instance_eval { initialize }
|
230
|
+
|
231
|
+
@message_processor = Thread.new do
|
232
|
+
while started?
|
233
|
+
sleep(0.01) and next if @messages.empty?
|
234
|
+
data, from = @messages.deq
|
235
|
+
method = data['method']; params = data['params']
|
236
|
+
methods = @server_object.public_methods(false)
|
237
|
+
|
238
|
+
begin
|
239
|
+
raise "Supplied method is not a String" unless method.is_a?(String)
|
240
|
+
raise "Supplied params object is not an Array" unless params.is_a?(Array)
|
241
|
+
raise "Cannot run '#{method}' because it is not defined in this server" unless methods.include?(method.to_s) or methods.include? :method_missing
|
242
|
+
|
243
|
+
puts "Call #{method}(#{params.map { |i| i.inspect }.join(', ')})" if log?
|
244
|
+
|
245
|
+
@server_object.instance_variable_set(:@caller, from)
|
246
|
+
|
247
|
+
result = nil
|
248
|
+
|
249
|
+
@users_mutex.synchronize do
|
250
|
+
if methods.include?(method.to_s)
|
251
|
+
result = @server_object.__send__(method.to_s, *params)
|
252
|
+
else
|
253
|
+
result = @server_object.method_missing(method.to_s, *params)
|
254
|
+
end
|
255
|
+
end
|
256
|
+
|
257
|
+
puts ">> #{method} #=> #{result.inspect}" if log?
|
258
|
+
|
259
|
+
from.send_data!({'id' => data['id'], 'result' => result}) unless data['id'].nil?
|
260
|
+
|
261
|
+
rescue Exception => e
|
262
|
+
from.send_data!({'error' => e, 'id' => data['id']}) unless data['id'].nil?
|
263
|
+
puts "Backtrace: \n" + e.backtrace.map { |i| " #{i}" }.join("\n") if log?
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
unless port.nil? or port == false
|
269
|
+
@listener = TCPServer.new(port)
|
270
|
+
|
271
|
+
@acceptor_thread = Thread.new do
|
272
|
+
while started?
|
273
|
+
user = Legs.new(@listener.accept, self)
|
274
|
+
@users_mutex.synchronize { @users.push user }
|
275
|
+
puts "User #{user.object_id} connected, number of users: #{@users.length}" if log?
|
276
|
+
self.event :connect, user
|
277
|
+
end
|
278
|
+
end
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
# stops the server, disconnects the clients
|
283
|
+
def stop
|
284
|
+
@started = false
|
285
|
+
@users.each { |user| user.close! }
|
286
|
+
end
|
287
|
+
|
288
|
+
# sends a notification message to all connected clients
|
289
|
+
def broadcast(method, *args)
|
290
|
+
@users.each { |user| user.notify!(method, *args) }
|
291
|
+
end
|
292
|
+
|
293
|
+
# Finds a user by the value of a certain property... like find_user_by :object_id, 12345
|
294
|
+
def find_user_by property, value
|
295
|
+
@users.find { |user| user.__send(property) == value }
|
296
|
+
end
|
297
|
+
|
298
|
+
def find_users_by property, *values
|
299
|
+
@users.select { |user| user.__send(property) == value }
|
300
|
+
end
|
301
|
+
|
302
|
+
# gives you an array of all the instances of Legs which are still connected
|
303
|
+
# direction can be :both, :in, or :out
|
304
|
+
def connections direction = :both
|
305
|
+
return @users if direction == :in
|
306
|
+
list = Array.new
|
307
|
+
ObjectSpace.each_object(self) do |leg|
|
308
|
+
next if list.include?(leg) unless leg.connected?
|
309
|
+
next unless leg.parent == false if direction == :out
|
310
|
+
list.push leg
|
311
|
+
end
|
312
|
+
return list
|
313
|
+
end
|
314
|
+
|
315
|
+
# add an event call to the server's message queue
|
316
|
+
def event(name, sender, *extras)
|
317
|
+
return unless @server_object.respond_to?("on_#{name}")
|
318
|
+
__data!({'method' => "on_#{name}", 'params' => extras.to_a, 'id' => nil}, sender)
|
319
|
+
end
|
320
|
+
|
321
|
+
# gets called to handle all incomming messages (RPC requests)
|
322
|
+
def __data!(data, from)
|
323
|
+
@messages.enq [data, from]
|
324
|
+
end
|
325
|
+
|
326
|
+
# returns true if server is running
|
327
|
+
def started?; @started; end
|
328
|
+
|
329
|
+
# creates a legs client, and passes it to supplied block, closes client after block finishes running
|
330
|
+
# I wouldn't have added this method to keep shoes small, but users insist comedic value makes it worthwhile
|
331
|
+
def open(*args, &blk)
|
332
|
+
client = Legs.new(*args)
|
333
|
+
blk[client]
|
334
|
+
client.close!
|
335
|
+
end
|
336
|
+
end
|
337
|
+
|
338
|
+
Legs.initializer
|
339
|
+
|
340
|
+
|
341
|
+
class Legs::AsyncData
|
342
|
+
def initialize(data); @data = data; end
|
343
|
+
def result
|
344
|
+
@errored = true and raise @data['error'] if @data['error'] unless @errored
|
345
|
+
return @data['result']
|
346
|
+
end
|
347
|
+
alias_method :value, :result
|
348
|
+
end
|
349
|
+
|
350
|
+
class Legs::StartBlockError < StandardError; end
|
351
|
+
class Legs::RequestError < StandardError; end
|
data/test/tester.rb
ADDED
@@ -0,0 +1,108 @@
|
|
1
|
+
require '../lib/legs.rb'
|
2
|
+
|
3
|
+
# class to test the marshaling
|
4
|
+
class Testing
|
5
|
+
attr_accessor :a, :b, :c
|
6
|
+
end
|
7
|
+
|
8
|
+
Legs.log = false
|
9
|
+
|
10
|
+
# a simple server to test with
|
11
|
+
Legs.start(6425) do
|
12
|
+
def echo(text)
|
13
|
+
return text
|
14
|
+
end
|
15
|
+
|
16
|
+
def count
|
17
|
+
caller.meta[:counter] ||= 0
|
18
|
+
caller.meta[:counter] += 1
|
19
|
+
end
|
20
|
+
|
21
|
+
def error
|
22
|
+
raise StandardError.new("This is a fake error")
|
23
|
+
end
|
24
|
+
|
25
|
+
def test_notify
|
26
|
+
puts "Success"
|
27
|
+
end
|
28
|
+
|
29
|
+
def marshal
|
30
|
+
obj = Testing.new
|
31
|
+
obj.a = 1; obj.b = 2; obj.c = 3
|
32
|
+
return obj
|
33
|
+
end
|
34
|
+
|
35
|
+
def on_disconnect
|
36
|
+
puts "#{caller.inspect} disconnected"
|
37
|
+
end
|
38
|
+
|
39
|
+
def on_connect
|
40
|
+
$server_instance = caller
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
## connects and tests a bunch of things
|
45
|
+
puts "Testing syncronous echo"
|
46
|
+
i = Legs.new('localhost',6425)
|
47
|
+
i.meta[:name] = 'first tester client'
|
48
|
+
puts i.echo('Hello World') == 'Hello World' ?"Success":"Failure"
|
49
|
+
|
50
|
+
puts "Testing Count"
|
51
|
+
puts i.count==1 && i.count == 2 && i.count == 3 ?'Success':'Failure'
|
52
|
+
|
53
|
+
puts "Testing count resets correctly"
|
54
|
+
ii = Legs.new('localhost',6425)
|
55
|
+
ii.meta[:name] = 'second tester client'
|
56
|
+
puts ii.count == 1 && ii.count == 2 && ii.count == 3 ?'Success':'Failure'
|
57
|
+
ii.close!
|
58
|
+
|
59
|
+
sleep(0.5)
|
60
|
+
puts "Testing disconnection went well"
|
61
|
+
puts ii.connected? == false && $server_instance.connected? == false ?'Success':'Failure'
|
62
|
+
|
63
|
+
ii = nil
|
64
|
+
ObjectSpace.garbage_collect
|
65
|
+
|
66
|
+
puts "Testing server disconnect worked correctly"
|
67
|
+
sleep(0.5)
|
68
|
+
puts Legs.users.length == 1 ?'Success':"Failure: #{Legs.users.length}"
|
69
|
+
|
70
|
+
puts "Testing async call..."
|
71
|
+
i.send_async!(:echo, 'Testing') do |r|
|
72
|
+
puts r.result == 'Testing' ?'Success':'Failure'
|
73
|
+
end
|
74
|
+
sleep(0.5)
|
75
|
+
|
76
|
+
puts "Testing async error..."
|
77
|
+
i.send_async!(:error) do |r|
|
78
|
+
begin
|
79
|
+
v = r.result
|
80
|
+
puts "Failure"
|
81
|
+
rescue
|
82
|
+
puts "Success"
|
83
|
+
end
|
84
|
+
end
|
85
|
+
sleep(0.5)
|
86
|
+
|
87
|
+
puts "Testing regular error"
|
88
|
+
v = i.error rescue :good
|
89
|
+
puts v == :good ?'Success':'Failure'
|
90
|
+
|
91
|
+
puts "Testing notify!"
|
92
|
+
i.test_notify
|
93
|
+
|
94
|
+
puts "Testing marshalling"
|
95
|
+
m = i.marshal
|
96
|
+
puts m.a == 1 && m.b == 2 && m.c == 3 ?'Success':'Failure'
|
97
|
+
|
98
|
+
puts "Testing Legs.connections"
|
99
|
+
puts (Legs.connections(:out) == i and Legs.connections.length == 2 and Legs.connections(:in) == Legs.users.first) ?'Success':'Failure'
|
100
|
+
puts ":in => #{Legs.connections(:in).map{ |l| l.inspect }}"
|
101
|
+
puts ":out => #{Legs.connections(:out).map{ |l| l.inspect }}"
|
102
|
+
puts ":both => #{Legs.connections.map{ |l| l.inspect }}"
|
103
|
+
|
104
|
+
puts "All: "
|
105
|
+
ObjectSpace.each_object(Legs) { |l| puts " #{l.inspect}" }
|
106
|
+
|
107
|
+
puts
|
108
|
+
puts "Done"
|
metadata
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: Bluebie-legs
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: "0.6"
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Jenna 'Bluebie' Fox
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2008-07-12 00:00:00 -07:00
|
13
|
+
default_executable:
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: json_pure
|
17
|
+
version_requirement:
|
18
|
+
version_requirements: !ruby/object:Gem::Requirement
|
19
|
+
requirements:
|
20
|
+
- - ">"
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: 1.1.0
|
23
|
+
version:
|
24
|
+
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.
|
25
|
+
email: blue@creativepony.com
|
26
|
+
executables: []
|
27
|
+
|
28
|
+
extensions: []
|
29
|
+
|
30
|
+
extra_rdoc_files: []
|
31
|
+
|
32
|
+
files:
|
33
|
+
- README.rdoc
|
34
|
+
- legs.gemspec
|
35
|
+
- lib/legs.rb
|
36
|
+
- examples/echo-server.rb
|
37
|
+
- examples/chat-server.rb
|
38
|
+
- examples/shoes-chat-client.rb
|
39
|
+
- test/tester.rb
|
40
|
+
has_rdoc: false
|
41
|
+
homepage: http://github.com/Bluebie/legs
|
42
|
+
post_install_message:
|
43
|
+
rdoc_options: []
|
44
|
+
|
45
|
+
require_paths:
|
46
|
+
- lib
|
47
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
48
|
+
requirements:
|
49
|
+
- - ">="
|
50
|
+
- !ruby/object:Gem::Version
|
51
|
+
version: "0"
|
52
|
+
version:
|
53
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
54
|
+
requirements:
|
55
|
+
- - ">="
|
56
|
+
- !ruby/object:Gem::Version
|
57
|
+
version: "0"
|
58
|
+
version:
|
59
|
+
requirements: []
|
60
|
+
|
61
|
+
rubyforge_project:
|
62
|
+
rubygems_version: 1.2.0
|
63
|
+
signing_key:
|
64
|
+
specification_version: 2
|
65
|
+
summary: Simple fun open networking for newbies and quick hacks
|
66
|
+
test_files: []
|
67
|
+
|