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