spyder 0.1.2 → 0.2.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3417dd82aa219dd2526c0fb01be1f6e9c58723651bac784b2f082a4c8d3b5f20
4
- data.tar.gz: eca6629c49c0048f7304da5c71f8701d38b1a24ef4a160f923bf5267d0cea628
3
+ metadata.gz: b5ee9dba2fca697adc1456d6473a6a1f7e011108ce4d8baf0f7659c9886b85c0
4
+ data.tar.gz: 719a5fe8d53574d442ddec5cbb666db6018526901be76ebf7ac665451da3c512
5
5
  SHA512:
6
- metadata.gz: d97926b4bc3a3064d7e6224c0bbb61ec2a225207f766802be5821db8d9b092eba60e92aec37b5012843e073999aa057c79e946551503e680dcf7c2e327013f32
7
- data.tar.gz: 51a1d5d5a6b4e8d7ba2598ddcf674e1acb224e35fa77a2c53f90a97f936709bc52274f9f92cef492d93f96ef3095758a7e946e63cc2db7720859a4c654096509
6
+ metadata.gz: 90c3f303a6d4c12abf9250ad13caba9cdf06632cfc4a68a317c7a8ccac47b2aa4c568c315581a03a353d9d0b15317a4dbe1e245ad9a1e70335a2406cb9f30ba3
7
+ data.tar.gz: 471ab8a863891a2b27f236bb0cea65028e37e62ac12a387daa81a302d58adf5cf7f81c236b8d4953e9e15815a3b56c86a15bf497f7c207a1ac4c8fecfe2a1447
@@ -4,6 +4,7 @@ module Spyder
4
4
  class Response
5
5
  attr_reader :code
6
6
  attr_accessor :body
7
+ attr_accessor :hijack
7
8
  attr_reader :headers
8
9
  attr_writer :reason_sentence
9
10
 
@@ -60,8 +61,10 @@ module Spyder
60
61
  ]
61
62
  end.freeze
62
63
 
63
- def initialize
64
- self.code = :ok
64
+ def initialize(code: :ok)
65
+ @body = nil
66
+ @hijack = false
67
+ self.code = code
65
68
  @headers = HeaderStore.new(:response)
66
69
  end
67
70
 
data/lib/spyder/router.rb CHANGED
@@ -2,10 +2,11 @@
2
2
 
3
3
  module Spyder
4
4
  class Router
5
- attr_accessor :fallback_route
5
+ attr_accessor :fallback_stack
6
6
 
7
7
  def initialize
8
8
  @routes = Hash.new { |k, v| k[v] = [] }
9
+ @fallback_stack = []
9
10
  end
10
11
 
11
12
  def add_route(verb, matcher, &handler)
@@ -14,6 +15,12 @@ module Spyder
14
15
  @routes[verb.to_s.upcase] << [matcher, handler]
15
16
  end
16
17
 
18
+ def add_fallback(callable = nil, &blk)
19
+ raise "Provide either a callable or a block, but not both" if callable && block_given?
20
+
21
+ @fallback_stack << (block_given? ? blk : callable)
22
+ end
23
+
17
24
  def call(ctx, request)
18
25
  only_path = request.path_info
19
26
 
@@ -26,7 +33,16 @@ module Spyder
26
33
  end
27
34
  end
28
35
 
29
- match_data ? handler.call(request, match_data) : Response.make_generic(:not_found)
36
+ response = match_data ? handler.call(request, match_data) : nil
37
+ return response if response.is_a?(Response)
38
+
39
+ fallback_stack.each do |blk|
40
+ response = blk.call(request)
41
+
42
+ return response if response.is_a?(Response)
43
+ end
44
+
45
+ Response.make_generic(:not_found)
30
46
  end
31
47
  end
32
48
 
data/lib/spyder/server.rb CHANGED
@@ -14,7 +14,7 @@ module Spyder
14
14
  @router = router
15
15
  end
16
16
 
17
- def add_middleware(callable, args)
17
+ def add_middleware(callable, args=[])
18
18
  @middleware << [callable, args]
19
19
  end
20
20
 
@@ -40,9 +40,9 @@ module Spyder
40
40
 
41
41
  Thread.new do
42
42
  begin
43
- error = nil
43
+ error, response = nil
44
44
  begin
