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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ff378718ea5094cf20614ab1fa80b2ab2fe66b96dbedfc448f230a752034d69d
4
+ data.tar.gz: db686eb560e89166d73ddf100c6b65695cc288a0f8118e9461edcb0d9b4e8aa8
5
+ SHA512:
6
+ metadata.gz: bdc3509de75f0de34b898e68ecbd2f3803eaca1959f0c27162c77b2845e8288bf51f0c329be6db0278491a2a5b9c49010952a11bc804fdc79856250b98594c6f
7
+ data.tar.gz: '072216312185f44a7f236477fd06cfe1a52f2a33fc286ddd6d00f4bfec92f21054345222fa9a5d6b0e2141ffb5f28ff441999e44895debcf42d9d06c04f2cf3c'
data/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2024-03-18
9
+
10
+ ### Added
11
+
12
+ - Initial release of Stīpa framework
13
+ - Zero-dependency HTTP/1.1 server built on Ruby stdlib
14
+ - Thread pool with bounded queue and graceful shutdown
15
+ - Middleware stack with built-in RequestId, Timing, CORS, and Static middleware
16
+ - ERB template engine with layout support and Vue 3 island helpers
17
+ - Vue.js integration for interactive components
18
+ - CLI generator for scaffolding MVC and API-only applications
19
+ - Comprehensive routing with named captures support
20
+ - Keep-alive connections with configurable timeouts
21
+ - Socket optimization (SO_REUSEPORT, TCP_NODELAY)
22
+
23
+ ### Features
24
+
25
+ - **HTTP/1.1 Protocol**: Full HTTP/1.1 support with keep-alive
26
+ - **Threading**: Configurable thread pool with graceful shutdown
27
+ - **Middleware**: Pre-compiled middleware stack for zero per-request overhead
28
+ - **Templates**: ERB-based views with partials and layouts
29
+ - **Vue.js**: Island architecture for interactive components
30
+ - **CLI**: `stipa new` command for project generation
31
+ - **Production Ready**: Socket tuning, backpressure handling, structured logging
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Pedro Harbs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,305 @@
1
+ <p align="center">
2
+ <img src="media/logo.png" alt="Stīpa" width="80">
3
+ </p>
4
+
5
+ <h1 align="center">Stīpa</h1>
6
+ <p align="center">Minimal, production-ready HTTP framework for Ruby — zero dependencies, stdlib only.</p>
7
+
8
+ ---
9
+
10
+ ## Features
11
+
12
+ - **Zero dependencies** — pure Ruby stdlib (`socket`, `thread`, `erb`, `json`, `securerandom`)
13
+ - **HTTP/1.1** keep-alive, `SO_REUSEPORT`, `TCP_NODELAY`
14
+ - **Thread pool** with bounded queue and graceful shutdown
15
+ - **Middleware stack** compiled once at startup — zero per-request overhead
16
+ - **ERB template engine** with layouts, partials, and Vue 3 island helpers
17
+ - **CLI generator** — `stipa new myapp` scaffolds a full MVC app with Vue + TypeScript
18
+
19
+ ---
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ gem install stipa
25
+ ```
26
+
27
+ Or in a `Gemfile`:
28
+
29
+ ```ruby
30
+ gem 'stipa'
31
+ ```
32
+
33
+ ---
34
+
35
+ ## Quick start
36
+
37
+ ```ruby
38
+ require 'stipa'
39
+
40
+ app = Stipa::App.new
41
+
42
+ app.get '/' do |_req, res|
43
+ res.body = 'Hello, Stīpa!'
44
+ end
45
+
46
+ app.get '/health' do |_req, res|
47
+ res.json(status: 'ok', version: Stipa::VERSION)
48
+ end
49
+
50
+ app.start(port: 3710)
51
+ ```
52
+
53
+ ```bash
54
+ ruby server.rb
55
+ # => Stīpa listening on 0.0.0.0:3710
56
+ ```
57
+
58
+ ---
59
+
60
+ ## CLI
61
+
62
+ Generate a new MVC app with Vue 3 + TypeScript:
63
+
64
+ ```bash
65
+ stipa new myapp # Vue MVC (default)
66
+ stipa new myapp --vue # same
67
+ stipa new myapp --api # API-only, no views
68
+ ```
69
+
70
+ Generated structure (`--vue`):
71
+
72
+ ```
73
+ myapp/
74
+ ├── server.rb # entry point
75
+ ├── Gemfile
76
+ ├── package.json # rollup + vue + typescript
77
+ ├── rollup.config.js
78
+ ├── tsconfig.json
79
+ ├── src/
80
+ │ ├── config/routes.rb
81
+ │ ├── controllers/
82
+ │ ├── models/
83
+ │ ├── views/
84
+ │ └── components/ # Vue SFC source (.vue, .ts)
85
+ └── public/
86
+ ├── stipa-vue.js
87
+ ├── app.css
88
+ └── components/ # Rollup compiled output
89
+ ```
90
+
91
+ ```bash
92
+ cd myapp
93
+ bundle install
94
+ npm install
95
+ npm run build # compile Vue components
96
+ bundle exec ruby server.rb
97
+ ```
98
+
99
+ ---
100
+
101
+ ## Routing
102
+
103
+ Patterns are either exact strings or regular expressions. First match wins.
104
+
105
+ ```ruby
106
+ app.get '/posts', &handler
107
+ app.post '/posts', &handler
108
+ app.put %r{/posts/(?<id>\d+)}, &handler
109
+ app.patch %r{/posts/(?<id>\d+)}, &handler
110
+ app.delete %r{/posts/(?<id>\d+)}, &handler
111
+ ```
112
+
113
+ Named captures are available as `req.params`:
114
+
115
+ ```ruby
116
+ app.get %r{/users/(?<id>\d+)} do |req, res|
117
+ res.json(id: req.params[:id].to_i)
118
+ end
119
+ ```
120
+
121
+ ---
122
+
123
+ ## Request & Response
124
+
125
+ ```ruby
126
+ app.post '/echo' do |req, res|
127
+ req.method # => "POST"
128
+ req.path # => "/echo"
129
+ req.query_string # => "foo=bar"
130
+ req.body # => raw body string
131
+ req['content-type'] # => "application/json" (case-insensitive)
132
+ req.params # => { id: "42" } (from named captures)
133
+
134
+ res.status = 201
135
+ res.body = 'created'
136
+ res['X-Custom'] = 'value'
137
+ res.json(ok: true) # sets body + Content-Type: application/json
138
+ end
139
+ ```
140
+
141
+ ---
142
+
143
+ ## Middleware
144
+
145
+ ```ruby
146
+ app.use Stipa::Middleware::RequestId # mint/propagate X-Request-Id
147
+ app.use Stipa::Middleware::Timing # append X-Response-Time
148
+ app.use Stipa::Middleware::Cors, origins: ['https://example.com']
149
+ app.use Stipa::Middleware::Static, root: 'public'
150
+ ```
151
+
152
+ Custom middleware:
153
+
154
+ ```ruby
155
+ # Class-based
156
+ class Auth
157
+ def initialize(app)
158
+ @app = app
159
+ end
160
+
161
+ def call(req, res)
162
+ return res.tap { res.status = 401 } unless req['authorization']
163
+ @app.call(req, res)
164
+ end
165
+ end
166
+
167
+ app.use Auth
168
+
169
+ # Lambda-based
170
+ app.use ->(req, res, next_app) {
171
+ puts "#{req.method} #{req.path}"
172
+ next_app.call(req, res)
173
+ }
174
+ ```
175
+
176
+ ---
177
+
178
+ ## MVC
179
+
180
+ ### Routes
181
+
182
+ ```ruby
183
+ # config/routes.rb
184
+ class Routes
185
+ def self.draw(app) = new(app).draw
186
+
187
+ def draw
188
+ get '/', to: 'home#index'
189
+ get '/posts', to: 'posts#index'
190
+ post '/posts', to: 'posts#create'
191
+ end
192
+
193
+ # ...
194
+ end
195
+ ```
196
+
197
+ ### Controllers
198
+
199
+ ```ruby
200
+ class PostsController < ApplicationController
201
+ def index
202
+ render('posts/index', locals: { posts: Post.all })
203
+ end
204
+
205
+ def create
206
+ post = Post.create(params.slice(:title, :body))
207
+ redirect_to "/posts/#{post.id}"
208
+ end
209
+ end
210
+ ```
211
+
212
+ ### Views (ERB)
213
+
214
+ ```
215
+ views/
216
+ layouts/
217
+ application.html.erb ← wraps every page
218
+ posts/
219
+ index.html.erb
220
+ show.html.erb
221
+ _form.html.erb ← partial (underscore prefix)
222
+ ```
223
+
224
+ ```erb
225
+ <%# layouts/application.html.erb %>
226
+ <%= stylesheet_tag '/app.css' %>
227
+ <main><%= content %></main>
228
+
229
+ <%# posts/index.html.erb %>
230
+ <% posts.each do |post| %>
231
+ <%= render 'posts/form', locals: { post: post } %>
232
+ <% end %>
233
+ ```
234
+
235
+ ---
236
+
237
+ ## Vue 3 Islands
238
+
239
+ Mount interactive components anywhere inside ERB views — server renders the shell, Vue hydrates on the client.
240
+
241
+ **Layout:**
242
+
243
+ ```erb
244
+ <%= vue_script %>
245
+ <%= stipa_vue_bootstrap %>
246
+
247
+ <script src="/components/Counter.js"></script>
248
+ <script>
249
+ window.StipaVue.register('Counter', window.Counter)
250
+ </script>
251
+ ```
252
+
253
+ **View:**
254
+
255
+ ```erb
256
+ <%= vue_component('Counter', props: { initial: 0 }) %>
257
+ ```
258
+
259
+ **Component** (`src/components/Counter.vue`):
260
+
261
+ ```vue
262
+ <template>
263
+ <button @click="n++">Clicked {{ n }} times</button>
264
+ </template>
265
+
266
+ <script lang="ts">
267
+ import { defineComponent, ref } from "vue";
268
+ export default defineComponent({
269
+ props: { initial: { type: Number, default: 0 } },
270
+ setup(props) {
271
+ const n = ref(props.initial);
272
+ return { n };
273
+ },
274
+ });
275
+ </script>
276
+ ```
277
+
278
+ Build: `npm run build` → outputs `public/components/Counter.js`.
279
+
280
+ ---
281
+
282
+ ## Server options
283
+
284
+ ```ruby
285
+ app.start(
286
+ host: '0.0.0.0',
287
+ port: 3710,
288
+ pool_size: 32, # worker threads
289
+ queue_depth: 64, # max queued jobs before backpressure
290
+ drain_timeout: 30, # graceful shutdown wait (seconds)
291
+ keepalive_timeout: 5,
292
+ max_requests: 100, # per connection
293
+ max_body_size: 1_048_576,
294
+ backpressure: :drop, # :drop (503) or :block
295
+ log_level: :info,
296
+ )
297
+ ```
298
+
299
+ Handles `SIGTERM` / `SIGINT` with graceful drain.
300
+
301
+ ---
302
+
303
+ ## License
304
+
305
+ MIT
data/bin/stipa ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
4
+
5
+ require 'stipa/cli'
6
+
7
+ Stipa::CLI.run(ARGV)
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Stīpa Vue Bootstrapper
3
+ *
4
+ * Automatically mounts Vue 3 components declared with the ERB helper:
5
+ * <%= vue_component("Counter", props: { initial: 5 }) %>
6
+ *
7
+ * Which renders on the page as:
8
+ * <div data-vue-component="Counter" data-props='{"initial":5}'></div>
9
+ *
10
+ * Usage in your layout (after vue_script and component <script> tags):
11
+ * <%= stipa_vue_bootstrap %>
12
+ *
13
+ * Register components before DOMContentLoaded fires, or call StipaVue.mount()
14
+ * manually after dynamic content is inserted.
15
+ *
16
+ * Example:
17
+ * <script type="module">
18
+ * import Counter from '/components/Counter.js'
19
+ * StipaVue.register('Counter', Counter)
20
+ * </script>
21
+ * <%= stipa_vue_bootstrap %>
22
+ */
23
+ (() => {
24
+ const COMPONENT_ATTR = "data-vue-component";
25
+ const PROPS_ATTR = "data-props";
26
+ const MOUNTED_ATTR = "data-stipa-vue-mounted";
27
+
28
+ const registry = {};
29
+ const mounted = [];
30
+
31
+ const StipaVue = {
32
+ register(name, component) {
33
+ registry[name] = component;
34
+ },
35
+
36
+ mount(root) {
37
+ root = root || document;
38
+
39
+ if (typeof Vue === "undefined") {
40
+ console.error(
41
+ "[StipaVue] Vue is not defined. Make sure vue_script() appears before stipa_vue_bootstrap() in your layout.",
42
+ );
43
+ return;
44
+ }
45
+
46
+ // Prune stale entries for elements no longer in the DOM
47
+ for (let i = mounted.length - 1; i >= 0; i--) {
48
+ if (!document.contains(mounted[i].el)) {
49
+ mounted.splice(i, 1);
50
+ }
51
+ }
52
+
53
+ const selector = "[" + COMPONENT_ATTR + "]:not([" + MOUNTED_ATTR + "])";
54
+ const elements = root.querySelectorAll(selector);
55
+
56
+ elements.forEach((el) => {
57
+ const name = el.getAttribute(COMPONENT_ATTR);
58
+ const component = registry[name];
59
+
60
+ if (!component) {
61
+ console.warn(
62
+ '[StipaVue] Component "' +
63
+ name +
64
+ '" is not registered. ' +
65
+ 'Call StipaVue.register("' +
66
+ name +
67
+ '", YourComponent) before the DOM loads.',
68
+ );
69
+ return;
70
+ }
71
+
72
+ let props = {};
73
+ const propsRaw = el.getAttribute(PROPS_ATTR);
74
+ if (propsRaw) {
75
+ try {
76
+ props = JSON.parse(propsRaw);
77
+ } catch (e) {
78
+ console.error('[StipaVue] Failed to parse props for "' + name + '":', e);
79
+ }
80
+ }
81
+
82
+ const app = Vue.createApp(component, props);
83
+ app.mount(el);
84
+
85
+ el.setAttribute(MOUNTED_ATTR, "1");
86
+ mounted.push({ app, el });
87
+ });
88
+ },
89
+
90
+ unmountAll() {
91
+ mounted.forEach((entry) => {
92
+ entry.app.unmount();
93
+ entry.el.removeAttribute(MOUNTED_ATTR);
94
+ });
95
+ mounted.length = 0;
96
+ },
97
+ };
98
+
99
+ // DOMContentLoaded handles the synchronous registration pattern:
100
+ // components registered via classic <script> tags before this event fires
101
+ // will be mounted automatically. For async/module-based registration,
102
+ // call StipaVue.mount() manually after registering.
103
+ document.addEventListener("DOMContentLoaded", () => {
104
+ StipaVue.mount();
105
+ });
106
+
107
+ window.StipaVue = StipaVue;
108
+ })();
data/lib/stipa/app.rb ADDED
@@ -0,0 +1,123 @@
1
+ require_relative 'version'
2
+ require_relative 'logger'
3
+ require_relative 'server'
4
+ require_relative 'middleware'
5
+ require_relative 'request'
6
+ require_relative 'response'
7
+
8
+ module Stipa
9
+ # User-facing DSL for defining routes and middleware.
10
+ #
11
+ # Usage:
12
+ #
13
+ # app = Stipa::App.new
14
+ #
15
+ # app.use Stipa::Middleware::RequestId
16
+ # app.use Stipa::Middleware::Timing
17
+ # app.use Stipa::Middleware::Cors, origins: ['https://example.com']
18
+ #
19
+ # app.get '/' { |_req, res| res.body = 'Hello' }
20
+ # app.get '/health' { |_req, res| res.json(status: 'ok') }
21
+ # app.post '/echo' { |req, res| res.body = req.body }
22
+ # app.get %r{/users/(?<id>\d+)} { |req, res| res.json(id: req.params[:id].to_i) }
23
+ #
24
+ # app.start(port: 3710)
25
+ #
26
+ # Handler signature:
27
+ # Handlers always receive (req, res) — both the Request and the Response.
28
+ # Mutate `res` directly: res.body = ..., res.status = ..., res.json(...).
29
+ # Return value of the block is ignored; mutating `res` is the contract.
30
+ #
31
+ # Route matching:
32
+ # - String patterns: exact path match only.
33
+ # - Regexp patterns: full match via Regexp#match. Named capture groups
34
+ # (e.g., (?<id>\d+)) are placed into req.params as symbol keys.
35
+ # - Routes are checked in insertion order; first match wins.
36
+ #
37
+ # Middleware:
38
+ # - call `use` before `start`. Order matters: first `use`-d runs first.
39
+ # - The chain is compiled once at start time; calling `use` afterwards
40
+ # has no effect (a warning is logged).
41
+ class App
42
+ HTTP_VERBS = %w[get post put patch delete head options].freeze
43
+
44
+ # views: path to the views directory (enables ERB rendering via res.render)
45
+ # public: path to the public directory (enables static file serving)
46
+ # When provided, Static middleware is automatically prepended.
47
+ def initialize(views: nil, public: nil)
48
+ @routes = []
49
+ @stack = MiddlewareStack.new
50
+ @started = false
51
+ @logger = Logger.new
52
+ @template_engine = views ? Template.new(views_dir: views) : nil
53
+ @public_dir = public ? File.expand_path(public) : nil
54
+ end
55
+
56
+ # DSL: register a route for the given HTTP verb.
57
+ # Pattern can be a String (exact match) or Regexp (with named captures).
58
+ HTTP_VERBS.each do |verb|
59
+ define_method(verb) do |pattern, &handler|
60
+ @routes << [verb.upcase, pattern, handler]
61
+ end
62
+ end
63
+
64
+ # Add a middleware to the stack. Must be called before start.
65
+ def use(middleware, **opts)
66
+ if @started
67
+ @logger.warn("use() called after start — #{middleware} will be ignored")
68
+ return self
69
+ end
70
+ @stack.use(middleware, **opts)
71
+ self
72
+ end
73
+
74
+ # Build the middleware chain and start the TCP server. Blocks until shutdown.
75
+ def start(**opts)
76
+ @started = true
77
+ # Prepend Static middleware automatically when a public dir is configured.
78
+ # It runs before all user-registered middleware so static assets are served
79
+ # without going through the full middleware stack.
80
+ if @public_dir
81
+ @stack.prepend(Middleware::Static, root: @public_dir)
82
+ end
83
+ chain = @stack.build(method(:dispatch))
84
+ Server.new(app: chain, **opts).start
85
+ end
86
+
87
+ private
88
+
89
+ # Core router — the innermost callable in the middleware chain.
90
+ # Matches req.method + req.path against registered routes.
91
+ # Sets req.params from Regexp named captures and calls the handler.
92
+ def dispatch(req, res)
93
+ @routes.each do |method, pattern, handler|
94
+ next unless method == req.method
95
+
96
+ match = case pattern
97
+ when String
98
+ # Exact string match
99
+ req.path == pattern ? true : nil
100
+ when Regexp
101
+ # Full Regexp match — named captures become req.params
102
+ pattern.match(req.path)
103
+ end
104
+
105
+ next unless match
106
+
107
+ # Populate req.params from named captures (for Regexp routes)
108
+ if match.respond_to?(:named_captures)
109
+ req.params = match.named_captures.transform_keys(&:to_sym)
110
+ end
111
+
112
+ res.template_engine = @template_engine if @template_engine
113
+ handler.call(req, res)
114
+ return res
115
+ end
116
+
117
+ # No route matched
118
+ res.status = 404
119
+ res.body = "Not Found: #{req.method} #{req.path}"
120
+ res
121
+ end
122
+ end
123
+ end
data/lib/stipa/cli.rb ADDED
@@ -0,0 +1,39 @@
1
+ require 'fileutils'
2
+ require_relative 'version'
3
+ require_relative 'generator'
4
+
5
+ module Stipa
6
+ module CLI
7
+ USAGE = <<~TEXT
8
+ Stipa #{Stipa::VERSION} — Minimal Ruby HTTP Framework
9
+
10
+ Usage:
11
+ stipa new <app_name> [--vue|--api]
12
+
13
+ Templates:
14
+ --vue MVC app with ERB views and Vue 3 components (default)
15
+ --api API-only app with JSON controllers, no views
16
+
17
+ Examples:
18
+ stipa new my_project
19
+ stipa new my_project --vue
20
+ stipa new my_api --api
21
+
22
+ TEXT
23
+
24
+ def self.run(argv)
25
+ command = argv[0]
26
+ case command
27
+ when 'new'
28
+ name = argv.reject { |a| a.start_with?('--') }[1]
29
+ template = argv.find { |a| a.start_with?('--') }&.delete_prefix('--') || Generator::DEFAULT
30
+
31
+ abort "Usage: stipa new <app_name> [--vue|--api]" if name.nil? || name.empty?
32
+
33
+ Generator.new(name, template: template).generate
34
+ else
35
+ print USAGE
36
+ end
37
+ end
38
+ end
39
+ end