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.
@@ -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