45
- process_new_client(client)
45
+ response = process_new_client(client)
46
46
  rescue Exception => e
47
47
  error = e
48
48
  end
@@ -54,7 +54,11 @@ module Spyder
54
54
  dispatch_response(client, response)
55
55
  end
56
56
 
57
- client.close rescue nil
57
+ if response&.hijack
58
+ response.hijack.call(client)
59
+ else
60
+ client.close rescue nil
61
+ end
58
62
  ensure
59
63
  @tp_sync.synchronize { busy_threads -= 1 }
60
64
  end
@@ -99,15 +103,16 @@ module Spyder
99
103
  content_length = response.headers.dict['content-length']
100
104
  if !content_length && response.body && response.body.is_a?(String)
101
105
  content_length = response.body.length
106
+ response.set_header 'content-length', content_length.to_s
102
107
  end
103
108
 
109
+ response.set_header('connection', 'close') unless response.headers.dict['connection']
110
+
104
111
  begin
105
112
  socket.write("HTTP/1.1 #{response.code} #{response.reason_sentence.b}\r\n")
106
113
  response.headers.ordered.each do |name, value|
107
114
  socket.write("#{name.b}: #{value.b}\r\n")
108
115
  end
109
- socket.write("connection: close\r\n") # FIXME:
110
- socket.write("content-length: #{content_length}\r\n") if content_length
111
116
  socket.write("\r\n")
112
117
 
113
118
  if response.body
@@ -120,6 +125,8 @@ module Spyder
120
125
  # socket closed. So what?
121
126
  socket.close rescue nil
122
127
  end
128
+
129
+ response
123
130
  end
124
131
 
125
132
  def read_line(socket)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Spyder
