stompserver 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/History.txt ADDED
@@ -0,0 +1,10 @@
1
+ == 0.9.0 / 16 Oct 2006
2
+
3
+ * Initialial Beta Release
4
+ * Seems to work
5
+ * Passes numerous test cases
6
+ * Journals using madeleine
7
+ * Needs documentaion
8
+ * Needs to command line processing
9
+ * Needs service/daemon options
10
+
data/Manifest.txt ADDED
@@ -0,0 +1,16 @@
1
+ History.txt
2
+ Manifest.txt
3
+ README.txt
4
+ Rakefile
5
+ setup.rb
6
+ bin/stompserver
7
+ lib/frame_journal.rb
8
+ lib/queue_manager.rb
9
+ lib/stomp_frame.rb
10
+ lib/stomp_server.rb
11
+ lib/topic_manager.rb
12
+ test/test_frame_journal.rb
13
+ test/test_queue_manager.rb
14
+ test/test_stomp_frame.rb
15
+ test/test_stomp_server.rb
16
+ test/test_topic_manager.rb
data/README.txt ADDED
@@ -0,0 +1,58 @@
1
+ stompserver
2
+ by Patrick Hurley
3
+ http://stompserver.rubyforge.org/
4
+
5
+ == DESCRIPTION:
6
+
7
+ Don't want to install a JVM, but still want to use messaging? Me too,
8
+ so I threw together this little server. All the hard work was done
9
+ by Francis Cianfrocca (big thank you) in his event machine gem (which
10
+ is required by this server).
11
+
12
+ == FEATURES/PROBLEMS:
13
+
14
+ Handles basic message queue processing
15
+ Does not support any server to server messaging
16
+ (although you could write a client to do this)
17
+ Server Id is not being well initialized
18
+ Quite a bit of polish is still required to make into a daemon/service
19
+ and add command line handling.
20
+ And oh yeah, I need to write some docs (see the tests for now)
21
+
22
+ == SYNOPSYS:
23
+
24
+ Handles basic message queue processing
25
+
26
+ == REQUIREMENTS:
27
+
28
+ + EventMachine
29
+ + madeleine
30
+
31
+ == INSTALL:
32
+
33
+ + Grab the gem
34
+
35
+ == LICENSE:
36
+
37
+ (The MIT License)
38
+
39
+ Copyright (c) 2006 Patrick Hurley
40
+
41
+ Permission is hereby granted, free of charge, to any person obtaining
42
+ a copy of this software and associated documentation files (the
43
+ 'Software'), to deal in the Software without restriction, including
44
+ without limitation the rights to use, copy, modify, merge, publish,
45
+ distribute, sublicense, and/or sell copies of the Software, and to
46
+ permit persons to whom the Software is furnished to do so, subject to
47
+ the following conditions:
48
+
49
+ The above copyright notice and this permission notice shall be
50
+ included in all copies or substantial portions of the Software.
51
+
52
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
53
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
54
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
55
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
56
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
57
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
58
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,23 @@
1
+ # -*- ruby -*-
2
+
3
+ require 'rubygems'
4
+ require 'hoe'
5
+ $LOAD_PATH << "./lib"
6
+ require 'stomp_server'
7
+
8
+ Hoe.new('stompserver', StompServer::VERSION) do |p|
9
+ p.rubyforge_name = 'stompserver'
10
+ p.summary = 'A very light messaging server'
11
+ p.description = p.paragraphs_of('README.txt', 2..4).join("\n\n")
12
+ p.url = p.paragraphs_of('README.txt', 0).first.split(/\n/)[1..-1]
13
+ p.changes = p.paragraphs_of('History.txt', 0..1).join("\n\n")
14
+ p.email = "phurley@gmail.com"
15
+ p.author = ["Patrick Hurley"]
16
+ p.extra_deps = [
17
+ ["eventmachine", ">= 5.0.0"],
18
+ ["madeleine", ">= 0.7.3"],
19
+ ["hoe", ">= 1.1.1"]
20
+ ]
21
+ end
22
+
23
+ # vim: syntax=Ruby
data/bin/stompserver ADDED
File without changes
@@ -0,0 +1,134 @@
1
+ # Simple Journal File(s) Manager
2
+ # You select the directory, and it will collect the messages
3
+ # The journal file format is:
4
+ #
5
+ # Status Byte: 0 - pending, 1 - processed
6
+ # Frame Size: 4 byte long (network endian - yes I limit my messages to 4G)
7
+ # Message
8
+ #
9
+ # Repeat
10
+ #
11
+ # When the size of a journal file exceeds its limit
12
+
13
+ require 'madeleine'
14
+ require 'madeleine/automatic'
15
+
16
+ class MadFrameJournal
17
+ include Madeleine::Automatic::Interceptor
18
+ attr_reader :frames
19
+ automatic_read_only :frames
20
+ attr_accessor :frame_index
21
+ automatic_read_only :frame_index
22
+ attr_accessor :system_id
23
+ automatic_read_only :system_id
24
+
25
+ def initialize
26
+ @frames = {}
27
+ @frame_index = 0
28
+ @system_id = nil
29
+ end
30
+
31
+ def add(msgid, frame)
32
+ @frames[msgid] = frame
33
+ end
34
+
35
+ def delete(msgid)
36
+ @frames.delete(msgid)
37
+ end
38
+
39
+ def clear
40
+ @frames.clear
41
+ end
42
+
43
+ automatic_read_only :lookup
44
+ def lookup(msgid)
45
+ @frames[msgid]
46
+ end
47
+ end
48
+
49
+ class FrameJournal
50
+ def initialize(directory='frame-journal', snap_freq = 60 * 5)
51
+ @directory = directory
52
+ @mad = AutomaticSnapshotMadeleine.new(directory) do
53
+ MadFrameJournal.new
54
+ end
55
+
56
+ # always snap on startup, in case we had an previous failure
57
+ @modified = true
58
+ Thread.new(@mad, snap_freq) do |mad, freq|
59
+ while true
60
+ sleep(freq)
61
+ mad.take_snapshot if @modified
62
+ @modified = false
63
+ end
64
+ end
65
+ end
66
+
67
+ def []=(msgid, frame)
68
+ @modified = true
69
+ @mad.system.add(msgid, frame)
70
+ end
71
+
72
+ def [](msgid)
73
+ @mad.system.lookup(msgid)
74
+ end
75
+
76
+ def delete(msgid)
77
+ @modified = true
78
+ @mad.system.delete(msgid)
79
+ end
80
+
81
+ def keys
82
+ @mad.system.frames.keys
83
+ end
84
+
85
+ def clear
86
+ @modified = true
87
+ @mad.system.clear
88
+ @mad.take_snapshot
89
+ end
90
+
91
+ def index
92
+ @mad.system.frame_index
93
+ end
94
+
95
+ def next_index
96
+ @modified = true
97
+ @mad.system.frame_index += 1
98
+ end
99
+
100
+ def system_id
101
+ unless name = @mad.system.system_id
102
+ # todo - grab default name from some place smarter...
103
+ @modified = true
104
+ @mad.system.system_id = 'cmastomp'
105
+ name = @mad.system.system_id
106
+ end
107
+ name
108
+ end
109
+ end
110
+
111
+ if __FILE__ == $0
112
+ fj = FrameJournal.new('fj', 3)
113
+ until ARGV.empty?
114
+ case cmd = ARGV.shift
115
+ when "keys"
116
+ puts fj.keys.inspect
117
+ when "dump"
118
+ fj.keys.each do |key|
119
+ puts "#{key}: #{fj[key]}"
120
+ end
121
+ when "show"
122
+ key = ARGV.shift
123
+ puts "#{key}: #{fj[key]}"
124
+ when "add"
125
+ key = ARGV.shift
126
+ val = ARGV.shift
127
+ fj[key] = val
128
+ when "sleep"
129
+ sleep ARGV.shift.to_i
130
+ when "delete"
131
+ fj.delete(ARGV.shift)
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,68 @@
1
+ # queue - persistent sent to a single subscriber
2
+ # queue_monitor - looks, but does not remove from queue
3
+
4
+ class QueueManager
5
+ Struct::new('QueueUser', :user, :ack)
6
+
7
+ def initialize(journal)
8
+ # read journal information
9
+ @journal = journal
10
+ @queues = Hash.new { Array.new }
11
+ @pending = Hash.new { Array.new }
12
+ @messages = Hash.new { Array.new }
13
+
14
+ # recover from previous run
15
+ msgids = @journal.keys.sort
16
+ msgids.each do |msgid|
17
+ sendmsg(@journal[msgid])
18
+ end
19
+ end
20
+
21
+ def subscribe(dest, user, use_ack=false)
22
+ user = Struct::QueueUser.new(user, use_ack)
23
+ @queues[dest] += [user]
24
+ @messages[dest].each do |frame|
25
+ send_to_user(frame, user)
26
+ end
27
+ end
28
+
29
+ def unsubscribe(topic, user)
30
+ @queues[topic].delete_if { |u| u.user == user }
31
+ end
32
+
33
+ def ack(user, frame)
34
+ pending_size = @pending[user]
35
+ msgid = frame.headers['message-id']
36
+ @pending[user].delete_if { |pf| pf.headers['message-id'] == msgid }
37
+ raise "Message (#{msgid}) not found" if pending_size == @pending[user]
38
+ @journal.delete(msgid)
39
+ end
40
+
41
+ def disconnect(user)
42
+ @pending[user].each do |frame|
43
+ sendmsg(frame)
44
+ end
45
+ end
46
+
47
+ def send_to_user(frame, user)
48
+ if user.ack
49
+ @pending[user.user] += [frame]
50
+ else
51
+ @journal.delete(frame.headers['message-id'])
52
+ end
53
+ user.user.send_data(frame.to_s)
54
+ end
55
+
56
+ def sendmsg(frame)
57
+ frame.command = "MESSAGE"
58
+ dest = frame.headers['destination']
59
+ @journal[frame.headers['message-id']] = frame
60
+
61
+ if user = @queues[dest].shift
62
+ send_to_user(frame, user)
63
+ @queues[dest].push(user)
64
+ else
65
+ @messages[dest] += [frame]
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,92 @@
1
+ class StompFrame
2
+ attr_accessor :command, :headers, :body
3
+ def initialize(command=nil, headers=nil, body=nil)
4
+ @command = command
5
+ @headers = headers || {}
6
+ @body = body || ''
7
+ end
8
+
9
+ def to_s
10
+ result = @command + "\n"
11
+ @headers['content-length'] = @body.size.to_s if @body.include?(0)
12
+ @headers.each_pair do |key, value|
13
+ result << "#{key}:#{value}\n"
14
+ end
15
+ result << "\n"
16
+ result << @body.to_s
17
+ result << "\000\n"
18
+ end
19
+
20
+ def dest
21
+ #@dest || (@dest = @headers['destination'])
22
+ @headers['destination']
23
+ end
24
+ end
25
+
26
+ class StompFrameRecognizer
27
+ attr_accessor :frames
28
+
29
+ def initialize
30
+ @buffer = ''
31
+ @body_length = nil
32
+ @frame = StompFrame.new
33
+ @frames = []
34
+ end
35
+
36
+ def parse_body(len)
37
+ raise RuntimeError.new("Invalid stompframe (missing null term)") unless @buffer[len] == 0
38
+ @frame.body = @buffer[0...len]
39
+ @buffer = @buffer[len+1..-1]
40
+ @frames << @frame
41
+ @frame = StompFrame.new
42
+ end
43
+
44
+ def parse_binary_body
45
+ if @buffer.length > @body_length
46
+ parse_body(@body_length)
47
+ end
48
+ end
49
+
50
+ def parse_text_body
51
+ if pos = @buffer.index(0)
52
+ parse_body(pos)
53
+ end
54
+ end
55
+
56
+ def parse_header
57
+ if match = @buffer.match(/^\s*(\S+)$((?:\s*.*?\s*:\s*.*?\s*$)*)$\r?\n$\r?\n/)
58
+ @frame.command, headers = match.captures
59
+ @buffer = match.post_match
60
+ headers.split(/\n/).each do |data|
61
+ if data =~ /^\s*(\S+)\s*:\s*(.*?)\s*$/
62
+ @frame.headers[$1] = $2
63
+ end
64
+ end
65
+
66
+ # body_length is nil, if there is no content-length, otherwise it is the length (as in integer)
67
+ @body_length = @frame.headers['content-length'] && @frame.headers['content-length'].to_i
68
+ end
69
+ end
70
+
71
+ def parse
72
+ count = @frames.size
73
+
74
+ parse_header unless @frame.command
75
+ if @frame.command
76
+ if @body_length
77
+ parse_binary_body
78
+ else
79
+ parse_text_body
80
+ end
81
+ end
82
+
83
+ # parse_XXX_body return the frame if they succeed and nil if they fail
84
+ # the result will fall through
85
+ parse if count != @frames.size
86
+ end
87
+
88
+ def<< (buf)
89
+ @buffer << buf
90
+ parse
91
+ end
92
+ end
@@ -0,0 +1,156 @@
1
+ require 'eventmachine'
2
+ require 'stomp_frame'
3
+ require 'topic_manager'
4
+ require 'queue_manager'
5
+ require 'frame_journal'
6
+
7
+ module StompServer
8
+ VERSION = '0.9.1'
9
+ VALID_COMMANDS = [:connect, :send, :subscribe, :unsubscribe, :begin, :commit, :abort, :ack, :disconnect]
10
+
11
+ def self.setup(j = FrameJournal.new, tm = TopicManager.new, qm = QueueManager.new(j))
12
+ @@journal = j
13
+ @@topic_manager = tm
14
+ @@queue_manager = qm
15
+ end
16
+
17
+ def post_init
18
+ @sfr = StompFrameRecognizer.new
19
+ @transactions = {}
20
+ @connected = false
21
+ end
22
+
23
+ def receive_data(data)
24
+ begin
25
+ puts "receive_data: #{data.inspect}" if $DEBUG
26
+ @sfr << data
27
+ process_frames
28
+ rescue Exception => e
29
+ puts "err: #{e} #{e.backtrace.join("\n")}" if $DEBUG
30
+ send_error(e.to_s)
31
+ close_connection_after_writing
32
+ end
33
+ end
34
+
35
+ def process_frames
36
+ frame = nil
37
+ process_frame(frame) while frame = @sfr.frames.shift
38
+ end
39
+
40
+ def process_frame(frame)
41
+ cmd = frame.command.downcase.to_sym
42
+ raise "Unhandled frame: #{cmd}" unless VALID_COMMANDS.include?(cmd)
43
+ raise "Not connected" if !@connected && cmd != :connect
44
+
45
+ # I really like this code, but my needs are a little trickier
46
+ #
47
+
48
+ if trans = frame.headers['transaction']
49
+ handle_transaction(frame, trans, cmd)
50
+ else
51
+ cmd = :sendmsg if cmd == :send
52
+ send(cmd, frame)
53
+ end
54
+
55
+ send_receipt(frame.headers['receipt']) if frame.headers['receipt']
56
+ end
57
+
58
+ def handle_transaction(frame, trans, cmd)
59
+ if [:begin, :commit, :abort].include?(cmd)
60
+ send(cmd, frame, trans)
61
+ else
62
+ raise "transaction does not exist" unless @transactions.has_key?(trans)
63
+ @transactions[trans] << frame
64
+ end
65
+ end
66
+
67
+ def connect(frame)
68
+ puts "Connecting" if $DEBUG
69
+ response = StompFrame.new("CONNECTED", {'session' => 'wow'})
70
+ send_data(response.to_s)
71
+ @connected = true
72
+ end
73
+
74
+ def sendmsg(frame)
75
+ # set message id
76
+ frame.headers['message-id'] = "msg-#{@@journal.system_id}-#{@@journal.next_index}"
77
+ if frame.dest.match(%r|^/queue|)
78
+ @@queue_manager.sendmsg(frame)
79
+ else
80
+ @@topic_manager.sendmsg(frame)
81
+ end
82
+ end
83
+
84
+ def subscribe(frame)
85
+ if frame.dest =~ %r|^/queue|
86
+ @@queue_manager.subscribe(frame.dest, self)
87
+ else
88
+ @@topic_manager.subscribe(frame.dest, self)
89
+ end
90
+ end
91
+
92
+ def unsubscribe(frame)
93
+ if frame.dest =~ %r|^/queue|
94
+ @@queue_manager.unsubscribe(self)
95
+ else
96
+ @@topic_manager.unsubscribe(self)
97
+ end
98
+ end
99
+
100
+ def begin(frame, trans=nil)
101
+ raise "Missing transaction" unless trans
102
+ raise "transaction exists" if @transactions.has_key?(trans)
103
+ @transactions[trans] = []
104
+ end
105
+
106
+ def commit(frame, trans=nil)
107
+ raise "Missing transaction" unless trans
108
+ raise "transaction does not exist" unless @transactions.has_key?(trans)
109
+
110
+ (@transactions[trans]).each do |frame|
111
+ frame.headers.delete('transaction')
112
+ process_frame(frame)
113
+ end
114
+ @transactions.delete(trans)
115
+ end
116
+
117
+ def abort(frame, trans=nil)
118
+ raise "Missing transaction" unless trans
119
+ raise "transaction does not exist" unless @transactions.has_key?(trans)
120
+ @transactions.delete(trans)
121
+ end
122
+
123
+ def ack(frame)
124
+ @@queue_manager.ack(self, frame)
125
+ end
126
+
127
+ def disconnect(frame)
128
+ puts "Polite disconnect" if $DEBUG
129
+ close_connection_after_writing
130
+ end
131
+
132
+ def send_message(msg)
133
+ msg.command = "MESSAGE"
134
+ send_data(msg.to_s)
135
+ end
136
+
137
+ def send_receipt(id)
138
+ send_frame("RECEIPT", { 'receipt-id' => id})
139
+ end
140
+
141
+ def send_error(msg)
142
+ send_frame("ERROR",{},msg)
143
+ end
144
+
145
+ def send_frame(command, headers={}, body='')
146
+ response = StompFrame.new(command, headers, body)
147
+ send_data(response.to_s)
148
+ end
149
+ end
150
+
151
+ if $0 == __FILE__
152
+ StompServer.setup
153
+ EventMachine::run do
154
+ EventMachine.start_server "0.0.0.0", 61613, StompServer
155
+ end
156
+ end
@@ -0,0 +1,24 @@
1
+ # topic - non persistent, sent to all interested parties
2
+
3
+ class TopicManager
4
+ def initialize
5
+ @topics = Hash.new { Array.new }
6
+ end
7
+
8
+ def subscribe(topic, user)
9
+ @topics[topic] += [user]
10
+ end
11
+
12
+ def unsubscribe(topic, user)
13
+ @topics[topic].delete(user)
14
+ end
15
+
16
+ def sendmsg(msg)
17
+ msg.command = "MESSAGE"
18
+ topic = msg.headers['destination']
19
+ payload = msg.to_s
20
+ @topics[topic].each do |user|
21
+ user.send_data(payload)
22
+ end
23
+ end
24
+ end