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.
- checksums.yaml +4 -4
- data/README.md +105 -16
- data/lib/tina4/cli.rb +154 -27
- data/lib/tina4/database.rb +101 -0
- data/lib/tina4/dev_mailbox.rb +1 -1
- data/lib/tina4/frond.rb +166 -5
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +314 -16
- data/lib/tina4/messenger.rb +111 -33
- data/lib/tina4/orm.rb +134 -12
- data/lib/tina4/queue.rb +125 -5
- data/lib/tina4/rack_app.rb +18 -5
- data/lib/tina4/response_cache.rb +446 -29
- data/lib/tina4/templates/errors/404.twig +2 -2
- data/lib/tina4/templates/errors/500.twig +1 -1
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +63 -2
- metadata +29 -1
data/lib/tina4/response_cache.rb
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Tina4
|
|
4
|
-
#
|
|
4
|
+
# Multi-backend response cache for GET requests.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
#
|
|
15
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
206
|
+
# @return [Hash] with :hits, :misses, :size, :backend, :keys
|
|
101
207
|
def cache_stats
|
|
102
208
|
@mutex.synchronize do
|
|
103
|
-
|
|
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
|
|
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
|
-
@
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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
|
|
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
|
|
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
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
|