4
- VERSION = '0.1.2'
4
+ VERSION = '0.2.0'
5
5
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spyder
4
+ module Web
5
+ class FileServer
6
+ attr_reader :base_paths
7
+ attr_reader :default_index
8
+
9
+ def initialize(paths, index: nil)
10
+ @default_index = index
11
+ @base_paths = Array(paths).map do |path|
12
+ File.expand_path(File.join(Dir.pwd, path))
13
+ end
14
+ end
15
+
16
+ def call(request)
17
+ return unless request.verb == 'GET'
18
+
19
+ input_path = request.path
20
+ input_path = @default_index if request.path == '/' && @default_index
21
+
22
+ req_path = safe_request_path(input_path)
23
+ full_path = nil
24
+ return unless @base_paths.any? do |base|
25
+ fp = File.join(base, *req_path)
26
+ next unless File.file?(fp)
27
+ full_path = fp
28
+
29
+ true
30
+ end
31
+
32
+ st = File.readable?(full_path) ? File.stat(full_path) : nil
33
+
34
+ return unless st && %w[file link].include?(st.ftype)
35
+
36
+ etag = "\"#{"%xT-%x0" % [st.mtime, st.size]}\""
37
+
38
+ resp = serve_not_modified_response(request, st, etag)
39
+ return resp if resp
40
+
41
+ resp = Spyder::Response.new
42
+ resp.add_standard_headers
43
+ resp.set_header 'last-modified', st.mtime.httpdate
44
+ resp.set_header 'etag', etag
45
+ resp.set_header 'cache-control', 'public, must-revalidate, max-age=0'
46
+
47
+ File.open full_path do |fp|
48
+ mime = Marcel::MimeType.for(fp)
49
+ if mime == 'application/octet-stream' || mime == 'text/plain'
50
+ mime = Marcel::MimeType.for(name: req_path.last)
51
+ end
52
+
53
+ resp.set_header('content-type', mime) if mime
54
+
55
+ fp.rewind
56
+ resp.body = fp.read
57
+ end
58
+
59
+ resp
60
+ end
61
+
62
+ private
63
+
64
+ def serve_not_modified_response(request, st, etag)
65
+ if_etag = request.headers.dict['if-none-match']
66
+ if_modified_since = request.headers.dict['if-modified-since']
67
+
68
+ return unless if_etag || if_modified_since
69
+
70
+ resp = Spyder::Response.new
71
+ resp.add_standard_headers
72
+ resp.code = 304
73
+
74
+ return resp if if_etag && if_etag == etag
75
+
76
+ if_ms = if_modified_since ? Time.parse(if_modified_since) : nil
77
+
78
+ (if_ms && Time.at(st.mtime.to_i) <= if_ms) ? resp : nil
79
+ end
80
+
81
+ def safe_request_path(path)
82
+ path.split('/').reject do |component|
83
+ component == '..' || component == '' || component == '.'
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spyder
4
+ class WebSocket
5
+ WS_CONST = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
6
+
7
+ class Frame
8
+ def initialize
9
+ end
10
+
11
+ def decode(raw_data)
12
+ fin = raw_data[0] & 1
13
+ rsv = (raw_data[0] & 0b0111) >> 1
14
+ end
15
+ end
16
+
17
+ def initialize
18
+ @socket = nil
19
+ @on_start = nil
20
+ @on_message = nil
21
+ @on_close = nil
22
+
23
+ @streaming_buffer = WebSocketStreamingBuffer.new do |frame, mode, fragmented, last_fragment|
24
+ @on_message.call(frame, mode) unless fragmented
25
+ end
26
+ @streaming_buffer.on_close = proc do
27
+ @socket.close rescue nil
28
+ end
29
+ end
30
+
31
+ def send_text(data)
32
+ send_data(data, :text)
33
+ end
34
+
35
+ def send_binary(data)
36
+ send_data(data, :binary)
37
+ end
38
+
39
+ def send_data(data, mode)
40
+ data = data.b
41
+
42
+ length = data.length
43
+ buffer = String.new(encoding: 'ascii-8bit', capacity: length + 32)
44
+ buffer += ((1 << 7) | (mode == :binary ? 2 : 1)).chr
45
+ if length < 126
46
+ buffer += length.chr
47
+ elsif length <= 0xFFFF
48
+ buffer += [126, length].pack('CS>')
49
+ else
50
+ buffer += [127, length].pack('CQ>')
51
+ end
52
+
53
+ @socket.write(buffer)
54
+ @socket.write(data)
55
+ end
56
+
57
+ def on_close(&blk)
58
+ @on_close = blk
59
+ end
60
+
61
+ def on_message(&blk)
62
+ @on_message = blk
63
+ end
64
+
65
+ def on_start(&blk)
66
+ @on_start = blk
67
+ end
68
+
69
+ def hijacked!(socket)
70
+ @socket = socket
71
+ puts "websocket hijacked! #{socket}"
72
+ @thread = Thread.start do
73
+ self.threaded_start!
74
+ end
75
+ end
76
+
77
+ def self.upgrade_websocket_request(request)
78
+ ws_key = request.headers.dict['sec-websocket-key']
79
+ return unless ws_key && request.headers.dict['upgrade'] == 'websocket'
80
+
81
+ conns = request.headers.dict.fetch('connection', '').split(' ').map(&:strip)
82
+ return unless conns.include?('Upgrade')
83
+
84
+ ws_version = request.headers.dict['sec-websocket-version'] # expect: 13
85
+ return unless ws_version
86
+
87
+ protocols = request.headers.dict['sec-websocket-protocol']
88
+ protocols = protocols.split(',').map(&:strip) if protocols
89
+
90
+ extensions = request.headers.dict['sec-websocket-extensions']
91
+ extensions = extensions.split(';').map(&:strip) if extensions
92
+
93
+ decoded_key = Base64.strict_decode64(ws_key) rescue nil
94
+ return unless decoded_key&.length == 16
95
+
96
+ response_key = Base64.strict_encode64(
97
+ OpenSSL::Digest::SHA1.digest("#{ws_key}#{WS_CONST}")
98
+ )
99
+
100
+ ws = new
101
+
102
+ chosen_proto = yield(ws, protocols) if (block_given? && protocols)
103
+ chosen_proto = protocols.first if !chosen_proto && protocols
104
+
105
+ resp = Spyder::Response.new(code: 101)
106
+ resp.add_standard_headers
107
+ resp.set_header 'connection', 'Upgrade'
108
+ resp.set_header 'upgrade', 'websocket'
109
+ resp.set_header('sec-websocket-protocol', chosen_proto) if chosen_proto
110
+ resp.set_header 'sec-websocket-accept', response_key
111
+
112
+ resp.hijack = proc { |client_socket| ws.hijacked!(client_socket) }
113
+
114
+ [resp, ws]
115
+ end
116
+
117
+ def threaded_start!
118
+ @on_start.call if @on_start
119
+
120
+ while !@socket.closed? && !@socket.eof?
121
+ data = nil
122
+ begin
123
+ data = @socket.read_nonblock(16 * 1024)
124
+ rescue IO::WaitReadable
125
+ IO.select([@socket], [], [@socket])
126
+ end
127
+
128
+ next unless data
129
+
130
+ puts "ws: read #{data.length} bytes"
131
+ @streaming_buffer.feed(data)
132
+ end
133
+
134
+ @on_close.call unless @on_close
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spyder
4
+ class WebSocketStreamingBuffer
5
+ attr_reader :buffer
6
+ attr_accessor :on_close
7
+
8
+ def initialize(&frame_callback)
9
+ @frame_callback = frame_callback
10
+ @on_close = nil
11
+ @buffer = []
12
+ _reset
13
+ end
14
+
15
+ def feed(buf)
16
+ @buffer += buf.bytes
17
+
18
+ # printable = buf.bytes.map{ |x| x.to_s(16).rjust(2, '0').upcase }.join(' ')
19
+ # puts "Got #{buf.bytes.length} more bytes: [#{printable}]"
20
+
21
+ iterations = 0
22
+ loop do
23
+ _flush
24
+
25
+ iterations += 1
26
+ if iterations > 1_000
27
+ raise "Watchdog error: got stuck on a loop of #{iterations} iterations."
28
+ end
29
+
30
+ break if (@buffer.length == 0 && !@need_buffer_length) ||
31
+ (@need_buffer_length && @buffer.length < @need_buffer_length)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def _reset
38
+ @state = :initial
39
+ @mask = nil
40
+ @opcode = nil
41
+ @data_mode = nil
42
+ @payload_length = nil
43
+ @need_buffer_length = nil
44
+ @flag_fin = nil
45
+ @fragmented = false
46
+ @fragmented_total_size = nil
47
+ end
48
+
49
+ def _flush
50
+ send(:"_flush_#{@state}")
51
+ end
52
+
53
+ def _flush_data
54
+ return unless @buffer.length >= @payload_length
55
+
56
+ data = if @mask
57
+ (0...@payload_length).map do |i|
58
+ @buffer[i] ^ @mask[i & 3]
59
+ end
60
+ else
61
+ @buffer[0...@payload_length]
62
+ end
63
+
64
+ last_fragment = @fragmented && @flag_fin
65
+ @frame_callback.call(data, @data_mode, @fragmented, last_fragment)
66
+
67
+ @buffer.shift(@payload_length)
68
+
69
+ if @fragmented && !last_fragment
70
+ @need_buffer_length = 2
71
+ @opcode = nil
72
+ @payload_length = nil
73
+ @state = "initial"
74
+ else
75
+ _reset
76
+ end
77
+ end
78
+
79
+ def close_with_error(msg)
80
+ @socket.close rescue nil
81
+
82
+ puts "Closing websocket: #{msg}"
83
+
84
+ @on_close.call if @on_close
85
+
86
+ _reset
87
+
88
+ @state = :closed
89
+ @socket = nil
90
+ @buffer = nil
91
+ end
92
+
93
+ def _flush_readhdr
94
+ return unless @buffer.length >= @need_buffer_length
95
+
96
+ @flag_fin = ((@buffer[0] & 0x80) != 0)
97
+ @opcode = (@buffer[0] & 0xF)
98
+ data_len = (@buffer[1] & 0x7F)
99
+ mask_flag = ((@buffer[1] & 0x80) != 0)
100
+
101
+ is_control_frame = ((@opcode & 8) != 0)
102
+ if is_control_frame && (data_len > 125 || !@flag_fin)
103
+ # Control frames must not be fragmented and must be <= 125 bytes in size
104
+ return close_with_error("Control frame invalid state")
105
+ end
106
+
107
+ if @fragmented && !is_control_frame && @opcode != 0
108
+ # We're in the middle of a fragmented frame and received a non-control
109
+ # frame. That's an error.
110
+ return close_with_error("Got non-control frame during fragmented frame.")
111
+ end
112
+
113
+ if !@fragmented && !@flag_fin && !is_control_frame
114
+ @fragmented = true
115
+ @fragmented_total_size = 0
116
+ end
117
+
118
+ shift_length = 2
119
+
120
+ @payload_length = if data_len == 127
121
+ shift_length += 8
122
+ @buffer[2...10].pack('C*').unpack('Q>').first
123
+ elsif data_len == 126
124
+ shift_length += 2
125
+ @buffer[2...4].pack('C*').unpack('S>').first
126
+ else
127
+ shift_length += 0
128
+ data_len
129
+ end
130
+
131
+ if mask_flag
132
+ @mask = @buffer[shift_length...(shift_length + 4)].pack('C*').bytes
133
+ shift_length += 4
134
+ else
135
+ @mask = nil
136
+ end
137
+
138
+ @fragmented_total_size += @payload_length if @fragmented
139
+
140
+ fgr = nil
141
+ fgr = ", fgr=#{@fragmented_total_size}" if @fragmented
142
+ puts "frame(code=#{@opcode}, len=#{@payload_length}, fin=#{@flag_fin}#{fgr})"
143
+
144
+ @buffer.shift(shift_length)
145
+ @need_buffer_length = ((0..2) === @opcode ? @payload_length : nil)
146
+
147
+ case @opcode
148
+ when 0x0 # continuation
149
+ @state = "data"
150
+ when 0x1 # text frame
151
+ @state = "data"
152
+ @data_mode = :text
153
+ when 0x2 # binary frame
154
+ @state = "data"
155
+ @data_mode = :binary
156
+ when (0x3..0x7) # reserved
157
+ unexpected_opcode(@opcode)
158
+ when 0x8 # connection close
159
+ @on_close.call if @on_close
160
+
161
+ _reset
162
+ when 0x9 # ping
163
+ # TODO: send pong?
164
+ when 0xA # pong
165
+ puts "WS: got pong!"
166
+
167
+ _reset
168
+ else
169
+ unexpected_opcode(@opcode)
170
+ end
171
+ end
172
+
173
+ def _flush_initial
174
+ return unless @buffer.length >= 2
175
+
176
+ data_len = (@buffer[1] & 0x7F)
177
+ mask_flag = (@buffer[1] & 0x80)
178
+
179
+ @need_buffer_length = 2 + (mask_flag ? 4 : 0) + (
180
+ data_len == 127 ? 8 : (data_len == 126 ? 2 : 0)
181
+ )
182
+
183
+ @state = "readhdr"
184
+ _flush
185
+ end
186
+
187
+ def unexpected_opcode opcode
188
+ puts "WS: unexpected opcode #{opcode}"
189
+ exit 1
190
+ end
191
+ end
192
+ end
data/lib/spyder.rb CHANGED
@@ -13,3 +13,12 @@ require 'spyder/request'
13
13
  require 'spyder/response'
