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
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
data/lib/js/stipa-vue.js
ADDED
|
@@ -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
|