goat 0.1.3
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/bin/yodel +28 -0
- data/lib/goat.rb +938 -0
- data/lib/goat/extn.rb +26 -0
- data/lib/goat/goat.js +172 -0
- data/lib/goat/html.rb +147 -0
- data/lib/goat/logger.rb +39 -0
- data/lib/goat/mongo.rb +28 -0
- data/lib/goat/notifications.rb +121 -0
- data/lib/goat/sinatra.rb +11 -0
- data/lib/goat/yodel.rb +38 -0
- data/lib/views/plain_layout.erb +7 -0
- metadata +77 -0
data/lib/goat/extn.rb
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
|
3
|
+
class Symbol
|
4
|
+
def to_proc
|
5
|
+
Proc.new { |*args| args.shift.__send__(self, *args) }
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
class Integer
|
10
|
+
def times_collect
|
11
|
+
(1..self).collect {|n| yield}
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
class String
|
16
|
+
unless self.instance_methods.include?('random')
|
17
|
+
def self.random(len=10, opts={})
|
18
|
+
opts = {:alpha => false}.merge(opts)
|
19
|
+
chars = ["a".."z", "A".."Z"]
|
20
|
+
chars << ("0".."9") unless opts[:alpha]
|
21
|
+
|
22
|
+
flat = chars.map(&:to_a).flatten
|
23
|
+
len.times_collect {flat[SecureRandom.random_number(flat.size)]}.join
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
data/lib/goat/goat.js
ADDED
@@ -0,0 +1,172 @@
|
|
1
|
+
var Goat = {
|
2
|
+
channelOpenFails: 0,
|
3
|
+
activeChannel: null,
|
4
|
+
components: {},
|
5
|
+
page_id: null
|
6
|
+
}
|
7
|
+
|
8
|
+
function closure(target, fn) {
|
9
|
+
return function() {
|
10
|
+
return fn.apply(target, arguments);
|
11
|
+
}
|
12
|
+
}
|
13
|
+
|
14
|
+
$.extend(Goat, {
|
15
|
+
closure: function(fn) { return closure(this, fn); },
|
16
|
+
|
17
|
+
node: function(str) {
|
18
|
+
return $('#' + str);
|
19
|
+
},
|
20
|
+
|
21
|
+
createNode: function(m) {
|
22
|
+
var after = m['after'];
|
23
|
+
var html = m['html'];
|
24
|
+
|
25
|
+
if(after) {
|
26
|
+
var anode = this.node(after);
|
27
|
+
if(anode.size() == 0)
|
28
|
+
console.error("Couldn't find node " + after + " to insert after");
|
29
|
+
else
|
30
|
+
anode.after(html);
|
31
|
+
} else {
|
32
|
+
$(document).after(html);
|
33
|
+
}
|
34
|
+
},
|
35
|
+
|
36
|
+
removeNode: function(m) {
|
37
|
+
var id = m['id'];
|
38
|
+
var html = m['html'];
|
39
|
+
var rm = this.node(id);
|
40
|
+
if(rm.size() == 0)
|
41
|
+
console.error("Couldn't find node " + id + " to remove");
|
42
|
+
else
|
43
|
+
rm.remove();
|
44
|
+
},
|
45
|
+
|
46
|
+
showAlert: function(m) {
|
47
|
+
alert_msg = m['message'];
|
48
|
+
alert(alert_msg);
|
49
|
+
},
|
50
|
+
|
51
|
+
messageReceived: function(m) {
|
52
|
+
var t = m['type'];
|
53
|
+
if(t == 'update') {
|
54
|
+
var id = m['id'];
|
55
|
+
var html = m['html'];
|
56
|
+
this.node(id).html(html);
|
57
|
+
} else if(t == 'redirect') {
|
58
|
+
console.log("Redirecting to " + m['location']);
|
59
|
+
sleep(2);
|
60
|
+
window.location = m['location'];
|
61
|
+
} else if (t == 'create') {
|
62
|
+
this.createNode(m);
|
63
|
+
} else if (t == 'remove') {
|
64
|
+
this.removeNode(m);
|
65
|
+
} else if(t == 'alert') {
|
66
|
+
this.showAlert(m);
|
67
|
+
} else {
|
68
|
+
console.log("Unknown message type: " + m);
|
69
|
+
}
|
70
|
+
},
|
71
|
+
|
72
|
+
channelDataReceived: function(data) {
|
73
|
+
if(data == "") {
|
74
|
+
this.reopenChannel();
|
75
|
+
return;
|
76
|
+
}
|
77
|
+
|
78
|
+
// console.warn("channel data: " + data);
|
79
|
+
data = $.evalJSON(data);
|
80
|
+
|
81
|
+
this.channelOpenFails = 0;
|
82
|
+
|
83
|
+
if(this.messageReceivedDelegate)
|
84
|
+
this.messageReceivedDelegate(data);
|
85
|
+
|
86
|
+
if(data['messages']) {
|
87
|
+
$(data['messages']).each(this.closure(function(i, m) { this.messageReceived(m) }));
|
88
|
+
this.openChannel();
|
89
|
+
} else {
|
90
|
+
console.error("Bad channel data: " + data)
|
91
|
+
}
|
92
|
+
},
|
93
|
+
|
94
|
+
reopenChannel: function() {
|
95
|
+
console.error("reopenChannel()");
|
96
|
+
var fails = this.channelOpenFails;
|
97
|
+
setTimeout(function() { Goat.openChannel(); }, (fails > 20) ? 20000 : (fails * 500));
|
98
|
+
this.channelOpenFails++;
|
99
|
+
},
|
100
|
+
|
101
|
+
openChannel: function() {
|
102
|
+
console.log("opening channel");
|
103
|
+
|
104
|
+
if(this.activeChannel) {
|
105
|
+
console.error("can't open channel: channel already open");
|
106
|
+
return;
|
107
|
+
}
|
108
|
+
|
109
|
+
this.activeChannel = $.ajax({
|
110
|
+
url: '/channel',
|
111
|
+
async: true,
|
112
|
+
data: {_id: Goat.page_id},
|
113
|
+
cache: false,
|
114
|
+
timeout: 1000000,
|
115
|
+
success: this.closure(function(data) {
|
116
|
+
this.activeChannel = null;
|
117
|
+
this.channelDataReceived(data);
|
118
|
+
}),
|
119
|
+
error: this.closure(function(req, status, err) {
|
120
|
+
this.activeChannel = null;
|
121
|
+
console.error("ajax channel error");
|
122
|
+
console.error({req: req, status: status, err: err});
|
123
|
+
this.reopenChannel();
|
124
|
+
})
|
125
|
+
});
|
126
|
+
},
|
127
|
+
|
128
|
+
submitForm: function(id) {
|
129
|
+
var data = {};
|
130
|
+
var appendValue = function(i, input){ data[input.name] = $(input).val(); };
|
131
|
+
$("#" + id + " input").each(appendValue);
|
132
|
+
$("#" + id + " textarea").each(appendValue);
|
133
|
+
data['_id'] = Goat.page_id;
|
134
|
+
$.ajax({
|
135
|
+
url: '/post',
|
136
|
+
async: true,
|
137
|
+
data: data,
|
138
|
+
cache: false,
|
139
|
+
success: function(data) {},
|
140
|
+
error: function(req, stat, err) {
|
141
|
+
console.log("submit form error");
|
142
|
+
console.log({req: req, status: stat, err: err});
|
143
|
+
}
|
144
|
+
});
|
145
|
+
|
146
|
+
return false;
|
147
|
+
},
|
148
|
+
|
149
|
+
remoteDispatch: function(c, k) {
|
150
|
+
$.ajax({
|
151
|
+
url: '/dispatch',
|
152
|
+
async: true,
|
153
|
+
data: {_id: Goat.page_id, _component: c, _dispatch: k},
|
154
|
+
success: function(data) {},
|
155
|
+
error: function(req, stat, err) {
|
156
|
+
console.log("remote dispatch error");
|
157
|
+
console.log({req: req, status: stat, err: err});
|
158
|
+
}
|
159
|
+
});
|
160
|
+
|
161
|
+
return false;
|
162
|
+
},
|
163
|
+
|
164
|
+
registerComponent: function(id) {
|
165
|
+
this.components[id] = id;
|
166
|
+
}
|
167
|
+
});
|
168
|
+
|
169
|
+
$(document).ready(function() {
|
170
|
+
setTimeout(function() { if(Goat.page_id) Goat.openChannel(); }, 1);
|
171
|
+
});
|
172
|
+
|
data/lib/goat/html.rb
ADDED
@@ -0,0 +1,147 @@
|
|
1
|
+
module Goat
|
2
|
+
class HTMLBuilder
|
3
|
+
class ::String
|
4
|
+
def to_html(builder, comp)
|
5
|
+
builder.string_to_html(self, comp)
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
class ::Array
|
10
|
+
def to_html(builder, comp)
|
11
|
+
raise InvalidBodyError.new(self) if self.include?(nil)
|
12
|
+
builder.array_to_html(self, comp)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
class InvalidBodyError < RuntimeError
|
17
|
+
attr_reader :body
|
18
|
+
|
19
|
+
def initialize(body)
|
20
|
+
super("Invalid body: #{body.inspect}")
|
21
|
+
@body = body
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
class TagBuilder
|
26
|
+
# TODO: gmail trick of only a single onclick() handler
|
27
|
+
|
28
|
+
def self.build(tag, attrs, body, comp)
|
29
|
+
self.new(tag, attrs, body, comp).dispatch
|
30
|
+
end
|
31
|
+
|
32
|
+
def initialize(tag, attrs, body, comp)
|
33
|
+
@tag = tag
|
34
|
+
@attrs = attrs
|
35
|
+
@body = body
|
36
|
+
@comp = comp
|
37
|
+
|
38
|
+
rewrite_attrs
|
39
|
+
end
|
40
|
+
|
41
|
+
def rewrite_attrs
|
42
|
+
new = {}
|
43
|
+
|
44
|
+
@attrs.map_to_hash do |k, v|
|
45
|
+
if k == :onclick && v.kind_of?(Hash)
|
46
|
+
raise "Invalid onclick" unless v.include?(:target) && v.include?(:selector)
|
47
|
+
target = v[:target]
|
48
|
+
sel = v[:selector]
|
49
|
+
|
50
|
+
key = @comp.register_handler(target, sel)
|
51
|
+
new[k] = "Goat.remoteDispatch('#{@comp.id}', '#{key}')"
|
52
|
+
new[:href] = "#"
|
53
|
+
elsif k == :class && v.kind_of?(Array)
|
54
|
+
new[:class] = @attrs[:class].join(' ')
|
55
|
+
else
|
56
|
+
new[k] = v
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
@attrs = new
|
61
|
+
end
|
62
|
+
|
63
|
+
def form_tag
|
64
|
+
target = @attrs.delete(:target)
|
65
|
+
[:form, @attrs, @body + [[:input, {:type => 'hidden', :name => '_component', :value => @comp.id}]]]
|
66
|
+
end
|
67
|
+
|
68
|
+
def a_tag
|
69
|
+
if action = @attrs.delete(:action)
|
70
|
+
# [:a, {}, 'dead']
|
71
|
+
# key = @comp.register_callback(action)
|
72
|
+
# @attrs[:onclick] = "Goat.remoteDispatch('#{@comp.id}', '#{key}')"
|
73
|
+
# @attrs[:href] = '#'
|
74
|
+
# [:a, @attrs, @body]
|
75
|
+
action[:target] ||= @comp
|
76
|
+
action[:args] ||= []
|
77
|
+
key = @comp.register_callback(ActionProc.new { action })
|
78
|
+
@attrs[:onclick] = "return Goat.remoteDispatch('#{@comp.id}', '#{key}')"
|
79
|
+
@attrs[:href] = '#'
|
80
|
+
[:a, @attrs, @body]
|
81
|
+
else
|
82
|
+
identity
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def identity
|
87
|
+
[@tag, @attrs, @body]
|
88
|
+
end
|
89
|
+
|
90
|
+
def dispatch
|
91
|
+
meth = "#{@tag}_tag".to_sym
|
92
|
+
|
93
|
+
if self.respond_to?(meth)
|
94
|
+
self.send(meth)
|
95
|
+
else
|
96
|
+
identity
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def standalone_tags
|
102
|
+
%w{br img input}
|
103
|
+
end
|
104
|
+
|
105
|
+
def inject_prefix(attrs, id)
|
106
|
+
attrs.map_to_hash {|k, v| [k, v.prefix_ns(id)]}
|
107
|
+
end
|
108
|
+
|
109
|
+
def attrs_to_html(attrs)
|
110
|
+
attrs.map {|k, v| "#{k}=\"#{v}\""}.join(' ')
|
111
|
+
end
|
112
|
+
|
113
|
+
def string_to_html(str, comp)
|
114
|
+
str
|
115
|
+
end
|
116
|
+
|
117
|
+
def array_to_html(ar, comp)
|
118
|
+
if ar.first.kind_of?(Symbol)
|
119
|
+
tag = ar[0]
|
120
|
+
have_attrs = ar[1].is_a?(Hash)
|
121
|
+
attrs = have_attrs ? ar[1] : {}
|
122
|
+
body = ar[(have_attrs ? 2 : 1)..-1]
|
123
|
+
|
124
|
+
tag, attrs, body = TagBuilder.build(tag, attrs, body, comp)
|
125
|
+
attrs = inject_prefix(attrs, comp.id)
|
126
|
+
lonely = standalone_tags.include?(tag.to_s)
|
127
|
+
|
128
|
+
open_tag = "<#{tag}#{attrs.empty? ? '' : (' ' + attrs_to_html(attrs))}>"
|
129
|
+
body_html = body.empty? ? '' : body.map{|x| x.to_html(self, comp)}.join
|
130
|
+
close_tag = (lonely ? '' : "</#{tag}>")
|
131
|
+
|
132
|
+
"#{open_tag}#{body_html}#{close_tag}"
|
133
|
+
else
|
134
|
+
ar.map{|x| x.to_html(self, comp)}.join
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def initialize(component, input)
|
139
|
+
@input = input
|
140
|
+
@component = component
|
141
|
+
end
|
142
|
+
|
143
|
+
def html
|
144
|
+
@input.to_html(self, @component)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
data/lib/goat/logger.rb
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
require 'term/ansicolor'
|
2
|
+
|
3
|
+
module Goat
|
4
|
+
class Logger
|
5
|
+
class << self
|
6
|
+
def init
|
7
|
+
@levels = [:error]
|
8
|
+
@categories = nil
|
9
|
+
end
|
10
|
+
|
11
|
+
def levels=(types)
|
12
|
+
@levels = types
|
13
|
+
end
|
14
|
+
|
15
|
+
def categories=(cats)
|
16
|
+
@categories = cats
|
17
|
+
end
|
18
|
+
|
19
|
+
def error(cat, str)
|
20
|
+
log(cat, str, :error)
|
21
|
+
end
|
22
|
+
|
23
|
+
def log(cat, str, level = :debug)
|
24
|
+
if @categories.kind_of?(Array) && level != :error
|
25
|
+
return unless @categories.include?(cat)
|
26
|
+
end
|
27
|
+
|
28
|
+
return unless @levels.include?(level)
|
29
|
+
|
30
|
+
levelstr = level.to_s.ljust(13)
|
31
|
+
colorlevel = level == :error ? Term::ANSIColor.red(levelstr) : levelstr
|
32
|
+
|
33
|
+
$stderr.puts "#{colorlevel} [#{Time.now.strftime("%d/%b/%Y %H:%M:%S")}] #{Term::ANSIColor.green(str)}"
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
init
|
38
|
+
end
|
39
|
+
end
|
data/lib/goat/mongo.rb
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
require 'mongo'
|
2
|
+
|
3
|
+
module Mongo
|
4
|
+
class Collection
|
5
|
+
alias_method :old_save, :save
|
6
|
+
alias_method :old_update, :update
|
7
|
+
|
8
|
+
def notify_update_or_save(more_info)
|
9
|
+
notif = more_info.merge(
|
10
|
+
'type' => 'mongo_change',
|
11
|
+
'db' => self.db.name,
|
12
|
+
'collection' => self.name
|
13
|
+
)
|
14
|
+
|
15
|
+
Goat::NotificationCenter.notify(notif)
|
16
|
+
end
|
17
|
+
|
18
|
+
def save(doc, opts={})
|
19
|
+
id = old_save(doc, opts)
|
20
|
+
notify_update_or_save('_id' => id)
|
21
|
+
end
|
22
|
+
|
23
|
+
def update(sel, doc, opts={})
|
24
|
+
old_update(sel, doc, opts)
|
25
|
+
notify_update_or_save(sel)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
require 'eventmachine'
|
2
|
+
|
3
|
+
require File.join(File.dirname(__FILE__), 'logger')
|
4
|
+
|
5
|
+
module Goat
|
6
|
+
module NotificationCenter
|
7
|
+
class Receiver < EM::Connection
|
8
|
+
include EM::P::LineText2
|
9
|
+
|
10
|
+
def self.start(host, port)
|
11
|
+
EM.connect(host, port, self)
|
12
|
+
rescue RuntimeError => e
|
13
|
+
Logger.error :live, "Couldn't connect to notification server at #{host}:#{port}"
|
14
|
+
raise e
|
15
|
+
end
|
16
|
+
|
17
|
+
def receive_line(line)
|
18
|
+
NotificationCenter.receive(line)
|
19
|
+
end
|
20
|
+
|
21
|
+
def unbind
|
22
|
+
Logger.error :live, "Lost connection"
|
23
|
+
EM.add_timer(1) do
|
24
|
+
NotificationCenter.start_receiver
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.init
|
30
|
+
unless @running
|
31
|
+
start if @configured
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.start_receiver
|
36
|
+
Receiver.start(@host, @recv_port)
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.start
|
40
|
+
start_receiver
|
41
|
+
@running = true
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.configure(opts={})
|
45
|
+
opts = {:host => '127.0.0.1', :recv_port => 8000, :send_port => 8001}.merge(opts)
|
46
|
+
@configured = true
|
47
|
+
@host = opts[:host]
|
48
|
+
@recv_port = opts[:recv_port]
|
49
|
+
@send_port = opts[:send_port]
|
50
|
+
|
51
|
+
@subscribers = Set.new
|
52
|
+
end
|
53
|
+
|
54
|
+
def self.wants_notification(sub, notif)
|
55
|
+
wants = true
|
56
|
+
sub[:sig].each do |k, v|
|
57
|
+
wants = false unless notif[k] == v
|
58
|
+
end
|
59
|
+
wants
|
60
|
+
end
|
61
|
+
|
62
|
+
def self.load_notif(notif)
|
63
|
+
JSON.load(notif)
|
64
|
+
end
|
65
|
+
|
66
|
+
def self.notif_to_json(notif)
|
67
|
+
notif.to_json
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.delegate_gc
|
71
|
+
kill_list = Set.new
|
72
|
+
Logger.log :gc, "Delegate gc"
|
73
|
+
@subscribers.each do |sub|
|
74
|
+
# $stderr.puts "Testing removal of #{sub.inspect}"
|
75
|
+
dlg = sub[:delegate]
|
76
|
+
# $stderr.puts "Responds to dead? #{dlg.respond_to?(:dead?)}"
|
77
|
+
if dlg.respond_to?(:dead?) && dlg.dead?
|
78
|
+
Logger.log :gc, "Dead"
|
79
|
+
kill_list << sub
|
80
|
+
next
|
81
|
+
end
|
82
|
+
end
|
83
|
+
# $stderr.puts "Removing #{kill_list.inspect}"
|
84
|
+
kill_list.each {|sub| @subscribers.delete(sub)}
|
85
|
+
# $stderr.puts "subscribers now: #{@subscribers.map{|x| x.inspect[0..50]}.inspect}"
|
86
|
+
end
|
87
|
+
|
88
|
+
def self.receive(line)
|
89
|
+
notif = load_notif(line)
|
90
|
+
Logger.log :live, "received notif #{notif.inspect}" if $verbose
|
91
|
+
|
92
|
+
delegate_gc
|
93
|
+
|
94
|
+
@subscribers.each do |sub|
|
95
|
+
blk = sub[:block]
|
96
|
+
if wants_notification(sub, notif)
|
97
|
+
blk.arity == 1 ? blk.call(notif) : blk.call
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def self.notify(notif)
|
103
|
+
if EM.reactor_running?
|
104
|
+
EM.connect(@host, @send_port) do |c|
|
105
|
+
# TODO: alert if this fails
|
106
|
+
c.send_data(notif_to_json(notif) + "\n")
|
107
|
+
# TODO: 2 secs right here?
|
108
|
+
EM.add_timer(2) { c.close_connection }
|
109
|
+
end
|
110
|
+
else
|
111
|
+
s = TCPSocket.open(@host, @send_port)
|
112
|
+
s.write(notif_to_json(notif) + "\n")
|
113
|
+
s.close
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def self.subscribe(obj, sig, &blk)
|
118
|
+
@subscribers << {:sig => sig, :block => blk, :delegate => obj}
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|