14
14
  require 'spyder/router'
15
15
  require 'spyder/server'
16
+ require 'spyder/web_socket_streaming_buffer'
17
+ require 'spyder/web_socket'
18
+
19
+ # spyder-web
20
+ require 'marcel'
21
+ require 'base64'
22
+ require 'openssl'
23
+
24
+ require 'spyder/web/file_server'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spyder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - André D. Piske
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-11-23 00:00:00.000000000 Z
11
+ date: 2025-06-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: mustermann
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '3.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: marcel
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.0'
27
41
  description: Spyder Web
28
42
  email: andrepiske@gmail.com
29
43
  executables: []
@@ -39,11 +53,14 @@ files:
39
53
  - lib/spyder/router.rb
40
54
  - lib/spyder/server.rb
41
55
  - lib/spyder/version.rb
56
+ - lib/spyder/web/file_server.rb
57
+ - lib/spyder/web_socket.rb
58
+ - lib/spyder/web_socket_streaming_buffer.rb
42
59
  homepage: https://github.com/andrepiske/spyder
43
60
  licenses:
44
61
  - MIT
45
62
  metadata: {}
46
- post_install_message:
63
+ post_install_message:
47
64
  rdoc_options: []
48
65
  require_paths:
49
66
  - lib
@@ -58,8 +75,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
58
75
  - !ruby/object:Gem::Version
59
76
  version: '0'
60
77
  requirements: []
61
- rubygems_version: 3.5.6
62
- signing_key:
78
+ rubygems_version: 3.0.3.1
79
+ signing_key:
63
80
  specification_version: 4
64
81
  summary: Spyder
65
82
  test_files: []