tina4ruby 3.0.0 → 3.2.1

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.
@@ -1,18 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- # In-memory response cache for GET requests.
4
+ # Multi-backend response cache for GET requests.
5
5
  #
6
- # Caches serialized responses by method + URL.
7
- # Designed to be used as Rack middleware or integrated into the Tina4 middleware chain.
6
+ # Backends are selected via the TINA4_CACHE_BACKEND env var:
7
+ # memory in-process LRU cache (default, zero deps)
8
+ # redis — Redis / Valkey (uses `redis` gem or raw RESP over TCP)
9
+ # file — JSON files in data/cache/
10
+ #
11
+ # Environment:
12
+ # TINA4_CACHE_BACKEND — memory | redis | file (default: memory)
13
+ # TINA4_CACHE_URL — redis://localhost:6379 (redis only)
14
+ # TINA4_CACHE_TTL — default TTL in seconds (default: 0 = disabled)
15
+ # TINA4_CACHE_MAX_ENTRIES — maximum cache entries (default: 1000)
8
16
  #
9
17
  # Usage:
10
18
  # cache = Tina4::ResponseCache.new(ttl: 60, max_entries: 1000)
11
19
  # cache.cache_response("GET", "/api/users", 200, "application/json", '{"users":[]}')
12
20
  # hit = cache.get("GET", "/api/users")
13
21
  #
14
- # Environment:
15
- # TINA4_CACHE_TTL -- default TTL in seconds (default: 0 = disabled)
22
+ # # Direct API (same across all 4 languages)
23
+ # cache.cache_set("key", {"data" => "value"}, ttl: 120)
24
+ # value = cache.cache_get("key")
25
+ # cache.cache_delete("key")
16
26
  #
17
27
  class ResponseCache
18
28
  CacheEntry = Struct.new(:body, :content_type, :status_code, :expires_at)
@@ -20,12 +30,24 @@ module Tina4
20
30
  # @param ttl [Integer] default TTL in seconds (0 = disabled)
21
31
  # @param max_entries [Integer] maximum cache entries
22
32
  # @param status_codes [Array<Integer>] only cache these status codes
23
- def initialize(ttl: nil, max_entries: 1000, status_codes: [200])
33
+ # @param backend [String, nil] cache backend: memory|redis|file
34
+ # @param cache_url [String, nil] Redis URL
35
+ # @param cache_dir [String, nil] File cache directory
36
+ def initialize(ttl: nil, max_entries: nil, status_codes: [200],
37
+ backend: nil, cache_url: nil, cache_dir: nil)
24
38
  @ttl = ttl || (ENV["TINA4_CACHE_TTL"] ? ENV["TINA4_CACHE_TTL"].to_i : 0)
25
- @max_entries = max_entries
39
+ @max_entries = max_entries || (ENV["TINA4_CACHE_MAX_ENTRIES"] ? ENV["TINA4_CACHE_MAX_ENTRIES"].to_i : 1000)
26
40
  @status_codes = status_codes
41
+ @backend_name = backend || ENV.fetch("TINA4_CACHE_BACKEND", "memory").downcase.strip
42
+ @cache_url = cache_url || ENV.fetch("TINA4_CACHE_URL", "redis://localhost:6379")
43
+ @cache_dir = cache_dir || ENV.fetch("TINA4_CACHE_DIR", "data/cache")
27
44
  @store = {}
28
45
  @mutex = Mutex.new
46
+ @hits = 0
47
+ @misses = 0
48
+
49
+ # Initialize backend
50
+ init_backend
29
51
  end
30
52
 
31
53
  # Check if caching is enabled.
@@ -54,16 +76,36 @@ module Tina4
54
76
  return nil unless method == "GET"
55
77
 
56
78
  key = cache_key(method, url)
57
- @mutex.synchronize do
58
- entry = @store[key]
59
- return nil unless entry
79
+ entry = backend_get(key)
80
+
81
+ if entry.nil?
82
+ @mutex.synchronize { @misses += 1 }
83
+ return nil
84
+ end
60
85
 
86
+ # For memory backend, entry is a CacheEntry; for others, reconstruct
87
+ if entry.is_a?(CacheEntry)
61
88
  if Time.now.to_f > entry.expires_at
