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,442 @@
|
|
|
1
|
+
require_relative 'base'
|
|
2
|
+
|
|
3
|
+
module Stipa
|
|
4
|
+
module Generators
|
|
5
|
+
class Vue < Base
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def template_name = 'vue'
|
|
9
|
+
|
|
10
|
+
def dirs
|
|
11
|
+
%w[
|
|
12
|
+
src/config
|
|
13
|
+
src/controllers
|
|
14
|
+
src/models
|
|
15
|
+
src/views/layouts
|
|
16
|
+
src/views/home
|
|
17
|
+
src/components
|
|
18
|
+
public/components
|
|
19
|
+
]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def post_generate
|
|
23
|
+
copy_asset 'stipa-vue.js', from: GEM_JS
|
|
24
|
+
copy_asset 'logo.png', from: GEM_MEDIA
|
|
25
|
+
copy_asset 'favicon.ico', from: GEM_MEDIA
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def copy_asset(file, from:)
|
|
29
|
+
src = File.join(from, file)
|
|
30
|
+
if File.exist?(src)
|
|
31
|
+
FileUtils.cp(src, File.join(target, "public/#{file}"))
|
|
32
|
+
say " create public/#{file}"
|
|
33
|
+
else
|
|
34
|
+
say " warn #{file} not found — copy it manually to public/"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def done_message
|
|
39
|
+
<<~DONE
|
|
40
|
+
|
|
41
|
+
Done! Next steps:
|
|
42
|
+
cd #{name}
|
|
43
|
+
bundle install
|
|
44
|
+
npm install
|
|
45
|
+
npm run build # compile Vue components
|
|
46
|
+
bundle exec ruby server.rb
|
|
47
|
+
DONE
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def files
|
|
51
|
+
{
|
|
52
|
+
'Gemfile' => t_gemfile,
|
|
53
|
+
'package.json' => t_package_json,
|
|
54
|
+
'rollup.config.js' => t_rollup_config,
|
|
55
|
+
'tsconfig.json' => t_tsconfig,
|
|
56
|
+
'server.rb' => t_server,
|
|
57
|
+
'src/config/routes.rb' => t_routes(
|
|
58
|
+
extra_requires: ['../controllers/home_controller', '../controllers/health_controller'],
|
|
59
|
+
extra_routes: ["get '/', to: 'home#index'", "get '/api/health', to: 'health#show'"],
|
|
60
|
+
method_override: true,
|
|
61
|
+
),
|
|
62
|
+
'src/controllers/application_controller.rb' => t_application_controller,
|
|
63
|
+
'src/controllers/home_controller.rb' => t_home_controller,
|
|
64
|
+
'src/controllers/health_controller.rb' => t_health_controller,
|
|
65
|
+
'src/views/layouts/application.html.erb' => t_layout,
|
|
66
|
+
'src/views/home/index.html.erb' => t_home_index,
|
|
67
|
+
'public/app.css' => t_app_css,
|
|
68
|
+
'src/components/RequestCard.vue' => t_request_card_vue,
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def t_package_json
|
|
73
|
+
JSON.pretty_generate(
|
|
74
|
+
name: name,
|
|
75
|
+
private: true,
|
|
76
|
+
type: 'module',
|
|
77
|
+
scripts: {
|
|
78
|
+
build: 'rollup -c',
|
|
79
|
+
watch: 'rollup -c --watch',
|
|
80
|
+
dev: 'concurrently "bundle exec ruby server.rb" "rollup -c --watch"',
|
|
81
|
+
},
|
|
82
|
+
devDependencies: {
|
|
83
|
+
'concurrently' => '^8.0.0',
|
|
84
|
+
'rollup' => '^4.0.0',
|
|
85
|
+
'rollup-plugin-vue' => '^6.0.0',
|
|
86
|
+
'@rollup/plugin-typescript' => '^11.0.0',
|
|
87
|
+
'@vue/compiler-sfc' => '^3.4.0',
|
|
88
|
+
'typescript' => '^5.0.0',
|
|
89
|
+
'vue' => '^3.4.0',
|
|
90
|
+
},
|
|
91
|
+
) + "\n"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def t_rollup_config
|
|
95
|
+
<<~JS
|
|
96
|
+
import vue from 'rollup-plugin-vue'
|
|
97
|
+
import typescript from '@rollup/plugin-typescript'
|
|
98
|
+
import { readdirSync } from 'fs'
|
|
99
|
+
|
|
100
|
+
const src = './src/components'
|
|
101
|
+
const out = './public/components'
|
|
102
|
+
|
|
103
|
+
const inputs = readdirSync(src).filter(f => f.endsWith('.vue') || f.endsWith('.ts'))
|
|
104
|
+
|
|
105
|
+
export default inputs.map(file => {
|
|
106
|
+
const name = file.replace(/\\.(vue|ts)$/, '')
|
|
107
|
+
const isTs = file.endsWith('.ts')
|
|
108
|
+
return {
|
|
109
|
+
input: `${src}/${file}`,
|
|
110
|
+
output: {
|
|
111
|
+
file: `${out}/${name}.js`,
|
|
112
|
+
format: 'iife',
|
|
113
|
+
name,
|
|
114
|
+
globals: { vue: 'Vue' },
|
|
115
|
+
},
|
|
116
|
+
external: ['vue'],
|
|
117
|
+
// rollup-plugin-vue handles <script lang="ts"> internally;
|
|
118
|
+
// @rollup/plugin-typescript is only needed for plain .ts files.
|
|
119
|
+
plugins: [vue(), ...(isTs ? [typescript({ tsconfig: './tsconfig.json' })] : [])],
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
JS
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def t_tsconfig
|
|
126
|
+
JSON.pretty_generate(
|
|
127
|
+
compilerOptions: {
|
|
128
|
+
target: 'ESNext',
|
|
129
|
+
module: 'ESNext',
|
|
130
|
+
moduleResolution: 'bundler',
|
|
131
|
+
strict: true,
|
|
132
|
+
skipLibCheck: true,
|
|
133
|
+
},
|
|
134
|
+
include: ['src/**/*'],
|
|
135
|
+
) + "\n"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def t_server
|
|
139
|
+
<<~RUBY
|
|
140
|
+
require 'stipa'
|
|
141
|
+
require_relative 'src/config/routes'
|
|
142
|
+
|
|
143
|
+
APP_DIR = __dir__
|
|
144
|
+
|
|
145
|
+
app = Stipa::App.new(
|
|
146
|
+
views: "\#{APP_DIR}/src/views",
|
|
147
|
+
public: "\#{APP_DIR}/public",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
app.use Stipa::Middleware::RequestId
|
|
151
|
+
app.use Stipa::Middleware::Timing
|
|
152
|
+
|
|
153
|
+
Routes.draw(app)
|
|
154
|
+
|
|
155
|
+
app.get '/api/health' do |_req, res|
|
|
156
|
+
res.json({ status: 'ok', framework: 'Stipa', version: Stipa::VERSION, ts: Time.now.utc.iso8601 })
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
app.start(host: '0.0.0.0', port: 3710)
|
|
160
|
+
RUBY
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def t_application_controller
|
|
164
|
+
<<~RUBY
|
|
165
|
+
require 'uri'
|
|
166
|
+
|
|
167
|
+
class ApplicationController
|
|
168
|
+
attr_reader :req, :res
|
|
169
|
+
|
|
170
|
+
def initialize(req, res)
|
|
171
|
+
@req = req
|
|
172
|
+
@res = res
|
|
173
|
+
@params = nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
def render(template, locals: {}, layout: :default)
|
|
179
|
+
res.render(template, locals: locals, layout: layout)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def redirect_to(path, status: 302)
|
|
183
|
+
res.status = status
|
|
184
|
+
res['Location'] = path
|
|
185
|
+
res.body = ''
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def not_found!(message = 'Not Found')
|
|
189
|
+
res.status = 404
|
|
190
|
+
res.body = message
|
|
191
|
+
throw :halt
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def params
|
|
195
|
+
@params ||= begin
|
|
196
|
+
p = req.params.dup
|
|
197
|
+
|
|
198
|
+
req.query_string.split('&').each do |pair|
|
|
199
|
+
next if pair.empty?
|
|
200
|
+
k, v = pair.split('=', 2)
|
|
201
|
+
p[k.to_sym] = URI.decode_www_form_component(v.to_s)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
if req['content-type']&.include?('application/x-www-form-urlencoded')
|
|
205
|
+
req.body.split('&').each do |pair|
|
|
206
|
+
next if pair.empty?
|
|
207
|
+
k, v = pair.split('=', 2)
|
|
208
|
+
p[k.to_sym] = URI.decode_www_form_component(v.to_s)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
p
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def flash_notice = params[:flash]
|
|
217
|
+
end
|
|
218
|
+
RUBY
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def t_home_controller
|
|
222
|
+
<<~RUBY
|
|
223
|
+
require_relative 'application_controller'
|
|
224
|
+
|
|
225
|
+
class HomeController < ApplicationController
|
|
226
|
+
def index
|
|
227
|
+
render('home/index')
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
RUBY
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def t_health_controller
|
|
234
|
+
<<~RUBY
|
|
235
|
+
require 'time'
|
|
236
|
+
require_relative 'application_controller'
|
|
237
|
+
|
|
238
|
+
class HealthController < ApplicationController
|
|
239
|
+
def show
|
|
240
|
+
res.json({
|
|
241
|
+
status: 'ok',
|
|
242
|
+
method: req.method,
|
|
243
|
+
path: req.path,
|
|
244
|
+
host: req['host'] || 'localhost:3710',
|
|
245
|
+
version: Stipa::VERSION,
|
|
246
|
+
ts: Time.now.utc.iso8601,
|
|
247
|
+
})
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
RUBY
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def t_layout
|
|
254
|
+
<<~ERB
|
|
255
|
+
<!DOCTYPE html>
|
|
256
|
+
<html lang="en">
|
|
257
|
+
<head>
|
|
258
|
+
<meta charset="UTF-8">
|
|
259
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
260
|
+
<title><%= content_for(:title) || '#{app_title}' %></title>
|
|
261
|
+
<link rel="icon" href="/favicon.ico">
|
|
262
|
+
<%= stylesheet_tag '/app.css' %>
|
|
263
|
+
|
|
264
|
+
<%# Vue 3 global build — sets window.Vue %>
|
|
265
|
+
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
|
266
|
+
|
|
267
|
+
<%# Stipa Vue bootstrapper — sets window.StipaVue, auto-mounts on DOMContentLoaded %>
|
|
268
|
+
<%= stipa_vue_bootstrap %>
|
|
269
|
+
|
|
270
|
+
<%# Compiled Vue components — add one block per component %>
|
|
271
|
+
<script src="/components/RequestCard.js"></script>
|
|
272
|
+
<script>
|
|
273
|
+
window.StipaVue.register('RequestCard', window.RequestCard)
|
|
274
|
+
</script>
|
|
275
|
+
|
|
276
|
+
<%# Measure time from first byte to DOMContentLoaded %>
|
|
277
|
+
<script>const _t0 = performance.now()</script>
|
|
278
|
+
</head>
|
|
279
|
+
<body>
|
|
280
|
+
<%= content %>
|
|
281
|
+
</body>
|
|
282
|
+
</html>
|
|
283
|
+
ERB
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def t_home_index
|
|
287
|
+
<<~ERB
|
|
288
|
+
<div class="circle-wrap">
|
|
289
|
+
<div class="hoop"></div>
|
|
290
|
+
<img src="/logo.png" alt="Stipa" class="logo">
|
|
291
|
+
<div class="circle-text">
|
|
292
|
+
<h1>STĪPA</h1>
|
|
293
|
+
<div class="tagline">Lightweight. Ruby. Bare HTTP.</div>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
<div class="container">
|
|
297
|
+
<div data-vue-component="RequestCard"></div>
|
|
298
|
+
|
|
299
|
+
<div class="stats">
|
|
300
|
+
<p>Stipa <%= h Stipa::VERSION %> | stdlib only | no gems</p>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
ERB
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def t_app_css
|
|
307
|
+
<<~CSS
|
|
308
|
+
:root { --red: #cc0000; --black: #1a1a1a; --white: #f4f4f4; }
|
|
309
|
+
|
|
310
|
+
body {
|
|
311
|
+
background: var(--white);
|
|
312
|
+
color: var(--black);
|
|
313
|
+
font-family: 'Input Mono', 'Menlo', monospace;
|
|
314
|
+
display: flex;
|
|
315
|
+
flex-direction: column;
|
|
316
|
+
justify-content: center;
|
|
317
|
+
align-items: center;
|
|
318
|
+
min-height: 100vh;
|
|
319
|
+
margin: 0;
|
|
320
|
+
gap: 1.5rem;
|
|
321
|
+
padding: 2rem 1rem;
|
|
322
|
+
box-sizing: border-box;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.circle-wrap {
|
|
326
|
+
position: relative;
|
|
327
|
+
width: 300px;
|
|
328
|
+
height: 300px;
|
|
329
|
+
display: flex;
|
|
330
|
+
align-items: center;
|
|
331
|
+
justify-content: center;
|
|
332
|
+
flex-shrink: 0;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.container { text-align: center; }
|
|
336
|
+
|
|
337
|
+
.circle-text {
|
|
338
|
+
position: relative;
|
|
339
|
+
z-index: 2;
|
|
340
|
+
text-align: center;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.logo {
|
|
344
|
+
position: absolute;
|
|
345
|
+
width: 220px;
|
|
346
|
+
height: 220px;
|
|
347
|
+
object-fit: contain;
|
|
348
|
+
opacity: 0.1;
|
|
349
|
+
z-index: 1;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.hoop {
|
|
353
|
+
position: absolute;
|
|
354
|
+
inset: 0;
|
|
355
|
+
border: 2px solid var(--red);
|
|
356
|
+
border-radius: 50%;
|
|
357
|
+
animation: rotate 10s linear infinite;
|
|
358
|
+
opacity: 0.1;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
@keyframes rotate {
|
|
362
|
+
from { transform: rotate(0deg) scale(1); }
|
|
363
|
+
to { transform: rotate(360deg) scale(1.1); }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
h1 { font-size: 3rem; letter-spacing: -2px; margin: 0; }
|
|
367
|
+
|
|
368
|
+
.tagline { color: var(--red); font-weight: bold; }
|
|
369
|
+
|
|
370
|
+
.code-box {
|
|
371
|
+
background: #eee;
|
|
372
|
+
padding: 20px;
|
|
373
|
+
border-radius: 4px;
|
|
374
|
+
text-align: left;
|
|
375
|
+
display: inline-block;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.stats {
|
|
379
|
+
font-size: 0.8rem;
|
|
380
|
+
border-top: 1px solid #ddd;
|
|
381
|
+
padding-top: 1rem;
|
|
382
|
+
color: #666;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
code { font-family: 'Input Mono', 'Menlo', monospace; }
|
|
386
|
+
.red { color: #d33; }
|
|
387
|
+
|
|
388
|
+
/* ── General pages (non-splash) ── */
|
|
389
|
+
.page {
|
|
390
|
+
max-width: 860px;
|
|
391
|
+
margin: 0 auto;
|
|
392
|
+
padding: 3rem 1.5rem 6rem;
|
|
393
|
+
}
|
|
394
|
+
CSS
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def t_request_card_vue
|
|
400
|
+
<<~VUE
|
|
401
|
+
<template>
|
|
402
|
+
<div class="code-box">
|
|
403
|
+
<code class="red"># Request Processed in {{ renderTime }}ms</code><br>
|
|
404
|
+
<code>{{ line1 }}</code><br>
|
|
405
|
+
<code>{{ line2 }}</code><br>
|
|
406
|
+
<code>{{ line3 }}</code>
|
|
407
|
+
</div>
|
|
408
|
+
</template>
|
|
409
|
+
|
|
410
|
+
<script lang="ts">
|
|
411
|
+
import { defineComponent, ref, onMounted } from 'vue'
|
|
412
|
+
|
|
413
|
+
export default defineComponent({
|
|
414
|
+
name: 'RequestCard',
|
|
415
|
+
setup() {
|
|
416
|
+
const renderTime = ref('…')
|
|
417
|
+
const line1 = ref('…')
|
|
418
|
+
const line2 = ref('…')
|
|
419
|
+
const line3 = ref('…')
|
|
420
|
+
|
|
421
|
+
onMounted(async () => {
|
|
422
|
+
renderTime.value = (performance.now() - (window._t0 || 0)).toFixed(1)
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
const d = await fetch('/api/health').then(r => r.json())
|
|
426
|
+
line1.value = d.method + ' ' + d.path + ' HTTP/1.1'
|
|
427
|
+
line2.value = 'Host: ' + d.host
|
|
428
|
+
line3.value = 'Status: 200 OK'
|
|
429
|
+
} catch {
|
|
430
|
+
line1.value = 'Failed to load health data'
|
|
431
|
+
}
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
return { renderTime, line1, line2, line3 }
|
|
435
|
+
},
|
|
436
|
+
})
|
|
437
|
+
</script>
|
|
438
|
+
VUE
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
data/lib/stipa/logger.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
require 'monitor'
|
|
2
|
+
|
|
3
|
+
module Stipa
|
|
4
|
+
# Structured, leveled logger that writes one logfmt line per event.
|
|
5
|
+
#
|
|
6
|
+
# Format (parseable by Splunk, Datadog, Loki, grep):
|
|
7
|
+
# time=2026-03-18T12:00:00.123Z level=INFO req_id=a1b2c3d4 method=GET
|
|
8
|
+
# path=/users status=200 bytes_in=0 bytes_out=412
|
|
9
|
+
#
|
|
10
|
+
# Thread-safe via Monitor (reentrant mutex — safe when a log call
|
|
11
|
+
# triggers another log call from a rescue block in the same thread).
|
|
12
|
+
class Logger
|
|
13
|
+
LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(output: $stdout, level: :info)
|
|
16
|
+
@output = output
|
|
17
|
+
@level = LEVELS.fetch(level, 1)
|
|
18
|
+
@lock = Monitor.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Log a completed request/response cycle. Called by Connection.
|
|
22
|
+
def info(req: nil, res: nil, bytes_in: 0, bytes_out: 0, **extra)
|
|
23
|
+
return if @level > LEVELS[:info]
|
|
24
|
+
fields = {
|
|
25
|
+
time: utc_now,
|
|
26
|
+
level: 'INFO',
|
|
27
|
+
req_id: req&.id || '-',
|
|
28
|
+
method: req&.method || '-',
|
|
29
|
+
path: req&.path || '-',
|
|
30
|
+
status: res&.status || '-',
|
|
31
|
+
bytes_in: bytes_in,
|
|
32
|
+
bytes_out: bytes_out,
|
|
33
|
+
}.merge(extra)
|
|
34
|
+
write(logfmt(fields))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def warn(msg, **fields); log(:warn, msg, **fields); end
|
|
38
|
+
def error(msg, **fields); log(:error, msg, **fields); end
|
|
39
|
+
def debug(msg, **fields); log(:debug, msg, **fields); end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def log(level, msg, **fields)
|
|
44
|
+
return if @level > LEVELS[level]
|
|
45
|
+
write(logfmt({ time: utc_now, level: level.to_s.upcase, msg: msg }.merge(fields)))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def write(line)
|
|
49
|
+
@lock.synchronize { @output.puts(line) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Encode as logfmt: key=value pairs, quoting values with special chars.
|
|
53
|
+
def logfmt(fields)
|
|
54
|
+
fields.map do |k, v|
|
|
55
|
+
v_s = v.to_s
|
|
56
|
+
v_s = %("#{v_s.gsub('"', '\\"')}") if v_s.match?(/[ ="\\]/)
|
|
57
|
+
"#{k}=#{v_s}"
|
|
58
|
+
end.join(' ')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def utc_now
|
|
62
|
+
Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%3NZ')
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
require 'securerandom'
|
|
2
|
+
|
|
3
|
+
module Stipa
|
|
4
|
+
# A minimal middleware stack inspired by Rack.
|
|
5
|
+
#
|
|
6
|
+
# Middleware signature: call(req, res) -> res
|
|
7
|
+
#
|
|
8
|
+
# The stack is compiled ONCE at server start into a chain of nested
|
|
9
|
+
# closures. Per-request cost is zero stack traversal — it's just
|
|
10
|
+
# nested method calls. The first `use`-d middleware is outermost
|
|
11
|
+
# (runs first on the way in, last on the way out).
|
|
12
|
+
#
|
|
13
|
+
# Example:
|
|
14
|
+
# app.use Stipa::Middleware::RequestId
|
|
15
|
+
# app.use Stipa::Middleware::Timing
|
|
16
|
+
# app.use Stipa::Middleware::Cors, origins: ['https://example.com']
|
|
17
|
+
class MiddlewareStack
|
|
18
|
+
def initialize
|
|
19
|
+
@layers = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def use(middleware, **options)
|
|
23
|
+
@layers << [middleware, options]
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Insert a middleware at the front of the stack (runs before all others).
|
|
28
|
+
# Used internally by App to prepend Static before user middleware.
|
|
29
|
+
def prepend(middleware, **options)
|
|
30
|
+
@layers.unshift([middleware, options])
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Compile the stack around `app` (the router callable).
|
|
35
|
+
# Returns a single callable: call(req, res) -> res
|
|
36
|
+
def build(app)
|
|
37
|
+
# Reverse so first-added middleware ends up outermost
|
|
38
|
+
@layers.reverse_each do |klass_or_proc, opts|
|
|
39
|
+
inner = app
|
|
40
|
+
app = if klass_or_proc.respond_to?(:new)
|
|
41
|
+
klass_or_proc.new(inner, **opts)
|
|
42
|
+
else
|
|
43
|
+
# Plain lambda/proc: wrap so it receives next_app context
|
|
44
|
+
->(req, res) { klass_or_proc.call(req, res, inner) }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
app
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def empty?; @layers.empty?; end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Built-in middleware
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
module Middleware
|
|
58
|
+
# Propagates an upstream X-Request-Id header or mints a new one.
|
|
59
|
+
# Runs before the router so req.id is always set when handlers execute.
|
|
60
|
+
#
|
|
61
|
+
# With a load balancer / API gateway that injects X-Request-Id, distributed
|
|
62
|
+
# traces stay correlated across services automatically.
|
|
63
|
+
class RequestId
|
|
64
|
+
def initialize(next_app, header: 'X-Request-Id')
|
|
65
|
+
@next_app = next_app
|
|
66
|
+
@header = header
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def call(req, res)
|
|
70
|
+
# Header lookup is lowercase in Request; header name for response is as-is
|
|
71
|
+
req.id = req[@header.downcase] || SecureRandom.hex(8)
|
|
72
|
+
res.set_header(@header, req.id)
|
|
73
|
+
@next_app.call(req, res)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Records wall-clock time and appends X-Response-Time to the response.
|
|
78
|
+
# Uses CLOCK_MONOTONIC (not Time.now) so system clock adjustments don't
|
|
79
|
+
# produce negative or inflated durations.
|
|
80
|
+
class Timing
|
|
81
|
+
def initialize(next_app)
|
|
82
|
+
@next_app = next_app
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def call(req, res)
|
|
86
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
87
|
+
result = @next_app.call(req, res)
|
|
88
|
+
ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(2)
|
|
89
|
+
result.set_header('X-Response-Time', "#{ms}ms")
|
|
90
|
+
result
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Simple CORS headers. Handles OPTIONS preflight automatically.
|
|
95
|
+
# Pass origins: ['*'] to allow all, or a specific list for allowlisting.
|
|
96
|
+
class Cors
|
|
97
|
+
def initialize(next_app, origins: ['*'],
|
|
98
|
+
methods: %w[GET POST PUT PATCH DELETE OPTIONS])
|
|
99
|
+
@next_app = next_app
|
|
100
|
+
@origins = Array(origins)
|
|
101
|
+
@methods = methods.join(', ')
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def call(req, res)
|
|
105
|
+
origin = req['origin'] || '*'
|
|
106
|
+
allowed = @origins.include?('*') || @origins.include?(origin)
|
|
107
|
+
|
|
108
|
+
if allowed
|
|
109
|
+
res.set_header('Access-Control-Allow-Origin', origin)
|
|
110
|
+
res.set_header('Access-Control-Allow-Methods', @methods)
|
|
111
|
+
res.set_header('Access-Control-Allow-Headers',
|
|
112
|
+
'Content-Type, Authorization, X-Request-Id')
|
|
113
|
+
# Vary tells caches that the response differs by Origin
|
|
114
|
+
res.set_header('Vary', 'Origin')
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# OPTIONS preflight: respond immediately without hitting the router
|
|
118
|
+
if req.method == 'OPTIONS'
|
|
119
|
+
res.status = 204
|
|
120
|
+
return res
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
@next_app.call(req, res)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|