stipa 0.1.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 +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE +21 -0
- data/README.md +305 -0
- data/bin/stipa +7 -0
- data/lib/js/stipa-vue.js +108 -0
- data/lib/stipa/app.rb +123 -0
- data/lib/stipa/cli.rb +39 -0
- data/lib/stipa/connection.rb +200 -0
- data/lib/stipa/generator.rb +19 -0
- data/lib/stipa/generators/api.rb +126 -0
- data/lib/stipa/generators/base.rb +117 -0
- data/lib/stipa/generators/vue.rb +442 -0
- data/lib/stipa/logger.rb +65 -0
- data/lib/stipa/middleware.rb +127 -0
- data/lib/stipa/request.rb +163 -0
- data/lib/stipa/response.rb +148 -0
- data/lib/stipa/server.rb +190 -0
- data/lib/stipa/static.rb +92 -0
- data/lib/stipa/template.rb +234 -0
- data/lib/stipa/thread_pool.rb +98 -0
- data/lib/stipa/version.rb +3 -0
- data/lib/stipa.rb +30 -0
- data/media/favicon.ico +0 -0
- data/media/logo.png +0 -0
- metadata +149 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
require 'socket'
|
|
2
|
+
|
|
3
|
+
module Stipa
|
|
4
|
+
# Manages the HTTP/1.1 keep-alive request/response loop for a single socket.
|
|
5
|
+
#
|
|
6
|
+
# A Connection is created by the Server for every accepted TCP socket and
|
|
7
|
+
# runs inside a worker thread from the ThreadPool. It owns the socket for
|
|
8
|
+
# the lifetime of the keep-alive session and closes it on exit.
|
|
9
|
+
#
|
|
10
|
+
# Keep-alive protocol:
|
|
11
|
+
# HTTP/1.1: persistent by default. We close when:
|
|
12
|
+
# - client sends "Connection: close"
|
|
13
|
+
# - we've served max_requests on this connection
|
|
14
|
+
# - a read/write timeout fires (slow client or idle connection)
|
|
15
|
+
# - a parse error occurs (send 400, close — client is misbehaving)
|
|
16
|
+
# - an unhandled exception occurs (send 500, close)
|
|
17
|
+
# HTTP/1.0: close by default unless client sends "Connection: keep-alive"
|
|
18
|
+
#
|
|
19
|
+
# Timeout strategy (using IO.select, not SO_RCVTIMEO):
|
|
20
|
+
# - header_read_timeout (10s): time to receive the full header block.
|
|
21
|
+
# Applied to the FIRST request on a new connection. Defeats slow-loris.
|
|
22
|
+
# - keepalive_timeout (5s): idle time allowed BETWEEN requests on a
|
|
23
|
+
# persistent connection. Much shorter than header_read_timeout.
|
|
24
|
+
# - body_read_timeout (30s): time to read the request body after headers.
|
|
25
|
+
# - write_timeout (10s): time to flush the full response to the client.
|
|
26
|
+
#
|
|
27
|
+
# Why IO.select instead of SO_RCVTIMEO?
|
|
28
|
+
# SO_RCVTIMEO raises Errno::EAGAIN or EWOULDBLOCK inconsistently across
|
|
29
|
+
# Ruby versions, especially in combination with Ruby's IO buffering. IO.select
|
|
30
|
+
# is pure Ruby scheduler: it releases the GVL while waiting, allows other
|
|
31
|
+
# threads to run, and works identically on Linux, macOS, and JRuby.
|
|
32
|
+
class Connection
|
|
33
|
+
CRLF2 = "\r\n\r\n".freeze
|
|
34
|
+
|
|
35
|
+
# Default timeouts and limits — all overridable via server config hash
|
|
36
|
+
DEFAULTS = {
|
|
37
|
+
header_read_timeout: 10,
|
|
38
|
+
body_read_timeout: 30,
|
|
39
|
+
write_timeout: 10,
|
|
40
|
+
keepalive_timeout: 5,
|
|
41
|
+
max_requests: 100,
|
|
42
|
+
max_header_size: Request::MAX_HEADER_SIZE,
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
def initialize(socket, app:, logger:, config: {})
|
|
46
|
+
@socket = socket
|
|
47
|
+
@app = app # compiled middleware+router callable
|
|
48
|
+
@logger = logger
|
|
49
|
+
cfg = DEFAULTS.merge(config)
|
|
50
|
+
@header_timeout = cfg[:header_read_timeout]
|
|
51
|
+
@body_timeout = cfg[:body_read_timeout]
|
|
52
|
+
@write_timeout = cfg[:write_timeout]
|
|
53
|
+
@ka_timeout = cfg[:keepalive_timeout]
|
|
54
|
+
@max_requests = cfg[:max_requests]
|
|
55
|
+
@max_header_size = cfg[:max_header_size]
|
|
56
|
+
@requests_served = 0
|
|
57
|
+
@peer = @socket.remote_address.inspect_sockaddr rescue 'unknown'
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Drive the request/response loop until the connection should be closed.
|
|
61
|
+
def run
|
|
62
|
+
loop do
|
|
63
|
+
# First request: use the full header timeout.
|
|
64
|
+
# Subsequent requests on the same connection: use the shorter
|
|
65
|
+
# keepalive idle timeout so we don't hold worker threads too long.
|
|
66
|
+
idle_timeout = @requests_served.zero? ? @header_timeout : @ka_timeout
|
|
67
|
+
|
|
68
|
+
result = read_headers(idle_timeout)
|
|
69
|
+
break if result.nil? # clean EOF, timeout, or parse error
|
|
70
|
+
|
|
71
|
+
raw_headers, leftover = result
|
|
72
|
+
|
|
73
|
+
req = Request.parse(
|
|
74
|
+
raw_headers,
|
|
75
|
+
socket: @socket,
|
|
76
|
+
peer: @peer,
|
|
77
|
+
body_timeout: @body_timeout,
|
|
78
|
+
socket_buffer: leftover,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
res = Response.new
|
|
82
|
+
keep_going = dispatch(req, res)
|
|
83
|
+
|
|
84
|
+
write_response(res, req)
|
|
85
|
+
@requests_served += 1
|
|
86
|
+
|
|
87
|
+
break unless keep_going && persistent?(req, res)
|
|
88
|
+
end
|
|
89
|
+
rescue => e
|
|
90
|
+
# Unexpected error outside the request cycle (e.g., SSL error)
|
|
91
|
+
@logger.error("connection error peer=#{@peer}: #{e.class}: #{e.message}")
|
|
92
|
+
ensure
|
|
93
|
+
# Always close the socket, even if an exception escapes
|
|
94
|
+
@socket.close rescue nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Read from the socket until we have the complete HTTP header block,
|
|
100
|
+
# identified by the \r\n\r\n separator.
|
|
101
|
+
#
|
|
102
|
+
# Returns [header_string, leftover_body_bytes], or nil on EOF/timeout/error.
|
|
103
|
+
#
|
|
104
|
+
# Why return leftover? We read in 4096-byte chunks, so a small POST body
|
|
105
|
+
# may land in the same chunk as the headers. Those bytes must be passed
|
|
106
|
+
# to Request as a pre-buffered "socket_buffer" — otherwise Request would
|
|
107
|
+
# try to read them from the socket again and either block or time out.
|
|
108
|
+
def read_headers(timeout)
|
|
109
|
+
buf = String.new(encoding: 'BINARY')
|
|
110
|
+
deadline = Time.now + timeout
|
|
111
|
+
|
|
112
|
+
loop do
|
|
113
|
+
remaining = deadline - Time.now
|
|
114
|
+
return nil if remaining <= 0 # timeout before headers arrived
|
|
115
|
+
|
|
116
|
+
readable = IO.select([@socket], nil, nil, remaining)
|
|
117
|
+
return nil if readable.nil? # select timed out
|
|
118
|
+
|
|
119
|
+
chunk = @socket.read_nonblock(4096, exception: false)
|
|
120
|
+
return nil if chunk.nil? # EOF — client disconnected
|
|
121
|
+
next if chunk == :wait_readable # spurious wakeup
|
|
122
|
+
|
|
123
|
+
buf << chunk
|
|
124
|
+
|
|
125
|
+
if buf.bytesize > @max_header_size
|
|
126
|
+
@logger.warn('oversized headers, closing', peer: @peer,
|
|
127
|
+
size: buf.bytesize)
|
|
128
|
+
return nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
break if buf.include?(CRLF2)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Split precisely at the blank line; leftover is body bytes already read.
|
|
135
|
+
head, leftover = buf.split(CRLF2, 2)
|
|
136
|
+
[head, leftover || '']
|
|
137
|
+
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE
|
|
138
|
+
nil # client disconnected mid-headers — close silently
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Run the middleware+router chain. Returns true if the connection can
|
|
142
|
+
# continue (keep-alive), false if it should close after this response.
|
|
143
|
+
def dispatch(req, res)
|
|
144
|
+
@app.call(req, res)
|
|
145
|
+
true
|
|
146
|
+
rescue BadRequest => e
|
|
147
|
+
# Protocol violation — send 400 and close the connection.
|
|
148
|
+
# Closing prevents further requests on a potentially corrupt stream.
|
|
149
|
+
res.status = 400
|
|
150
|
+
res.body = "Bad Request: #{e.message}"
|
|
151
|
+
false
|
|
152
|
+
rescue => e
|
|
153
|
+
req_id = req.id || '-'
|
|
154
|
+
@logger.error("handler error req_id=#{req_id} path=#{req.path}: " \
|
|
155
|
+
"#{e.class}: #{e.message}")
|
|
156
|
+
res.status = 500
|
|
157
|
+
res.body = 'Internal Server Error'
|
|
158
|
+
false # close after 500 to avoid a corrupted response stream
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Write the serialized response to the socket with a deadline.
|
|
162
|
+
# Uses write_nonblock + IO.select so the worker thread's GVL hold
|
|
163
|
+
# is minimal and slow clients don't block other in-flight requests.
|
|
164
|
+
def write_response(res, req)
|
|
165
|
+
data = res.to_http(req)
|
|
166
|
+
deadline = Time.now + @write_timeout
|
|
167
|
+
written = 0
|
|
168
|
+
|
|
169
|
+
while written < data.bytesize
|
|
170
|
+
remaining = deadline - Time.now
|
|
171
|
+
if remaining <= 0
|
|
172
|
+
@logger.warn('write timeout', peer: @peer)
|
|
173
|
+
return
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
writable = IO.select(nil, [@socket], nil, remaining)
|
|
177
|
+
if writable.nil?
|
|
178
|
+
@logger.warn('write timeout (select)', peer: @peer)
|
|
179
|
+
return
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
n = @socket.write_nonblock(data.byteslice(written..), exception: false)
|
|
183
|
+
next if n == :wait_writable
|
|
184
|
+
written += n
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
@logger.info(req:, res:, bytes_in: req.bytes_in, bytes_out: data.bytesize)
|
|
188
|
+
rescue Errno::EPIPE, Errno::ECONNRESET
|
|
189
|
+
# Client disconnected before we finished writing — not an error worth logging
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Determine whether to keep the connection alive for another request.
|
|
193
|
+
def persistent?(req, res)
|
|
194
|
+
return false if @requests_served >= @max_requests
|
|
195
|
+
return false if res.status >= 500 # decided to close in dispatch
|
|
196
|
+
conn = req['connection']&.downcase
|
|
197
|
+
req.http_version == 'HTTP/1.1' ? conn != 'close' : conn == 'keep-alive'
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require_relative 'generators/base'
|
|
2
|
+
require_relative 'generators/vue'
|
|
3
|
+
require_relative 'generators/api'
|
|
4
|
+
|
|
5
|
+
module Stipa
|
|
6
|
+
module Generator
|
|
7
|
+
TEMPLATES = {
|
|
8
|
+
'vue' => Generators::Vue,
|
|
9
|
+
'api' => Generators::Api,
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
DEFAULT = 'vue'
|
|
13
|
+
|
|
14
|
+
def self.new(name, template: DEFAULT)
|
|
15
|
+
klass = TEMPLATES.fetch(template) { abort "Unknown template '#{template}'. Available: #{TEMPLATES.keys.join(', ')}" }
|
|
16
|
+
klass.new(name)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
require_relative 'base'
|
|
2
|
+
|
|
3
|
+
module Stipa
|
|
4
|
+
module Generators
|
|
5
|
+
class Api < Base
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def template_name = 'api'
|
|
9
|
+
|
|
10
|
+
def dirs
|
|
11
|
+
%w[config controllers]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def done_message
|
|
15
|
+
<<~DONE
|
|
16
|
+
|
|
17
|
+
Done! Next steps:
|
|
18
|
+
cd #{name}
|
|
19
|
+
bundle install
|
|
20
|
+
bundle exec ruby server.rb
|
|
21
|
+
DONE
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def files
|
|
25
|
+
{
|
|
26
|
+
'Gemfile' => t_gemfile,
|
|
27
|
+
'server.rb' => t_server,
|
|
28
|
+
'config/routes.rb' => t_routes(
|
|
29
|
+
extra_requires: ['../controllers/health_controller'],
|
|
30
|
+
extra_routes: ["get '/health', to: 'health#show'"],
|
|
31
|
+
),
|
|
32
|
+
'controllers/application_controller.rb' => t_application_controller,
|
|
33
|
+
'controllers/health_controller.rb' => t_health_controller,
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def t_server
|
|
38
|
+
<<~RUBY
|
|
39
|
+
require 'stipa'
|
|
40
|
+
require_relative 'config/routes'
|
|
41
|
+
|
|
42
|
+
app = Stipa::App.new
|
|
43
|
+
|
|
44
|
+
app.use Stipa::Middleware::RequestId
|
|
45
|
+
app.use Stipa::Middleware::Timing
|
|
46
|
+
app.use Stipa::Middleware::Cors
|
|
47
|
+
|
|
48
|
+
Routes.draw(app)
|
|
49
|
+
|
|
50
|
+
app.start(host: '0.0.0.0', port: 3710)
|
|
51
|
+
RUBY
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def t_application_controller
|
|
55
|
+
<<~RUBY
|
|
56
|
+
require 'uri'
|
|
57
|
+
require 'json'
|
|
58
|
+
|
|
59
|
+
class ApplicationController
|
|
60
|
+
attr_reader :req, :res
|
|
61
|
+
|
|
62
|
+
def initialize(req, res)
|
|
63
|
+
@req = req
|
|
64
|
+
@res = res
|
|
65
|
+
@params = nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def json(data, status: 200)
|
|
71
|
+
res.status = status
|
|
72
|
+
res.json(data)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def not_found!(message = 'Not found')
|
|
76
|
+
res.status = 404
|
|
77
|
+
res.json(error: message)
|
|
78
|
+
throw :halt
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def unprocessable!(errors)
|
|
82
|
+
res.status = 422
|
|
83
|
+
res.json(errors: errors)
|
|
84
|
+
throw :halt
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def params
|
|
88
|
+
@params ||= begin
|
|
89
|
+
p = req.params.dup
|
|
90
|
+
|
|
91
|
+
req.query_string.split('&').each do |pair|
|
|
92
|
+
next if pair.empty?
|
|
93
|
+
k, v = pair.split('=', 2)
|
|
94
|
+
p[k.to_sym] = URI.decode_www_form_component(v.to_s)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
if req['content-type']&.include?('application/json')
|
|
98
|
+
begin
|
|
99
|
+
body = JSON.parse(req.body, symbolize_names: true)
|
|
100
|
+
p.merge!(body) if body.is_a?(Hash)
|
|
101
|
+
rescue JSON::ParserError
|
|
102
|
+
# ignore malformed JSON body
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
p
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
RUBY
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def t_health_controller
|
|
114
|
+
<<~RUBY
|
|
115
|
+
require_relative 'application_controller'
|
|
116
|
+
|
|
117
|
+
class HealthController < ApplicationController
|
|
118
|
+
def show
|
|
119
|
+
json(status: 'ok', framework: 'Stipa', version: Stipa::VERSION, ts: Time.now.utc.iso8601)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
RUBY
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'pathname'
|
|
4
|
+
|
|
5
|
+
module Stipa
|
|
6
|
+
module Generators
|
|
7
|
+
class Base
|
|
8
|
+
GEM_JS = File.expand_path('../../js', __dir__)
|
|
9
|
+
GEM_MEDIA = File.expand_path('../../../media', __dir__)
|
|
10
|
+
GEM_ROOT = File.expand_path('../..', GEM_JS)
|
|
11
|
+
|
|
12
|
+
attr_reader :name, :target
|
|
13
|
+
|
|
14
|
+
def initialize(name)
|
|
15
|
+
@name = name
|
|
16
|
+
@target = File.expand_path(name, Dir.pwd)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def generate
|
|
20
|
+
abort "Error: '#{name}' already exists." if Dir.exist?(target)
|
|
21
|
+
|
|
22
|
+
say "Creating #{name} (#{template_name})..."
|
|
23
|
+
make_dirs
|
|
24
|
+
write_files
|
|
25
|
+
post_generate
|
|
26
|
+
say done_message
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def make_dirs
|
|
32
|
+
dirs.each { |d| FileUtils.mkdir_p(File.join(target, d)) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def write_files
|
|
36
|
+
files.each do |path, content|
|
|
37
|
+
File.write(File.join(target, path), content)
|
|
38
|
+
say " create #{path}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def post_generate = nil
|
|
43
|
+
|
|
44
|
+
def say(msg) = puts(msg)
|
|
45
|
+
|
|
46
|
+
def app_title
|
|
47
|
+
name.split(/[-_]/).map(&:capitalize).join(' ')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# ── Shared templates ───────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
def t_gemfile
|
|
53
|
+
<<~RUBY
|
|
54
|
+
source 'https://rubygems.org'
|
|
55
|
+
|
|
56
|
+
gem 'stipa'
|
|
57
|
+
RUBY
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def t_routes(extra_requires: [], extra_routes: [], method_override: false)
|
|
61
|
+
requires = extra_requires.map { |r| "require_relative '#{r}'" }.join("\n")
|
|
62
|
+
override = method_override ? <<~RUBY : ''
|
|
63
|
+
# ── Method Override ────────────────────────────────────────────────────────
|
|
64
|
+
# Allows HTML forms to tunnel PUT/DELETE via a hidden _method field.
|
|
65
|
+
|
|
66
|
+
MethodOverride = lambda do |req, res, next_app|
|
|
67
|
+
if req.method == 'POST' && req['content-type']&.include?('application/x-www-form-urlencoded')
|
|
68
|
+
form = req.body.split('&').each_with_object({}) do |pair, h|
|
|
69
|
+
k, v = pair.split('=', 2)
|
|
70
|
+
h[k] = URI.decode_www_form_component(v.to_s) if k
|
|
71
|
+
end
|
|
72
|
+
override = form['_method']&.upcase
|
|
73
|
+
req.instance_variable_set(:@method, override) if %w[PUT PATCH DELETE].include?(override)
|
|
74
|
+
end
|
|
75
|
+
next_app.call(req, res)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
RUBY
|
|
79
|
+
|
|
80
|
+
use_override = method_override ? "\n @app.use MethodOverride\n" : ''
|
|
81
|
+
routes_body = extra_routes.map { |r| " #{r}" }.join("\n")
|
|
82
|
+
|
|
83
|
+
<<~RUBY
|
|
84
|
+
require 'uri'
|
|
85
|
+
#{requires}
|
|
86
|
+
#{override}
|
|
87
|
+
class Routes
|
|
88
|
+
def self.draw(app) = new(app).draw
|
|
89
|
+
|
|
90
|
+
def initialize(app)
|
|
91
|
+
@app = app
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def draw#{use_override}
|
|
95
|
+
#{routes_body}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def resolve(to)
|
|
101
|
+
ctrl, action = to.split('#', 2)
|
|
102
|
+
klass = Object.const_get(ctrl.split('_').map(&:capitalize).join + 'Controller')
|
|
103
|
+
[klass, action.to_sym]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
%w[get post put patch delete].each do |verb|
|
|
107
|
+
define_method(verb) do |pattern, to:|
|
|
108
|
+
klass, action = resolve(to)
|
|
109
|
+
@app.public_send(verb, pattern) { |req, res| klass.new(req, res).public_send(action) }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
RUBY
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|