62
- @store.delete(key)
89
+ backend_delete(key)
90
+ @mutex.synchronize { @misses += 1 }
63
91
  return nil
64
92
  end
65
-
93
+ @mutex.synchronize { @hits += 1 }
66
94
  entry
95
+ elsif entry.is_a?(Hash)
96
+ expires_at = entry["expires_at"] || entry[:expires_at] || 0
97
+ if Time.now.to_f > expires_at
98
+ backend_delete(key)
99
+ @mutex.synchronize { @misses += 1 }
100
+ return nil
101
+ end
102
+ @mutex.synchronize { @hits += 1 }
103
+ CacheEntry.new(
104
+ entry["body"] || entry[:body],
105
+ entry["content_type"] || entry[:content_type],
106
+ entry["status_code"] || entry[:status_code],
107
+ expires_at
108
+ )
67
109
  end
68
110
  end
69
111
 
@@ -84,51 +126,426 @@ module Tina4
84
126
  key = cache_key(method, url)
85
127
  expires_at = Time.now.to_f + effective_ttl
86
128
 
87
- @mutex.synchronize do
88
- # Evict oldest if at capacity
89
- if @store.size >= @max_entries && !@store.key?(key)
90
- oldest_key = @store.keys.first
91
- @store.delete(oldest_key)
129
+ entry_data = {
130
+ "body" => body,
131
+ "content_type" => content_type,
132
+ "status_code" => status_code,
133
+ "expires_at" => expires_at
134
+ }
135
+
136
+ case @backend_name
137
+ when "memory"
138
+ @mutex.synchronize do
139
+ if @store.size >= @max_entries && !@store.key?(key)
140
+ oldest_key = @store.keys.first
141
+ @store.delete(oldest_key)
142
+ end
143
+ @store[key] = CacheEntry.new(body, content_type, status_code, expires_at)
92
144
  end
145
+ else
146
+ backend_set(key, entry_data, effective_ttl)
147
+ end
148
+ end
149
+
150
+ # ── Direct Cache API (same across all 4 languages) ──────────
93
151
 
94
- @store[key] = CacheEntry.new(body, content_type, status_code, expires_at)
152
+ # Get a value from the cache by key.
153
+ #
154
+ # @param key [String]
155
+ # @return [Object, nil]
156
+ def cache_get(key)
157
+ full_key = "direct:#{key}"
158
+ raw = backend_get(full_key)
159
+
160
+ if raw.nil?
161
+ @mutex.synchronize { @misses += 1 }
162
+ return nil
95
163
  end
164
+
165
+ if raw.is_a?(Hash)
166
+ expires_at = raw["expires_at"] || raw[:expires_at] || 0
167
+ if expires_at > 0 && Time.now.to_f > expires_at
168
+ backend_delete(full_key)
169
+ @mutex.synchronize { @misses += 1 }
170
+ return nil
171
+ end
172
+ @mutex.synchronize { @hits += 1 }
173
+ raw["value"] || raw[:value]
174
+ else
175
+ @mutex.synchronize { @hits += 1 }
176
+ raw
177
+ end
178
+ end
179
+
180
+ # Store a value in the cache with optional TTL.
181
+ #
182
+ # @param key [String]
183
+ # @param value [Object]
184
+ # @param ttl [Integer] TTL in seconds (0 uses default)
185
+ def cache_set(key, value, ttl: 0)
186
+ effective_ttl = ttl > 0 ? ttl : @ttl
187
+ effective_ttl = 60 if effective_ttl <= 0 # fallback for direct API
188
+ full_key = "direct:#{key}"
189
+ entry = {
190
+ "value" => value,
191
+ "expires_at" => Time.now.to_f + effective_ttl
192
+ }
193
+ backend_set(full_key, entry, effective_ttl)
194
+ end
195
+
196
+ # Delete a key from the cache.
197
+ #
198
+ # @param key [String]
199
+ # @return [Boolean]
200
+ def cache_delete(key)
201
+ backend_delete("direct:#{key}")
96
202
  end
97
203
 
98
204
  # Get cache statistics.
99
205
  #
100
- # @return [Hash] with :size and :keys
206
+ # @return [Hash] with :hits, :misses, :size, :backend, :keys
101
207
  def cache_stats
