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,163 @@
|
|
|
1
|
+
module Stipa
|
|
2
|
+
# Raised for any HTTP/1.1 protocol violation.
|
|
3
|
+
# Caught by Connection#dispatch and turned into a 400 response.
|
|
4
|
+
# Not a subclass of StandardError to avoid accidental rescue-all clauses
|
|
5
|
+
# catching it — but we keep it as StandardError for practical simplicity.
|
|
6
|
+
class BadRequest < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Parses a raw HTTP/1.1 header block (already read by Connection) and
|
|
9
|
+
# reads the body from the socket using Content-Length.
|
|
10
|
+
#
|
|
11
|
+
# Design notes:
|
|
12
|
+
# - Headers are stored with lower-cased keys for O(1) case-insensitive
|
|
13
|
+
# lookup (RFC 7230 §3.2: header names are case-insensitive).
|
|
14
|
+
# - Body is read with IO.select + read_nonblock rather than SO_RCVTIMEO
|
|
15
|
+
# because SO_RCVTIMEO behaves inconsistently across Ruby versions and
|
|
16
|
+
# platforms. IO.select releases the GVL while waiting.
|
|
17
|
+
# - `id` and `params` are writable: RequestId middleware sets `id`,
|
|
18
|
+
# the router sets `params` after matching.
|
|
19
|
+
# - We reject header folding (obsolete since RFC 7230) with a 400.
|
|
20
|
+
# - Chunked Transfer-Encoding is not supported in this version.
|
|
21
|
+
class Request
|
|
22
|
+
MAX_HEADER_SIZE = 8 * 1024 # 8 KB — slow-loris defence
|
|
23
|
+
MAX_BODY_SIZE = 1 * 1024 * 1024 # 1 MB default; configurable per-server
|
|
24
|
+
VALID_METHODS = %w[GET POST PUT PATCH DELETE HEAD OPTIONS TRACE CONNECT].freeze
|
|
25
|
+
|
|
26
|
+
attr_accessor :id, :params
|
|
27
|
+
attr_reader :method, :path, :query_string, :http_version,
|
|
28
|
+
:headers, :body, :bytes_in
|
|
29
|
+
|
|
30
|
+
# Factory — called by Connection after reading the header block.
|
|
31
|
+
#
|
|
32
|
+
# @param raw_headers [String] everything up to (not including) \r\n\r\n
|
|
33
|
+
# @param socket [Socket] live socket for reading remaining body bytes
|
|
34
|
+
# @param peer [String] remote address string (for error messages)
|
|
35
|
+
# @param body_timeout [Numeric] seconds allowed for body read
|
|
36
|
+
# @param socket_buffer [String] body bytes already read by Connection
|
|
37
|
+
# when it over-read past the header boundary
|
|
38
|
+
# @param config [Hash] server-level config overrides
|
|
39
|
+
def self.parse(raw_headers, socket:, peer:, body_timeout:,
|
|
40
|
+
socket_buffer: '', config: {})
|
|
41
|
+
new(raw_headers, socket:, peer:, body_timeout:,
|
|
42
|
+
socket_buffer:, config:)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(raw_headers, socket:, peer:, body_timeout:,
|
|
46
|
+
socket_buffer: '', config: {})
|
|
47
|
+
@socket = socket
|
|
48
|
+
@peer = peer
|
|
49
|
+
@body_timeout = body_timeout
|
|
50
|
+
@socket_buffer = socket_buffer.b # binary copy of pre-read body bytes
|
|
51
|
+
@max_body = config.fetch(:max_body_size, MAX_BODY_SIZE)
|
|
52
|
+
@bytes_in = raw_headers.bytesize
|
|
53
|
+
@id = nil # set by RequestId middleware
|
|
54
|
+
@params = {} # set by App#dispatch after route match
|
|
55
|
+
parse_headers(raw_headers)
|
|
56
|
+
read_body
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Case-insensitive header lookup: req['Content-Type'] or req['content-type']
|
|
60
|
+
def [](name)
|
|
61
|
+
@headers[name.to_s.downcase]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def parse_headers(raw)
|
|
67
|
+
lines = raw.split("\r\n", -1)
|
|
68
|
+
raise BadRequest, 'empty request' if lines.empty? || lines[0].empty?
|
|
69
|
+
|
|
70
|
+
parse_request_line(lines.shift)
|
|
71
|
+
|
|
72
|
+
@headers = {}
|
|
73
|
+
lines.each do |line|
|
|
74
|
+
# RFC 7230 §3.2.4: header field folding is obsolete — reject with 400
|
|
75
|
+
raise BadRequest, 'header folding not supported' if line.start_with?(' ', "\t")
|
|
76
|
+
name, value = line.split(':', 2)
|
|
77
|
+
raise BadRequest, "malformed header: #{line.inspect}" unless value
|
|
78
|
+
# RFC 7230: no whitespace between field name and colon
|
|
79
|
+
raise BadRequest, "whitespace before colon" if name != name.rstrip
|
|
80
|
+
@headers[name.downcase.strip] = value.strip
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def parse_request_line(line)
|
|
85
|
+
parts = line.split(' ', 3)
|
|
86
|
+
raise BadRequest, "bad request line: #{line.inspect}" unless parts.length == 3
|
|
87
|
+
|
|
88
|
+
@method, full_path, @http_version = parts
|
|
89
|
+
|
|
90
|
+
raise BadRequest, "unknown method: #{@method}" unless VALID_METHODS.include?(@method)
|
|
91
|
+
raise BadRequest, "unknown HTTP version: #{@http_version}" \
|
|
92
|
+
unless @http_version.match?(/\AHTTP\/1\.[01]\z/)
|
|
93
|
+
|
|
94
|
+
# Separate path from query string
|
|
95
|
+
if (q = full_path.index('?'))
|
|
96
|
+
@path = full_path[0, q]
|
|
97
|
+
@query_string = full_path[q + 1..]
|
|
98
|
+
else
|
|
99
|
+
@path = full_path
|
|
100
|
+
@query_string = ''
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def read_body
|
|
105
|
+
cl = @headers['content-length']
|
|
106
|
+
unless cl
|
|
107
|
+
@body = ''
|
|
108
|
+
return
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Integer() raises ArgumentError on non-numeric strings
|
|
112
|
+
begin
|
|
113
|
+
length = Integer(cl, 10)
|
|
114
|
+
rescue ArgumentError
|
|
115
|
+
raise BadRequest, "invalid Content-Length: #{cl.inspect}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
raise BadRequest, 'negative Content-Length' if length < 0
|
|
119
|
+
raise BadRequest, "body exceeds #{@max_body} bytes limit" if length > @max_body
|
|
120
|
+
|
|
121
|
+
@body = read_exactly(length)
|
|
122
|
+
@bytes_in += @body.bytesize
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Read exactly `n` bytes for the body.
|
|
126
|
+
#
|
|
127
|
+
# Drains @socket_buffer first (bytes already read by Connection when it
|
|
128
|
+
# over-read past the header/body boundary), then reads remaining bytes
|
|
129
|
+
# from the live socket using IO.select + read_nonblock.
|
|
130
|
+
#
|
|
131
|
+
# GVL note: IO.select releases the GVL while waiting, so other Ruby
|
|
132
|
+
# threads continue running during I/O waits.
|
|
133
|
+
def read_exactly(n)
|
|
134
|
+
return '' if n == 0
|
|
135
|
+
|
|
136
|
+
# Start with whatever Connection already buffered
|
|
137
|
+
buf = @socket_buffer.byteslice(0, n) || ''
|
|
138
|
+
buf = String.new(buf, encoding: 'BINARY')
|
|
139
|
+
|
|
140
|
+
return buf if buf.bytesize >= n # entire body was pre-buffered
|
|
141
|
+
|
|
142
|
+
deadline = Time.now + @body_timeout
|
|
143
|
+
|
|
144
|
+
while buf.bytesize < n
|
|
145
|
+
remaining = deadline - Time.now
|
|
146
|
+
raise BadRequest, 'body read timeout' if remaining <= 0
|
|
147
|
+
|
|
148
|
+
readable = IO.select([@socket], nil, nil, remaining)
|
|
149
|
+
raise BadRequest, 'body read timeout' if readable.nil?
|
|
150
|
+
|
|
151
|
+
want = [n - buf.bytesize, 65_536].min
|
|
152
|
+
chunk = @socket.read_nonblock(want, exception: false)
|
|
153
|
+
|
|
154
|
+
raise BadRequest, 'unexpected EOF during body read' if chunk.nil?
|
|
155
|
+
next if chunk == :wait_readable # spurious wakeup, retry
|
|
156
|
+
|
|
157
|
+
buf << chunk
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
buf
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
module Stipa
|
|
4
|
+
# Builds a valid HTTP/1.1 response and serializes it to wire bytes.
|
|
5
|
+
#
|
|
6
|
+
# Design notes:
|
|
7
|
+
# - Content-Length is ALWAYS computed from body.bytesize in to_http,
|
|
8
|
+
# never stored manually. This prevents handler authors from setting
|
|
9
|
+
# a wrong value and makes binary correctness automatic.
|
|
10
|
+
# - Header names are stored in Title-Case for wire compatibility but
|
|
11
|
+
# set_header accepts any casing for developer ergonomics.
|
|
12
|
+
# - Keep-alive vs Connection:close is decided in to_http based on the
|
|
13
|
+
# request's HTTP version, so handler code never needs to think about it.
|
|
14
|
+
# - The Date header is injected automatically — required by RFC 7231.
|
|
15
|
+
class Response
|
|
16
|
+
STATUS_MESSAGES = {
|
|
17
|
+
200 => 'OK',
|
|
18
|
+
201 => 'Created',
|
|
19
|
+
204 => 'No Content',
|
|
20
|
+
301 => 'Moved Permanently',
|
|
21
|
+
302 => 'Found',
|
|
22
|
+
304 => 'Not Modified',
|
|
23
|
+
400 => 'Bad Request',
|
|
24
|
+
401 => 'Unauthorized',
|
|
25
|
+
403 => 'Forbidden',
|
|
26
|
+
404 => 'Not Found',
|
|
27
|
+
405 => 'Method Not Allowed',
|
|
28
|
+
408 => 'Request Timeout',
|
|
29
|
+
413 => 'Payload Too Large',
|
|
30
|
+
422 => 'Unprocessable Entity',
|
|
31
|
+
429 => 'Too Many Requests',
|
|
32
|
+
500 => 'Internal Server Error',
|
|
33
|
+
502 => 'Bad Gateway',
|
|
34
|
+
503 => 'Service Unavailable',
|
|
35
|
+
504 => 'Gateway Timeout',
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
attr_accessor :status, :body, :template_engine
|
|
39
|
+
attr_reader :headers
|
|
40
|
+
|
|
41
|
+
def initialize
|
|
42
|
+
@status = 200
|
|
43
|
+
@headers = {}
|
|
44
|
+
@body = ''
|
|
45
|
+
@template_engine = nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Set a response header. Name is normalized to Title-Case.
|
|
49
|
+
# Accepts any casing: set_header('content-type', 'text/html') is fine.
|
|
50
|
+
def set_header(name, value)
|
|
51
|
+
@headers[titlecase(name)] = value.to_s
|
|
52
|
+
end
|
|
53
|
+
alias []= set_header
|
|
54
|
+
|
|
55
|
+
def [](name)
|
|
56
|
+
@headers[titlecase(name)]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Render an ERB template and set the body + Content-Type to text/html.
|
|
60
|
+
#
|
|
61
|
+
# Requires a template engine to be configured on the app:
|
|
62
|
+
# app = Stipa::App.new(views: 'views')
|
|
63
|
+
#
|
|
64
|
+
# Examples:
|
|
65
|
+
# res.render('home')
|
|
66
|
+
# res.render('users/show', locals: { user: @user })
|
|
67
|
+
# res.render('welcome', locals: { name: 'Alice' }, layout: false)
|
|
68
|
+
# res.render('dashboard', layout: 'layouts/admin')
|
|
69
|
+
#
|
|
70
|
+
# Returns self for chaining.
|
|
71
|
+
def render(template, locals: {}, layout: :default)
|
|
72
|
+
raise 'No template engine configured. Pass views: "path" to Stipa::App.new.' \
|
|
73
|
+
unless @template_engine
|
|
74
|
+
|
|
75
|
+
set_header('Content-Type', 'text/html; charset=utf-8')
|
|
76
|
+
@body = @template_engine.render(template, locals: locals, layout: layout)
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Set body to a JSON representation of `data` and set Content-Type.
|
|
81
|
+
# Returns self so it can be used as the last expression in a handler.
|
|
82
|
+
def json(data)
|
|
83
|
+
@body = JSON.generate(data)
|
|
84
|
+
set_header('Content-Type', 'application/json; charset=utf-8')
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Serialize to the exact HTTP/1.1 bytes to write to the socket.
|
|
89
|
+
# `req` is used to decide the Connection header (keep-alive or close).
|
|
90
|
+
def to_http(req = nil)
|
|
91
|
+
# Force binary encoding so bytesize is always the byte count,
|
|
92
|
+
# not the character count (matters for multi-byte UTF-8 bodies).
|
|
93
|
+
body_bytes = @body.to_s.b
|
|
94
|
+
|
|
95
|
+
finalize_headers(body_bytes, req)
|
|
96
|
+
|
|
97
|
+
status_text = STATUS_MESSAGES.fetch(@status, 'Unknown')
|
|
98
|
+
status_line = "HTTP/1.1 #{@status} #{status_text}"
|
|
99
|
+
header_block = @headers.map { |k, v| "#{k}: #{v}" }.join("\r\n")
|
|
100
|
+
|
|
101
|
+
# RFC 7230: blank line (CRLF CRLF) separates header block from body
|
|
102
|
+
"#{status_line}\r\n#{header_block}\r\n\r\n#{body_bytes}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# Inject protocol-level headers last so handlers cannot accidentally
|
|
108
|
+
# set a wrong Content-Length or omit a required header.
|
|
109
|
+
def finalize_headers(body_bytes, req)
|
|
110
|
+
# Content-Length: always recomputed from actual bytes
|
|
111
|
+
set_header('Content-Length', body_bytes.bytesize)
|
|
112
|
+
|
|
113
|
+
# Default Content-Type if handler didn't set one
|
|
114
|
+
set_header('Content-Type', 'text/plain; charset=utf-8') \
|
|
115
|
+
unless @headers.key?('Content-Type')
|
|
116
|
+
|
|
117
|
+
# Date: required by RFC 7231 §7.1.1.2
|
|
118
|
+
set_header('Date', Time.now.utc.strftime('%a, %d %b %Y %H:%M:%S GMT'))
|
|
119
|
+
|
|
120
|
+
# Server: identifies the framework
|
|
121
|
+
set_header('Server', "Stipa/#{Stipa::VERSION}")
|
|
122
|
+
|
|
123
|
+
inject_connection_header(req) if req
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Set Connection header based on HTTP version and client's preference.
|
|
127
|
+
# HTTP/1.1 defaults to keep-alive; HTTP/1.0 defaults to close.
|
|
128
|
+
def inject_connection_header(req)
|
|
129
|
+
if keep_alive?(req)
|
|
130
|
+
set_header('Connection', 'keep-alive')
|
|
131
|
+
set_header('Keep-Alive', 'timeout=5, max=100')
|
|
132
|
+
else
|
|
133
|
+
set_header('Connection', 'close')
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def keep_alive?(req)
|
|
138
|
+
return false if @status >= 500 # always close after server errors
|
|
139
|
+
conn = req['connection']&.downcase
|
|
140
|
+
req.http_version == 'HTTP/1.1' ? conn != 'close' : conn == 'keep-alive'
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Convert any casing to HTTP Title-Case: "content-type" → "Content-Type"
|
|
144
|
+
def titlecase(name)
|
|
145
|
+
name.to_s.split('-').map(&:capitalize).join('-')
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
data/lib/stipa/server.rb
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
require 'socket'
|
|
2
|
+
require_relative 'thread_pool'
|
|
3
|
+
require_relative 'connection'
|
|
4
|
+
require_relative 'request'
|
|
5
|
+
require_relative 'response'
|
|
6
|
+
require_relative 'logger'
|
|
7
|
+
|
|
8
|
+
module Stipa
|
|
9
|
+
# TCP accept loop and connection lifecycle manager.
|
|
10
|
+
#
|
|
11
|
+
# Architecture:
|
|
12
|
+
#
|
|
13
|
+
# Main thread (accept loop) Worker threads (pool)
|
|
14
|
+
# ───────────────────────── ──────────────────────────────────
|
|
15
|
+
# TCPServer.accept_nonblock Connection.new(socket, ...).run
|
|
16
|
+
# └─> pool.submit(job) ─────> └─> Request.parse
|
|
17
|
+
# (drop → 503) └─> app.call(req, res)
|
|
18
|
+
# (accept continues) └─> write_response
|
|
19
|
+
#
|
|
20
|
+
# Socket options:
|
|
21
|
+
# SO_REUSEADDR — always set; allows rebinding immediately after SIGTERM
|
|
22
|
+
# without waiting for the TIME_WAIT timeout (~60 s).
|
|
23
|
+
# SO_REUSEPORT — set on Linux ≥ 3.9 when available; multiple processes
|
|
24
|
+
# can bind the same port simultaneously, enabling zero-
|
|
25
|
+
# downtime rolling restarts via a process supervisor.
|
|
26
|
+
# TCP_NODELAY — disables Nagle's algorithm; reduces latency for small
|
|
27
|
+
# responses (JSON APIs) by sending immediately rather than
|
|
28
|
+
# waiting to coalesce small writes.
|
|
29
|
+
# listen(1024) — kernel-level SYN backlog; OSes cap at net.core.somaxconn.
|
|
30
|
+
#
|
|
31
|
+
# Backpressure (when all workers are busy and the queue is full):
|
|
32
|
+
# We write a 503 directly on the accept thread without involving a worker.
|
|
33
|
+
# This keeps the accept loop free to continue processing new connections
|
|
34
|
+
# and avoids wasting a worker thread on a connection we'll immediately reject.
|
|
35
|
+
#
|
|
36
|
+
# Graceful shutdown (SIGTERM / SIGINT):
|
|
37
|
+
# 1. @running = false → accept loop exits after the current poll returns
|
|
38
|
+
# 2. pool.shutdown(drain_timeout:) → waits for in-flight requests to finish
|
|
39
|
+
# 3. server socket is closed in the ensure block of start
|
|
40
|
+
class Server
|
|
41
|
+
DEFAULT_CONFIG = {
|
|
42
|
+
host: '0.0.0.0',
|
|
43
|
+
port: 3710,
|
|
44
|
+
pool_size: 32,
|
|
45
|
+
queue_depth: 64,
|
|
46
|
+
drain_timeout: 30,
|
|
47
|
+
header_read_timeout: 10,
|
|
48
|
+
body_read_timeout: 30,
|
|
49
|
+
write_timeout: 10,
|
|
50
|
+
keepalive_timeout: 5,
|
|
51
|
+
max_requests: 100,
|
|
52
|
+
max_header_size: 8 * 1024,
|
|
53
|
+
max_body_size: 1 * 1024 * 1024,
|
|
54
|
+
backpressure: :drop, # :drop (503) or :block (wait briefly)
|
|
55
|
+
log_level: :info,
|
|
56
|
+
}.freeze
|
|
57
|
+
|
|
58
|
+
def initialize(app:, **overrides)
|
|
59
|
+
@app = app # compiled middleware+router callable
|
|
60
|
+
@config = DEFAULT_CONFIG.merge(overrides)
|
|
61
|
+
@logger = Logger.new(level: @config[:log_level])
|
|
62
|
+
@pool = ThreadPool.new(
|
|
63
|
+
size: @config[:pool_size],
|
|
64
|
+
queue_depth: @config[:queue_depth],
|
|
65
|
+
on_error: method(:pool_error),
|
|
66
|
+
)
|
|
67
|
+
@running = false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Start the server. Blocks until SIGTERM/SIGINT.
|
|
71
|
+
def start
|
|
72
|
+
@server = build_server_socket
|
|
73
|
+
@running = true
|
|
74
|
+
|
|
75
|
+
register_signals
|
|
76
|
+
|
|
77
|
+
@logger.info(
|
|
78
|
+
req: nil, res: nil,
|
|
79
|
+
msg: 'Stīpa listening',
|
|
80
|
+
host: @config[:host],
|
|
81
|
+
port: @config[:port],
|
|
82
|
+
workers: @config[:pool_size],
|
|
83
|
+
queue: @config[:queue_depth],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
accept_loop
|
|
87
|
+
ensure
|
|
88
|
+
@server&.close rescue nil
|
|
89
|
+
@logger.info(req: nil, res: nil, msg: 'Stīpa stopped')
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def accept_loop
|
|
95
|
+
while @running
|
|
96
|
+
begin
|
|
97
|
+
# accept_nonblock raises IO::WaitReadable when no connection is
|
|
98
|
+
# waiting. We use IO.select to poll every 100ms so @running is
|
|
99
|
+
# checked regularly for clean shutdown.
|
|
100
|
+
socket = @server.accept_nonblock
|
|
101
|
+
rescue IO::WaitReadable
|
|
102
|
+
IO.select([@server], nil, nil, 0.1)
|
|
103
|
+
retry
|
|
104
|
+
rescue Errno::ECONNABORTED, Errno::EPROTO
|
|
105
|
+
# Connection was aborted between SYN and accept — ignore and continue
|
|
106
|
+
retry
|
|
107
|
+
rescue IOError, Errno::EBADF
|
|
108
|
+
# Server socket was closed (shutdown path) — exit the loop
|
|
109
|
+
break
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
configure_client(socket)
|
|
113
|
+
enqueue_or_503(socket)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Try to hand the socket to the thread pool. If the queue is full,
|
|
118
|
+
# write a 503 directly on the accept thread and close the socket.
|
|
119
|
+
def enqueue_or_503(socket)
|
|
120
|
+
submitted = @pool.submit(mode: @config[:backpressure]) do
|
|
121
|
+
Connection.new(
|
|
122
|
+
socket,
|
|
123
|
+
app: @app,
|
|
124
|
+
logger: @logger,
|
|
125
|
+
config: @config,
|
|
126
|
+
).run
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
return if submitted
|
|
130
|
+
|
|
131
|
+
# Queue is full — fast reject
|
|
132
|
+
@logger.warn('backpressure 503', queue_depth: @pool.queue_depth)
|
|
133
|
+
begin
|
|
134
|
+
socket.write(
|
|
135
|
+
"HTTP/1.1 503 Service Unavailable\r\n" \
|
|
136
|
+
"Content-Type: text/plain\r\n" \
|
|
137
|
+
"Content-Length: 19\r\n" \
|
|
138
|
+
"Connection: close\r\n" \
|
|
139
|
+
"\r\n" \
|
|
140
|
+
"Service Unavailable"
|
|
141
|
+
)
|
|
142
|
+
rescue nil
|
|
143
|
+
ensure
|
|
144
|
+
socket.close rescue nil
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def build_server_socket
|
|
149
|
+
server = TCPServer.new(@config[:host], @config[:port])
|
|
150
|
+
|
|
151
|
+
# SO_REUSEADDR: rebind immediately after SIGTERM without TIME_WAIT delay
|
|
152
|
+
server.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
|
|
153
|
+
|
|
154
|
+
# SO_REUSEPORT (Linux ≥ 3.9): allows multiple processes on the same port
|
|
155
|
+
# for zero-downtime rolling restarts. Guard with const_defined? for
|
|
156
|
+
# portability across macOS, BSD, and older Linux kernels.
|
|
157
|
+
if Socket.const_defined?(:SO_REUSEPORT)
|
|
158
|
+
server.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEPORT, true)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Backlog of 1024: kernel queues up to this many SYN_RCVD connections.
|
|
162
|
+
# The actual limit is min(1024, net.core.somaxconn) on Linux.
|
|
163
|
+
server.listen(1024)
|
|
164
|
+
server
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def configure_client(socket)
|
|
168
|
+
# TCP_NODELAY disables Nagle's algorithm so small responses
|
|
169
|
+
# (e.g., a 200-byte JSON body) are sent in one TCP segment
|
|
170
|
+
# rather than waiting 40–200ms for more data to coalesce.
|
|
171
|
+
socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, true)
|
|
172
|
+
rescue StandardError
|
|
173
|
+
# Not fatal — continue without TCP_NODELAY
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def register_signals
|
|
177
|
+
shutdown_proc = ->(_signal) {
|
|
178
|
+
@logger.warn('shutdown signal received')
|
|
179
|
+
@running = false
|
|
180
|
+
@pool.shutdown(drain_timeout: @config[:drain_timeout])
|
|
181
|
+
}
|
|
182
|
+
trap('TERM', &shutdown_proc)
|
|
183
|
+
trap('INT', &shutdown_proc)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def pool_error(err, _job)
|
|
187
|
+
@logger.error("worker crash: #{err.class}: #{err.message}")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
data/lib/stipa/static.rb
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
module Stipa
|
|
2
|
+
module Middleware
|
|
3
|
+
# Serve static files from a directory (typically 'public/').
|
|
4
|
+
#
|
|
5
|
+
# Features:
|
|
6
|
+
# - Path traversal prevention (no ../../ escaping the root)
|
|
7
|
+
# - Correct MIME types for web assets including .vue and .mjs files
|
|
8
|
+
# - ETag-based conditional GET (304 Not Modified)
|
|
9
|
+
# - HEAD request support
|
|
10
|
+
# - Only intercepts GET/HEAD; other methods fall through to the app
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# app.use Stipa::Middleware::Static, root: 'public'
|
|
14
|
+
# app.use Stipa::Middleware::Static, root: '/srv/myapp/public', prefix: '/assets'
|
|
15
|
+
class Static
|
|
16
|
+
MIME_TYPES = {
|
|
17
|
+
'.html' => 'text/html; charset=utf-8',
|
|
18
|
+
'.css' => 'text/css; charset=utf-8',
|
|
19
|
+
'.js' => 'application/javascript; charset=utf-8',
|
|
20
|
+
'.mjs' => 'application/javascript; charset=utf-8',
|
|
21
|
+
# .vue files are served as JS so the browser can import them as ES modules
|
|
22
|
+
'.vue' => 'application/javascript; charset=utf-8',
|
|
23
|
+
'.json' => 'application/json; charset=utf-8',
|
|
24
|
+
'.png' => 'image/png',
|
|
25
|
+
'.jpg' => 'image/jpeg',
|
|
26
|
+
'.jpeg' => 'image/jpeg',
|
|
27
|
+
'.gif' => 'image/gif',
|
|
28
|
+
'.webp' => 'image/webp',
|
|
29
|
+
'.svg' => 'image/svg+xml',
|
|
30
|
+
'.ico' => 'image/x-icon',
|
|
31
|
+
'.woff' => 'font/woff',
|
|
32
|
+
'.woff2' => 'font/woff2',
|
|
33
|
+
'.ttf' => 'font/ttf',
|
|
34
|
+
'.eot' => 'application/vnd.ms-fontobject',
|
|
35
|
+
'.map' => 'application/json',
|
|
36
|
+
'.txt' => 'text/plain; charset=utf-8',
|
|
37
|
+
'.xml' => 'application/xml; charset=utf-8',
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# root: directory to serve files from (absolute or relative to cwd)
|
|
41
|
+
# prefix: URL path prefix that triggers static file serving (default '/')
|
|
42
|
+
def initialize(next_app, root:, prefix: '/')
|
|
43
|
+
@next_app = next_app
|
|
44
|
+
@root = File.expand_path(root)
|
|
45
|
+
@prefix = prefix.chomp('/')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def call(req, res)
|
|
49
|
+
if %w[GET HEAD].include?(req.method) && req.path.start_with?("#{@prefix}/", @prefix)
|
|
50
|
+
rel = req.path.delete_prefix(@prefix)
|
|
51
|
+
found = serve_static(rel, req, res)
|
|
52
|
+
return found if found
|
|
53
|
+
end
|
|
54
|
+
@next_app.call(req, res)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def serve_static(rel_path, req, res)
|
|
60
|
+
full_path = File.expand_path(File.join(@root, rel_path))
|
|
61
|
+
|
|
62
|
+
# Security: reject any path that escapes the root directory.
|
|
63
|
+
# File.expand_path resolves '..' so this comparison is reliable.
|
|
64
|
+
root_with_sep = @root.end_with?(File::SEPARATOR) ? @root : "#{@root}#{File::SEPARATOR}"
|
|
65
|
+
return nil unless full_path.start_with?(root_with_sep)
|
|
66
|
+
return nil unless File.file?(full_path)
|
|
67
|
+
|
|
68
|
+
stat = File.stat(full_path)
|
|
69
|
+
etag = %("stipa-#{stat.mtime.to_i}-#{stat.size}")
|
|
70
|
+
content_type = MIME_TYPES.fetch(File.extname(full_path).downcase, 'application/octet-stream')
|
|
71
|
+
|
|
72
|
+
res['ETag'] = etag
|
|
73
|
+
res['Cache-Control'] = 'public, max-age=3600'
|
|
74
|
+
res['Content-Type'] = content_type
|
|
75
|
+
|
|
76
|
+
if req['if-none-match'] == etag
|
|
77
|
+
res.status = 304
|
|
78
|
+
res.body = ''
|
|
79
|
+
elsif req.method == 'HEAD'
|
|
80
|
+
res.status = 200
|
|
81
|
+
res.body = ''
|
|
82
|
+
res['Content-Length'] = stat.size.to_s
|
|
83
|
+
else
|
|
84
|
+
res.status = 200
|
|
85
|
+
res.body = File.binread(full_path)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
res
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|