dripdrop 0.6.0 → 0.7.1

Sign up to get free protection for your applications and to get access to all the features.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.6.0
1
+ 0.7.1
data/dripdrop.gemspec CHANGED
@@ -5,11 +5,11 @@
5
5
 
6
6
  Gem::Specification.new do |s|
7
7
  s.name = %q{dripdrop}
8
- s.version = "0.6.0"
8
+ s.version = "0.7.1"
9
9
 
10
10
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
11
  s.authors = ["Andrew Cholakian"]
12
- s.date = %q{2010-12-17}
12
+ s.date = %q{2011-01-30}
13
13
  s.description = %q{Evented framework for ZeroMQ and EventMachine Apps. }
14
14
  s.email = %q{andrew@andrewvc.com}
15
15
  s.extra_rdoc_files = [
@@ -27,21 +27,14 @@ Gem::Specification.new do |s|
27
27
  "dripdrop.gemspec",
28
28
  "example/agent_test.rb",
29
29
  "example/combined.rb",
30
+ "example/complex/README",
31
+ "example/complex/client.rb",
32
+ "example/complex/server.rb",
33
+ "example/complex/service.rb",
34
+ "example/complex/websocket.rb",
30
35
  "example/http.rb",
31
36
  "example/pubsub.rb",
32
37
  "example/pushpull.rb",
33
- "example/stats_app/core.rb",
34
- "example/stats_app/public/.sass-cache/b48b4299d80c05f528daf63fe51d85e5e3c10d98/stats.scssc",
35
- "example/stats_app/public/backbone.js",
36
- "example/stats_app/public/build_templates.rb",
37
- "example/stats_app/public/json2.js",
38
- "example/stats_app/public/protovis-r3.2.js",
39
- "example/stats_app/public/stats.css",
40
- "example/stats_app/public/stats.haml",
41
- "example/stats_app/public/stats.html",
42
- "example/stats_app/public/stats.js",
43
- "example/stats_app/public/stats.scss",
44
- "example/stats_app/public/underscore.js",
45
38
  "example/subclass.rb",
46
39
  "example/xreq_xrep.rb",
47
40
  "js/dripdrop.html",
@@ -74,16 +67,16 @@ Gem::Specification.new do |s|
74
67
  s.rubygems_version = %q{1.3.7}
75
68
  s.summary = %q{Evented framework for ZeroMQ and EventMachine Apps.}
76
69
  s.test_files = [
77
- "spec/gimite-websocket.rb",
78
- "spec/message_spec.rb",
79
- "spec/node_spec.rb",
70
+ "spec/node_spec.rb",
80
71
  "spec/spec_helper.rb",
81
- "spec/node/http_spec.rb",
72
+ "spec/gimite-websocket.rb",
73
+ "spec/message_spec.rb",
82
74
  "spec/node/nodelet_spec.rb",
75
+ "spec/node/zmq_pushpull_spec.rb",
76
+ "spec/node/zmq_xrepxreq_spec.rb",
83
77
  "spec/node/routing_spec.rb",
84
78
  "spec/node/websocket_spec.rb",
85
- "spec/node/zmq_pushpull_spec.rb",
86
- "spec/node/zmq_xrepxreq_spec.rb"
79
+ "spec/node/http_spec.rb"
87
80
  ]
88
81
 
89
82
  if s.respond_to? :specification_version then
@@ -0,0 +1,22 @@
1
+ This example creates an async, evented app that can do the following.
2
+
3
+ * Broadcast messages to all connected websockets originating from a
4
+ master control server.
5
+ * Proxy requests sent via websocket to HTTP through a master
6
+ control server
7
+
8
+ It's broken into three parts
9
+ * service.rb: The async core, written in dripdrop/eventmachine/zeromq
10
+ * client.rb: A Test websocket client
11
+ * service.rb: A test HTTP web-service that could beb used to control messages
12
+
13
+ To run.
14
+
15
+ In one terminal (in dripdrop root)
16
+ ruby -I lib/ example/complex/server.rb
17
+
18
+ In another terminal (Websocket client)
19
+ cd example/complex && ruby client.rb
20
+
21
+ In a third terminal (Minimal webapp in sinatra)
22
+ cd example/complex && ruby service.rb
@@ -0,0 +1,20 @@
1
+ require 'rubygems'
2
+ require 'websocket'
3
+ require 'dripdrop/message'
4
+
5
+ Thread.abort_on_exception = true
6
+
7
+ client = WebSocket.new('ws://127.0.0.1:8080')
8
+
9
+ Thread.new do
10
+ while data = client.receive
11
+ puts data
12
+ end
13
+ end
14
+
15
+ i = 0
16
+ while sleep 1
17
+ i += 1
18
+ puts '.'
19
+ client.send(DripDrop::Message.new('Client Broadcast', :body => i).json_encoded)
20
+ end
@@ -0,0 +1,115 @@
1
+ require 'dripdrop'
2
+ Thread.abort_on_exception = true
3
+
4
+ class ComplexExample < DripDrop::Node
5
+ def initialize(mode=:all)
6
+ super()
7
+ @mode = mode
8
+ end
9
+
10
+ def action
11
+ if [:all, :websockets].include?(@mode)
12
+ route :ws_listener, :websocket, 'ws://127.0.0.1:8080'
13
+ route :broadcast_in, :zmq_subscribe, 'tcp://127.0.0.1:2200', :connect
14
+ route :reqs_out, :zmq_xreq, 'tcp://127.0.0.1:2201', :connect
15
+
16
+ WSListener.new(:ws => ws_listener, :broadcast_in => broadcast_in, :reqs_out => reqs_out).run
17
+ end
18
+
19
+ if [:all, :coordinator].include?(@mode)
20
+ route :broadcast_out, :zmq_publish, 'tcp://127.0.0.1:2200', :bind
21
+ route :reqs_in, :zmq_xrep, 'tcp://127.0.0.1:2201', :bind
22
+ route :reqs_htout, :http_client, 'tcp://127.0.0.1:3000/endpoint'
23
+
24
+ Coordinator.new(:broadcast_out => broadcast_out, :reqs_in => reqs_in, :reqs_htout => reqs_htout).run
25
+ end
26
+ end
27
+ end
28
+
29
+ class Coordinator
30
+ def initialize(opts={})
31
+ @bc_out = opts[:broadcast_out]
32
+ @reqs_in = opts[:reqs_in]
33
+ @reqs_htout = opts[:reqs_htout]
34
+ end
35
+
36
+ def run
37
+ proxy_reqs
38
+ heartbeat
39
+ end
40
+
41
+ def proxy_reqs
42
+ @reqs_in.on_recv do |message, response|
43
+ puts "Proxying #{message.inspect} to htout"
44
+ @reqs_htout.send_message(message) do |http_response|
45
+ puts "Received http response #{http_response.inspect} sending back"
46
+ response.send_message(http_response)
47
+ end
48
+ end
49
+ end
50
+
51
+ def heartbeat
52
+ EM::PeriodicTimer.new(1) do
53
+ @bc_out.send_message :name => 'tick', :body => Time.now.to_s
54
+ end
55
+ end
56
+ end
57
+
58
+ class WSListener
59
+ def initialize(opts={})
60
+ @ws = opts[:ws]
61
+ @bc_in = opts[:broadcast_in]
62
+ @reqs_out = opts[:reqs_out]
63
+ @client_channel = EM::Channel.new
64
+ end
65
+ def run
66
+ proxy_websockets
67
+ broadcast_to_websockets
68
+ end
69
+
70
+ def broadcast_to_websockets
71
+ # Receives messages from Broadcast Out
72
+ @bc_in.on_recv do |message|
73
+ puts "Broadcast In recv: #{message.inspect}"
74
+ @client_channel.push(message)
75
+ end
76
+ end
77
+
78
+ def proxy_websockets
79
+ ws = @ws
80
+
81
+ sigs_sids = {} #Map connection signatures to subscriber IDs
82
+
83
+ ws.on_open do |conn|
84
+ puts "WS Connected"
85
+ conn.send_message(DripDrop::Message.new('test'))
86
+
87
+ sid = @client_channel.subscribe do |message|
88
+ puts message.inspect
89
+ conn.send_message(message)
90
+ end
91
+
92
+ sigs_sids[conn.signature] = sid
93
+ end
94
+ ws.on_close do |conn|
95
+ puts "Closed #{conn.signature}"
96
+ @client_channel.unsubscribe sigs_sids[conn.signature]
97
+ end
98
+ ws.on_error do |reason,conn|
99
+ puts "Errored #{reason.inspect}, #{conn.signature}"
100
+ @client_channel.unsubscribe sigs_sids[conn.signature]
101
+ end
102
+
103
+ ws.on_recv do |message,conn|
104
+ puts "WS Recv #{message.name}"
105
+ @reqs_out.send_message(message) do |resp_message|
106
+ puts "Recvd resp_message #{resp_message.inspect}, sending back to client"
107
+ conn.send_message(resp_message)
108
+ end
109
+ end
110
+ end
111
+ end
112
+
113
+
114
+ puts "Starting..."
115
+ ComplexExample.new.start!
@@ -0,0 +1,8 @@
1
+ require 'rubygems'
2
+ require 'sinatra'
3
+ require 'dripdrop/message'
4
+
5
+ post '/endpoint' do
6
+ puts DripDrop::Message.decode_json(request.body.read).inspect
7
+ DripDrop::Message.new('ack').json_encoded
8
+ end
@@ -0,0 +1,442 @@
1
+ # Copyright: Hiroshi Ichikawa <http://gimite.net/en/>
2
+ # Lincense: New BSD Lincense
3
+ # Reference: http://tools.ietf.org/html/draft-hixie-thewebsocketprotocol
4
+
5
+ require "socket"
6
+ require "uri"
7
+ require "digest/md5"
8
+ require "openssl"
9
+
10
+
11
+ class WebSocket
12
+
13
+ class << self
14
+
15
+ attr_accessor(:debug)
16
+
17
+ end
18
+
19
+ class Error < RuntimeError
20
+
21
+ end
22
+
23
+ def initialize(arg, params = {})
24
+ if params[:server] # server
25
+
26
+ @server = params[:server]
27
+ @socket = arg
28
+ line = gets().chomp()
29
+ if !(line =~ /\AGET (\S+) HTTP\/1.1\z/n)
30
+ raise(WebSocket::Error, "invalid request: #{line}")
31
+ end
32
+ @path = $1
33
+ read_header()
34
+ if @header["sec-websocket-key1"] && @header["sec-websocket-key2"]
35
+ @key3 = read(8)
36
+ else
37
+ # Old Draft 75 protocol
38
+ @key3 = nil
39
+ end
40
+ if !@server.accepted_origin?(self.origin)
41
+ raise(WebSocket::Error,
42
+ ("Unaccepted origin: %s (server.accepted_domains = %p)\n\n" +
43
+ "To accept this origin, write e.g. \n" +
44
+ " WebSocketServer.new(..., :accepted_domains => [%p]), or\n" +
45
+ " WebSocketServer.new(..., :accepted_domains => [\"*\"])\n") %
46
+ [self.origin, @server.accepted_domains, @server.origin_to_domain(self.origin)])
47
+ end
48
+ @handshaked = false
49
+
50
+ else # client
51
+
52
+ uri = arg.is_a?(String) ? URI.parse(arg) : arg
53
+
54
+ if uri.scheme == "ws"
55
+ default_port = 80
56
+ elsif uri.scheme = "wss"
57
+ default_port = 443
58
+ else
59
+ raise(WebSocket::Error, "unsupported scheme: #{uri.scheme}")
60
+ end
61
+
62
+ @path = (uri.path.empty? ? "/" : uri.path) + (uri.query ? "?" + uri.query : "")
63
+ host = uri.host + (uri.port == default_port ? "" : ":#{uri.port}")
64
+ origin = params[:origin] || "http://#{uri.host}"
65
+ key1 = generate_key()
66
+ key2 = generate_key()
67
+ key3 = generate_key3()
68
+
69
+ socket = TCPSocket.new(uri.host, uri.port || default_port)
70
+
71
+ if uri.scheme == "ws"
72
+ @socket = socket
73
+ else
74
+ @socket = ssl_handshake(socket)
75
+ end
76
+
77
+ write(
78
+ "GET #{@path} HTTP/1.1\r\n" +
79
+ "Upgrade: WebSocket\r\n" +
80
+ "Connection: Upgrade\r\n" +
81
+ "Host: #{host}\r\n" +
82
+ "Origin: #{origin}\r\n" +
83
+ "Sec-WebSocket-Key1: #{key1}\r\n" +
84
+ "Sec-WebSocket-Key2: #{key2}\r\n" +
85
+ "\r\n" +
86
+ "#{key3}")
87
+ flush()
88
+
89
+ line = gets().chomp()
90
+ raise(WebSocket::Error, "bad response: #{line}") if !(line =~ /\AHTTP\/1.1 101 /n)
91
+ read_header()
92
+ if (@header["sec-websocket-origin"] || "").downcase() != origin.downcase()
93
+ raise(WebSocket::Error,
94
+ "origin doesn't match: '#{@header["sec-websocket-origin"]}' != '#{origin}'")
95
+ end
96
+ reply_digest = read(16)
97
+ expected_digest = security_digest(key1, key2, key3)
98
+ if reply_digest != expected_digest
99
+ raise(WebSocket::Error,
100
+ "security digest doesn't match: %p != %p" % [reply_digest, expected_digest])
101
+ end
102
+ @handshaked = true
103
+
104
+ end
105
+ @received = []
106
+ @buffer = ""
107
+ @closing_started = false
108
+ end
109
+
110
+ attr_reader(:server, :header, :path)
111
+
112
+ def handshake(status = nil, header = {})
113
+ if @handshaked
114
+ raise(WebSocket::Error, "handshake has already been done")
115
+ end
116
+ status ||= "101 Web Socket Protocol Handshake"
117
+ sec_prefix = @key3 ? "Sec-" : ""
118
+ def_header = {
119
+ "#{sec_prefix}WebSocket-Origin" => self.origin,
120
+ "#{sec_prefix}WebSocket-Location" => self.location,
121
+ }
122
+ header = def_header.merge(header)
123
+ header_str = header.map(){ |k, v| "#{k}: #{v}\r\n" }.join("")
124
+ if @key3
125
+ digest = security_digest(
126
+ @header["Sec-WebSocket-Key1"], @header["Sec-WebSocket-Key2"], @key3)
127
+ else
128
+ digest = ""
129
+ end
130
+ # Note that Upgrade and Connection must appear in this order.
131
+ write(
132
+ "HTTP/1.1 #{status}\r\n" +
133
+ "Upgrade: WebSocket\r\n" +
134
+ "Connection: Upgrade\r\n" +
135
+ "#{header_str}\r\n#{digest}")
136
+ flush()
137
+ @handshaked = true
138
+ end
139
+
140
+ def send(data)
141
+ if !@handshaked
142
+ raise(WebSocket::Error, "call WebSocket\#handshake first")
143
+ end
144
+ data = force_encoding(data.dup(), "ASCII-8BIT")
145
+ write("\x00#{data}\xff")
146
+ flush()
147
+ end
148
+
149
+ def receive()
150
+ if !@handshaked
151
+ raise(WebSocket::Error, "call WebSocket\#handshake first")
152
+ end
153
+ packet = gets("\xff")
154
+ return nil if !packet
155
+ if packet =~ /\A\x00(.*)\xff\z/nm
156
+ return force_encoding($1, "UTF-8")
157
+ elsif packet == "\xff" && read(1) == "\x00" # closing
158
+ if @server
159
+ @socket.close()
160
+ else
161
+ close()
162
+ end
163
+ return nil
164
+ else
165
+ raise(WebSocket::Error, "input must be either '\\x00...\\xff' or '\\xff\\x00'")
166
+ end
167
+ end
168
+
169
+ def tcp_socket
170
+ return @socket
171
+ end
172
+
173
+ def host
174
+ return @header["host"]
175
+ end
176
+
177
+ def origin
178
+ return @header["origin"]
179
+ end
180
+
181
+ def location
182
+ return "ws://#{self.host}#{@path}"
183
+ end
184
+
185
+ # Does closing handshake.
186
+ def close()
187
+ return if @closing_started
188
+ write("\xff\x00")
189
+ @socket.close() if !@server
190
+ @closing_started = true
191
+ end
192
+
193
+ def close_socket()
194
+ @socket.close()
195
+ end
196
+
197
+ private
198
+
199
+ NOISE_CHARS = ("\x21".."\x2f").to_a() + ("\x3a".."\x7e").to_a()
200
+
201
+ def read_header()
202
+ @header = {}
203
+ while line = gets()
204
+ line = line.chomp()
205
+ break if line.empty?
206
+ if !(line =~ /\A(\S+): (.*)\z/n)
207
+ raise(WebSocket::Error, "invalid request: #{line}")
208
+ end
209
+ @header[$1] = $2
210
+ @header[$1.downcase()] = $2
211
+ end
212
+ if !(@header["upgrade"] =~ /\AWebSocket\z/i)
213
+ raise(WebSocket::Error, "invalid Upgrade: " + @header["upgrade"])
214
+ end
215
+ if !(@header["connection"] =~ /\AUpgrade\z/i)
216
+ raise(WebSocket::Error, "invalid Connection: " + @header["connection"])
217
+ end
218
+ end
219
+
220
+ def gets(rs = $/)
221
+ line = @socket.gets(rs)
222
+ $stderr.printf("recv> %p\n", line) if WebSocket.debug
223
+ return line
224
+ end
225
+
226
+ def read(num_bytes)
227
+ str = @socket.read(num_bytes)
228
+ $stderr.printf("recv> %p\n", str) if WebSocket.debug
229
+ return str
230
+ end
231
+
232
+ def write(data)
233
+ if WebSocket.debug
234
+ data.scan(/\G(.*?(\n|\z))/n) do
235
+ $stderr.printf("send> %p\n", $&) if !$&.empty?
236
+ end
237
+ end
238
+ @socket.write(data)
239
+ end
240
+
241
+ def flush()
242
+ @socket.flush()
243
+ end
244
+
245
+ def security_digest(key1, key2, key3)
246
+ bytes1 = websocket_key_to_bytes(key1)
247
+ bytes2 = websocket_key_to_bytes(key2)
248
+ return Digest::MD5.digest(bytes1 + bytes2 + key3)
249
+ end
250
+
251
+ def generate_key()
252
+ spaces = 1 + rand(12)
253
+ max = 0xffffffff / spaces
254
+ number = rand(max + 1)
255
+ key = (number * spaces).to_s()
256
+ (1 + rand(12)).times() do
257
+ char = NOISE_CHARS[rand(NOISE_CHARS.size)]
258
+ pos = rand(key.size + 1)
259
+ key[pos...pos] = char
260
+ end
261
+ spaces.times() do
262
+ pos = 1 + rand(key.size - 1)
263
+ key[pos...pos] = " "
264
+ end
265
+ return key
266
+ end
267
+
268
+ def generate_key3()
269
+ return [rand(0x100000000)].pack("N") + [rand(0x100000000)].pack("N")
270
+ end
271
+
272
+ def websocket_key_to_bytes(key)
273
+ num = key.gsub(/[^\d]/n, "").to_i() / key.scan(/ /).size
274
+ return [num].pack("N")
275
+ end
276
+
277
+ def force_encoding(str, encoding)
278
+ if str.respond_to?(:force_encoding)
279
+ return str.force_encoding(encoding)
280
+ else
281
+ return str
282
+ end
283
+ end
284
+
285
+ def ssl_handshake(socket)
286
+ ssl_context = OpenSSL::SSL::SSLContext.new()
287
+ ssl_socket = OpenSSL::SSL::SSLSocket.new(socket, ssl_context)
288
+ ssl_socket.sync_close = true
289
+ ssl_socket.connect()
290
+ return ssl_socket
291
+ end
292
+
293
+ end
294
+
295
+
296
+ class WebSocketServer
297
+
298
+ def initialize(params_or_uri, params = nil)
299
+ if params
300
+ uri = params_or_uri.is_a?(String) ? URI.parse(params_or_uri) : params_or_uri
301
+ params[:port] ||= uri.port
302
+ params[:accepted_domains] ||= [uri.host]
303
+ else
304
+ params = params_or_uri
305
+ end
306
+ @port = params[:port] || 80
307
+ @accepted_domains = params[:accepted_domains]
308
+ if !@accepted_domains
309
+ raise(ArgumentError, "params[:accepted_domains] is required")
310
+ end
311
+ if params[:host]
312
+ @tcp_server = TCPServer.open(params[:host], @port)
313
+ else
314
+ @tcp_server = TCPServer.open(@port)
315
+ end
316
+ end
317
+
318
+ attr_reader(:tcp_server, :port, :accepted_domains)
319
+
320
+ def run(&block)
321
+ while true
322
+ Thread.start(accept()) do |s|
323
+ begin
324
+ ws = create_web_socket(s)
325
+ yield(ws) if ws
326
+ rescue => ex
327
+ print_backtrace(ex)
328
+ ensure
329
+ begin
330
+ ws.close_socket() if ws
331
+ rescue
332
+ end
333
+ end
334
+ end
335
+ end
336
+ end
337
+
338
+ def accept()
339
+ return @tcp_server.accept()
340
+ end
341
+
342
+ def accepted_origin?(origin)
343
+ domain = origin_to_domain(origin)
344
+ return @accepted_domains.any?(){ |d| File.fnmatch(d, domain) }
345
+ end
346
+
347
+ def origin_to_domain(origin)
348
+ if origin == "null" || origin == "file://" # local file
349
+ return "null"
350
+ else
351
+ return URI.parse(origin).host
352
+ end
353
+ end
354
+
355
+ def create_web_socket(socket)
356
+ ch = socket.getc()
357
+ if ch == ?<
358
+ # This is Flash socket policy file request, not an actual Web Socket connection.
359
+ send_flash_socket_policy_file(socket)
360
+ return nil
361
+ else
362
+ socket.ungetc(ch)
363
+ return WebSocket.new(socket, :server => self)
364
+ end
365
+ end
366
+
367
+ private
368
+
369
+ def print_backtrace(ex)
370
+ $stderr.printf("%s: %s (%p)\n", ex.backtrace[0], ex.message, ex.class)
371
+ for s in ex.backtrace[1..-1]
372
+ $stderr.printf(" %s\n", s)
373
+ end
374
+ end
375
+
376
+ # Handles Flash socket policy file request sent when web-socket-js is used:
377
+ # http://github.com/gimite/web-socket-js/tree/master
378
+ def send_flash_socket_policy_file(socket)
379
+ socket.puts('<?xml version="1.0"?>')
380
+ socket.puts('<!DOCTYPE cross-domain-policy SYSTEM ' +
381
+ '"http://www.macromedia.com/xml/dtds/cross-domain-policy.dtd">')
382
+ socket.puts('<cross-domain-policy>')
383
+ for domain in @accepted_domains
384
+ next if domain == "file://"
385
+ socket.puts("<allow-access-from domain=\"#{domain}\" to-ports=\"#{@port}\"/>")
386
+ end
387
+ socket.puts('</cross-domain-policy>')
388
+ socket.close()
389
+ end
390
+
391
+ end
392
+
393
+
394
+ if __FILE__ == $0
395
+ Thread.abort_on_exception = true
396
+
397
+ if ARGV[0] == "server" && ARGV.size == 3
398
+
399
+ server = WebSocketServer.new(
400
+ :accepted_domains => [ARGV[1]],
401
+ :port => ARGV[2].to_i())
402
+ puts("Server is running at port %d" % server.port)
403
+ server.run() do |ws|
404
+ puts("Connection accepted")
405
+ puts("Path: #{ws.path}, Origin: #{ws.origin}")
406
+ if ws.path == "/"
407
+ ws.handshake()
408
+ while data = ws.receive()
409
+ printf("Received: %p\n", data)
410
+ ws.send(data)
411
+ printf("Sent: %p\n", data)
412
+ end
413
+ else
414
+ ws.handshake("404 Not Found")
415
+ end
416
+ puts("Connection closed")
417
+ end
418
+
419
+ elsif ARGV[0] == "client" && ARGV.size == 2
420
+
421
+ client = WebSocket.new(ARGV[1])
422
+ puts("Connected")
423
+ Thread.new() do
424
+ while data = client.receive()
425
+ printf("Received: %p\n", data)
426
+ end
427
+ end
428
+ $stdin.each_line() do |line|
429
+ data = line.chomp()
430
+ client.send(data)
431
+ printf("Sent: %p\n", data)
432
+ end
433
+
434
+ else
435
+
436
+ $stderr.puts("Usage:")
437
+ $stderr.puts(" ruby web_socket.rb server ACCEPTED_DOMAIN PORT")
438
+ $stderr.puts(" ruby web_socket.rb client ws://HOST:PORT/")
439
+ exit(1)
440
+
441
+ end
442
+ end