102
208
  @mutex.synchronize do
103
- { size: @store.size, keys: @store.keys.dup }
209
+ case @backend_name
210
+ when "memory"
211
+ now = Time.now.to_f
212
+ @store.reject! { |_k, v| v.is_a?(CacheEntry) && now > v.expires_at }
213
+ { hits: @hits, misses: @misses, size: @store.size, backend: @backend_name, keys: @store.keys.dup }
214
+ when "file"
215
+ sweep
216
+ files = Dir.glob(File.join(@cache_dir, "*.json"))
217
+ { hits: @hits, misses: @misses, size: files.size, backend: @backend_name, keys: [] }
218
+ when "redis"
219
+ size = 0
220
+ if @redis_client
221
+ begin
222
+ keys = @redis_client.keys("tina4:cache:*")
223
+ size = keys.size
224
+ rescue StandardError
225
+ end
226
+ end
227
+ { hits: @hits, misses: @misses, size: size, backend: @backend_name, keys: [] }
228
+ else
229
+ { hits: @hits, misses: @misses, size: @store.size, backend: @backend_name, keys: @store.keys.dup }
230
+ end
104
231
  end
105
232
  end
106
233
 
107
234
  # Clear all cached responses.
108
235
  def clear_cache
109
- @mutex.synchronize { @store.clear }
236
+ @mutex.synchronize do
237
+ @hits = 0
238
+ @misses = 0
239
+
240
+ case @backend_name
241
+ when "memory"
242
+ @store.clear
243
+ when "file"
244
+ Dir.glob(File.join(@cache_dir, "*.json")).each { |f| File.delete(f) rescue nil }
245
+ when "redis"
246
+ if @redis_client
247
+ begin
248
+ keys = @redis_client.keys("tina4:cache:*")
249
+ @redis_client.del(*keys) unless keys.empty?
250
+ rescue StandardError
251
+ end
252
+ end
253
+ end
254
+ end
110
255
  end
111
256
 
112
257
  # Remove expired entries.
113
258
  #
114
259
  # @return [Integer] number of entries removed
115
260
  def sweep
116
- @mutex.synchronize do
261
+ case @backend_name
262
+ when "memory"
263
+ @mutex.synchronize do
264
+ now = Time.now.to_f
265
+ keys_to_remove = @store.select { |_k, v| v.is_a?(CacheEntry) && now > v.expires_at }.keys
266
+ keys_to_remove += @store.select { |_k, v| v.is_a?(Hash) && (v["expires_at"] || 0) > 0 && now > (v["expires_at"] || 0) }.keys
267
+ keys_to_remove.each { |k| @store.delete(k) }
268
+ keys_to_remove.size
269
+ end
270
+ when "file"
271
+ removed = 0
117
272
  now = Time.now.to_f
118
- keys_to_remove = @store.select { |_k, v| now > v.expires_at }.keys
119
- keys_to_remove.each { |k| @store.delete(k) }
120
- keys_to_remove.size
273
+ Dir.glob(File.join(@cache_dir, "*.json")).each do |f|
274
+ begin
275
+ data = JSON.parse(File.read(f))
276
+ if data["expires_at"] && now > data["expires_at"]
277
+ File.delete(f)
278
+ removed += 1
279
+ end
280
+ rescue StandardError
281
+ end
282
+ end
283
+ removed
284
+ else
285
+ 0
121
286
  end
122
287
  end
123
288
 
124
289
  # Current TTL setting.
125
- #
126
- # @return [Integer]
127
290
  attr_reader :ttl
128
291
 
129
292
  # Maximum entries setting.
130
- #
131
- # @return [Integer]
132
293
  attr_reader :max_entries
294
+
295
+ # Active backend name.
296
+ def backend_name
297
+ @backend_name
298
+ end
299
+
300
+ private
301
+
302
+ # ── Backend initialization ─────────────────────────────────
303
+
304
+ def init_backend
305
+ case @backend_name
306
+ when "redis"
307
+ init_redis
308
+ when "file"
309
+ init_file_dir
310
+ end
311
+ end
312
+
313
+ def init_redis
314
+ @redis_client = nil
315
+ begin
316
+ require "redis"
317
+ parsed = parse_redis_url(@cache_url)
318
+ @redis_client = Redis.new(host: parsed[:host], port: parsed[:port], db: parsed[:db], timeout: 5)
319
+ @redis_client.ping
320
+ rescue LoadError, StandardError
321
+ @redis_client = nil
322
+ end
323
+ end
324
+
325
+ def parse_redis_url(url)
326
+ cleaned = url.sub(%r{^redis://}, "")
327
+ parts = cleaned.split(":")
328
+ host = parts[0].empty? ? "localhost" : parts[0]
329
+ port_and_db = parts[1] ? parts[1].split("/") : ["6379"]
330
+ port = port_and_db[0].to_i
331
+ port = 6379 if port == 0
332
+ db = port_and_db[1] ? port_and_db[1].to_i : 0
333
+ { host: host, port: port, db: db }
334
+ end
335
+
336
+ def init_file_dir
337
+ require "json"
338
+ require "fileutils"
339
+ FileUtils.mkdir_p(@cache_dir)
340
+ end
341
+
342
+ # ── Backend operations ─────────────────────────────────────
343
+
344
+ def backend_get(key)
345
+ case @backend_name
346
+ when "memory"
347
+ @mutex.synchronize { @store[key] }
348
+ when "redis"
349
+ redis_get(key)
350
+ when "file"
351
+ file_get(key)
352
+ else
353
+ @mutex.synchronize { @store[key] }
354
+ end
355
+ end
356
+
357
+ def backend_set(key, entry, ttl)
358
+ case @backend_name
359
+ when "memory"
360
+ @mutex.synchronize do
361
+ if @store.size >= @max_entries && !@store.key?(key)
362
+ oldest_key = @store.keys.first
363
+ @store.delete(oldest_key)
364
+ end
365
+ @store[key] = entry
366
+ end
367
+ when "redis"
368
+ redis_set(key, entry, ttl)
369
+ when "file"
370
+ file_set(key, entry)
371
+ end
372
+ end
373
+
374
+ def backend_delete(key)
375
+ case @backend_name
376
+ when "memory"
377
+ @mutex.synchronize do
378
+ !@store.delete(key).nil?
379
+ end
380
+ when "redis"
381
+ redis_delete(key)
382
+ when "file"
383
+ file_delete(key)
384
+ end
385
+ end
386
+
387
+ # ── Redis operations ───────────────────────────────────────
388
+
389
+ def redis_get(key)
390
+ full_key = "tina4:cache:#{key}"
391
+ if @redis_client
392
+ begin
393
+ raw = @redis_client.get(full_key)
394
+ return nil if raw.nil?
395
+ JSON.parse(raw)
396
+ rescue StandardError
397
+ nil
398
+ end
399
+ else
400
+ resp_get(full_key)
401
+ end
402
+ end
403
+
404
+ def redis_set(key, entry, ttl)
405
+ full_key = "tina4:cache:#{key}"
406
+ serialized = JSON.generate(entry)
407
+ if @redis_client
408
+ begin
409
+ if ttl > 0
410
+ @redis_client.setex(full_key, ttl, serialized)
411
+ else
412
+ @redis_client.set(full_key, serialized)
413
+ end
414
+ rescue StandardError
415
+ end
416
+ else
417
+ if ttl > 0
418
+ resp_command("SETEX", full_key, ttl.to_s, serialized)
419
+ else
420
+ resp_command("SET", full_key, serialized)
421
+ end
422
+ end
423
+ end
424
+
425
+ def redis_delete(key)
426
+ full_key = "tina4:cache:#{key}"
427
+ if @redis_client
428
+ begin
429
+ @redis_client.del(full_key) > 0
430
+ rescue StandardError
431
+ false
432
+ end
433
+ else
434
+ result = resp_command("DEL", full_key)
435
+ result == "1"
436
+ end
437
+ end
438
+
439
+ def resp_get(key)
440
+ result = resp_command("GET", key)
441
+ return nil if result.nil?
442
+ JSON.parse(result) rescue nil
443
+ end
444
+
445
+ def resp_command(*args)
446
+ parsed = parse_redis_url(@cache_url)
447
+ cmd = "*#{args.size}\r\n"
448
+ args.each { |arg| s = arg.to_s; cmd += "$#{s.bytesize}\r\n#{s}\r\n" }
449
+
450
+ sock = TCPSocket.new(parsed[:host], parsed[:port])
451
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, [5, 0].pack("l_2"))
452
+ if parsed[:db] > 0
453
+ select_cmd = "*2\r\n$6\r\nSELECT\r\n$#{parsed[:db].to_s.bytesize}\r\n#{parsed[:db]}\r\n"
454
+ sock.write(select_cmd)
455
+ sock.recv(1024)
456
+ end
457
+ sock.write(cmd)
458
+ response = sock.recv(65536)
459
+ sock.close
460
+
461
+ if response.start_with?("+")
462
+ response[1..].strip
463
+ elsif response.start_with?("$-1")
464
+ nil
465
+ elsif response.start_with?("$")
466
+ lines = response.split("\r\n")
467
+ lines[1]
468
+ elsif response.start_with?(":")
469
+ response[1..].strip
470
+ else
471
+ nil
472
+ end
473
+ rescue StandardError
474
+ nil
475
+ end
476
+
477
+ # ── File operations ────────────────────────────────────────
478
+
479
+ def file_key_path(key)
480
+ require "digest"
481
+ safe = Digest::SHA256.hexdigest(key)
482
+ File.join(@cache_dir, "#{safe}.json")
483
+ end
484
+
485
+ def file_get(key)
486
+ path = file_key_path(key)
487
+ return nil unless File.exist?(path)
488
+ begin
489
+ data = JSON.parse(File.read(path))
490
+ if data["expires_at"] && Time.now.to_f > data["expires_at"]
491
+ File.delete(path) rescue nil
492
+ return nil
493
+ end
494
+ data
495
+ rescue StandardError
496
+ nil
497
+ end
498
+ end
499
+
500
+ def file_set(key, entry)
501
+ init_file_dir
502
+ files = Dir.glob(File.join(@cache_dir, "*.json")).sort_by { |f| File.mtime(f) }
503
+ while files.size >= @max_entries
504
+ File.delete(files.shift) rescue nil
505
+ end
506
+ path = file_key_path(key)
507
+ File.write(path, JSON.generate(entry))
508
+ rescue StandardError
509
+ end
510
+
511
+ def file_delete(key)
512
+ path = file_key_path(key)
513
+ if File.exist?(path)
514
+ File.delete(path) rescue nil
515
+ true
516
+ else
517
+ false
518
+ end
519
+ end
520
+ end
521
+
522
+ # ── Module-level convenience (singleton) ───────────────────────
523
+
524
+ @default_cache = nil
525
+
526
+ class << self
527
+ def cache_instance
528
+ @default_cache ||= ResponseCache.new(ttl: ENV["TINA4_CACHE_TTL"] ? ENV["TINA4_CACHE_TTL"].to_i : 60)
529
+ end
530
+
531
+ def cache_get(key)
532
+ cache_instance.cache_get(key)
533
+ end
534
+
535
+ def cache_set(key, value, ttl: 0)
536
+ cache_instance.cache_set(key, value, ttl: ttl)
537
+ end
538
+
539
+ def cache_delete(key)
540
+ cache_instance.cache_delete(key)
541
+ end
542
+
543
+ def cache_clear
544
+ cache_instance.clear_cache
545
+ end
546
+
547
+ def cache_stats
548
+ cache_instance.cache_stats
549
+ end
133
550
  end
134
551
  end
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>404 Not Found</title>
6
+ <title>404 - Not Found</title>
7
7
  <style>
8
8
  * { box-sizing: border-box; margin: 0; padding: 0; }
9
9
  body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
@@ -20,7 +20,7 @@ body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; c
20
20
  <div class="error-card">
21
21
  <div class="error-code">404</div>
22
22
  <div class="error-title">Page Not Found</div>
23
- <div class="error-msg">The page you're looking for doesn't exist or has been moved. Check the URL and try again.</div>
23
+ <div class="error-msg">The page you are looking for does not exist or has been moved. Check the URL and try again.</div>
24
24
  <div class="error-path">{{ path }}</div>
25
25
  <br>
26
26
  <a href="/" class="error-home">Go Home</a>
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>500 Server Error</title>
6
+ <title>500 - Server Error</title>
7
7
  <style>
8
8
  * { box-sizing: border-box; margin: 0; padding: 0; }
9
9
  body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.0.0"
4
+ VERSION = "3.2.1"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -106,7 +106,7 @@ module Tina4
106
106
  class << self
107
107
  attr_accessor :root_dir, :database
108
108
 
109
- def print_banner(host: "0.0.0.0", port: 7147)
109
+ def print_banner(host: "0.0.0.0", port: 7147, server_name: nil)
110
110
  is_tty = $stdout.respond_to?(:isatty) && $stdout.isatty
111
111
  color = is_tty ? "\e[31m" : ""
112
112
  reset = is_tty ? "\e[0m" : ""
@@ -115,10 +115,24 @@ module Tina4
115
115
  log_level = (ENV["TINA4_LOG_LEVEL"] || "[TINA4_LOG_ALL]").upcase
116
116
  display = (host == "0.0.0.0" || host == "::") ? "localhost" : host
117
117
 
118
+ # Auto-detect server name if not provided
119
+ if server_name.nil?
120
+ if is_debug
121
+ server_name = "WEBrick"
122
+ else
123
+ begin
124
+ require "puma"
125
+ server_name = "puma"
126
+ rescue LoadError
127
+ server_name = "WEBrick"
128
+ end
129
+ end
130
+ end
131
+
118
132
  puts "#{color}#{BANNER}#{reset}"
119
133
  puts " Tina4 Ruby v#{VERSION} — This is not a framework"
120
134
  puts ""
121
- puts " Server: http://#{display}:#{port}"
135
+ puts " Server: http://#{display}:#{port} (#{server_name})"
122
136
  puts " Swagger: http://localhost:#{port}/swagger"
123
137
  puts " Dashboard: http://localhost:#{port}/__dev"
124
138
  puts " Debug: #{is_debug ? 'ON' : 'OFF'} (Log level: #{log_level})"
@@ -155,6 +169,53 @@ module Tina4
155
169
  Tina4::Log.info("Tina4 initialized successfully")
156
170
  end
157
171
 
172
+ # Initialize and start the web server.
173
+ # This is the primary entry point for app.rb files:
174
+ # Tina4.initialize!(__dir__)
175
+ # Tina4.run!
176
+ # Or combined: Tina4.run!(__dir__)
177
+ def run!(root_dir = Dir.pwd)
178
+ initialize!(root_dir) unless @root_dir
179
+
180
+ host = ENV.fetch("HOST", ENV.fetch("TINA4_HOST", "0.0.0.0"))
181
+ port = ENV.fetch("PORT", ENV.fetch("TINA4_PORT", "7147")).to_i
182
+
183
+ app = Tina4::RackApp.new(root_dir: root_dir)
184
+ is_debug = Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
185
+
186
+ # Try Puma first (production-grade), fall back to WEBrick
187
+ if !is_debug
188
+ begin
189
+ require "puma"
190
+ require "puma/configuration"
191
+ require "puma/launcher"
192
+
193
+ config = Puma::Configuration.new do |user_config|
194
+ user_config.bind "tcp://#{host}:#{port}"
195
+ user_config.app app
196
+ user_config.threads 0, 16
197
+ user_config.workers 0
198
+ user_config.environment "production"
199
+ user_config.log_requests false
200
+ user_config.quiet
201
+ end
202
+
203
+ Tina4::Log.info("Production server: puma")
204
+ Tina4::Shutdown.setup
205
+
206
+ launcher = Puma::Launcher.new(config)
207
+ launcher.run
208
+ return
209
+ rescue LoadError
210
+ # Puma not installed, fall through to WEBrick
211
+ end
212
+ end
213
+
214
+ Tina4::Log.info("Development server: WEBrick")
215
+ server = Tina4::WebServer.new(app, host: host, port: port)
216
+ server.start
217
+ end
218
+
158
219
  # DSL methods for route registration
159
220
  # GET is public by default (matching tina4_python behavior)
160
221
  # POST/PUT/PATCH/DELETE are secured by default — use auth: false to make public