tina4ruby 3.0.0 → 3.9.2

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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +120 -32
  3. data/lib/tina4/auth.rb +137 -27
  4. data/lib/tina4/auto_crud.rb +55 -3
  5. data/lib/tina4/cli.rb +228 -28
  6. data/lib/tina4/cors.rb +1 -1
  7. data/lib/tina4/database.rb +230 -26
  8. data/lib/tina4/database_result.rb +122 -8
  9. data/lib/tina4/dev_mailbox.rb +1 -1
  10. data/lib/tina4/env.rb +1 -1
  11. data/lib/tina4/frond.rb +314 -7
  12. data/lib/tina4/gallery/queue/meta.json +1 -1
  13. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +314 -16
  14. data/lib/tina4/localization.rb +1 -1
  15. data/lib/tina4/messenger.rb +111 -33
  16. data/lib/tina4/middleware.rb +349 -1
  17. data/lib/tina4/migration.rb +132 -11
  18. data/lib/tina4/orm.rb +149 -18
  19. data/lib/tina4/public/js/tina4-dev-admin.min.js +1 -1
  20. data/lib/tina4/public/js/tina4js.min.js +47 -0
  21. data/lib/tina4/query_builder.rb +374 -0
  22. data/lib/tina4/queue.rb +219 -61
  23. data/lib/tina4/queue_backends/lite_backend.rb +42 -7
  24. data/lib/tina4/queue_backends/mongo_backend.rb +126 -0
  25. data/lib/tina4/rack_app.rb +200 -11
  26. data/lib/tina4/request.rb +14 -1
  27. data/lib/tina4/response.rb +26 -0
  28. data/lib/tina4/response_cache.rb +446 -29
  29. data/lib/tina4/router.rb +127 -0
  30. data/lib/tina4/service_runner.rb +1 -1
  31. data/lib/tina4/session.rb +6 -1
  32. data/lib/tina4/session_handlers/database_handler.rb +66 -0
  33. data/lib/tina4/swagger.rb +1 -1
  34. data/lib/tina4/templates/errors/404.twig +2 -2
  35. data/lib/tina4/templates/errors/500.twig +1 -1
  36. data/lib/tina4/validator.rb +174 -0
  37. data/lib/tina4/version.rb +1 -1
  38. data/lib/tina4/websocket.rb +23 -4
  39. data/lib/tina4/websocket_backplane.rb +118 -0
  40. data/lib/tina4.rb +126 -5
  41. metadata +40 -3
@@ -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
data/lib/tina4/router.rb CHANGED
@@ -4,6 +4,7 @@ module Tina4
4
4
  class Route
5
5
  attr_reader :method, :path, :handler, :auth_handler, :swagger_meta,
6
6
  :path_regex, :param_names, :middleware, :template
7
+ attr_accessor :auth_required, :cached
7
8
 
8
9
  def initialize(method, path, handler, auth_handler: nil, swagger_meta: {}, middleware: [], template: nil)
9
10
  @method = method.to_s.upcase.freeze
@@ -13,11 +14,34 @@ module Tina4
13
14
  @swagger_meta = swagger_meta
14
15
  @middleware = middleware.freeze
15
16
  @template = template&.freeze
17
+ @auth_required = %w[POST PUT PATCH DELETE].include?(@method)
18
+ @cached = false
16
19
  @param_names = []
17
20
  @path_regex = compile_pattern(@path)
18
21
  @param_names.freeze
19
22
  end
20
23
 
24
+ # Mark this route as requiring bearer-token authentication.
25
+ # Returns self for chaining: Router.get("/path") { ... }.secure
26
+ def secure
27
+ @auth_required = true
28
+ self
29
+ end
30
+
31
+ # Opt out of the secure-by-default auth on write routes.
32
+ # Returns self for chaining: Router.post("/login") { ... }.no_auth
33
+ def no_auth
34
+ @auth_required = false
35
+ self
36
+ end
37
+
38
+ # Mark this route as cacheable.
39
+ # Returns self for chaining: Router.get("/path") { ... }.cache
40
+ def cache
41
+ @cached = true
42
+ self
43
+ end
44
+
21
45
  # Returns params hash if matched, false otherwise
22
46
  def match?(request_path, request_method = nil)
23
47
  return false if request_method && @method != "ANY" && @method != request_method.to_s.upcase
@@ -105,12 +129,98 @@ module Tina4
105
129
  end
106
130
  end
107
131
 
132
+ # A registered WebSocket route with path pattern matching (reuses Route's compile logic)
133
+ class WebSocketRoute
134
+ attr_reader :path, :handler, :path_regex, :param_names
135
+
136
+ def initialize(path, handler)
137
+ @path = normalize_path(path).freeze
138
+ @handler = handler
139
+ @param_names = []
140
+ @path_regex = compile_pattern(@path)
141
+ @param_names.freeze
142
+ end
143
+
144
+ # Returns params hash if matched, false otherwise
145
+ def match?(request_path)
146
+ match = @path_regex.match(request_path)
147
+ return false unless match
148
+
149
+ if @param_names.empty?
150
+ {}
151
+ else
152
+ params = {}
153
+ @param_names.each_with_index do |param_def, i|
154
+ raw_value = match[i + 1]
155
+ params[param_def[:name]] = raw_value
156
+ end
157
+ params
158
+ end
159
+ end
160
+
161
+ private
162
+
163
+ def normalize_path(path)
164
+ p = path.to_s.gsub("\\", "/")
165
+ p = "/#{p}" unless p.start_with?("/")
166
+ p = p.chomp("/") unless p == "/"
167
+ p
168
+ end
169
+
170
+ def compile_pattern(path)
171
+ return Regexp.new("\\A/\\z") if path == "/"
172
+
173
+ parts = path.split("/").reject(&:empty?)
174
+ regex_parts = parts.map do |part|
175
+ if part =~ /\A\{(\w+)\}\z/
176
+ name = Regexp.last_match(1)
177
+ @param_names << { name: name.to_sym }
178
+ '([^/]+)'
179
+ else
180
+ Regexp.escape(part)
181
+ end
182
+ end
183
+ Regexp.new("\\A/#{regex_parts.join("/")}\\z")
184
+ end
185
+ end
186
+
108
187
  module Router
109
188
  class << self
110
189
  def routes
111
190
  @routes ||= []
112
191
  end
113
192
 
193
+ # Registered WebSocket routes
194
+ def ws_routes
195
+ @ws_routes ||= []
196
+ end
197
+
198
+ # Register a WebSocket route.
199
+ # The handler block receives (connection, event, data) where:
200
+ # connection — WebSocketConnection with #send, #broadcast, #close, #params
201
+ # event — :open, :message, or :close
202
+ # data — String payload for :message, nil for :open/:close
203
+ def websocket(path, &block)
204
+ ws_route = WebSocketRoute.new(path, block)
205
+ ws_routes << ws_route
206
+ Tina4::Log.debug("WebSocket route registered: #{path}")
207
+ ws_route
208
+ end
209
+
210
+ # Find a matching WebSocket route for a given path.
211
+ # Returns [ws_route, params] or nil.
212
+ def find_ws_route(path)
213
+ normalized = path.gsub("\\", "/")
214
+ normalized = "/#{normalized}" unless normalized.start_with?("/")
215
+ normalized = normalized.chomp("/") unless normalized == "/"
216
+
217
+ ws_routes.each do |ws_route|
218
+ params = ws_route.match?(normalized)
219
+ return [ws_route, params] if params
220
+ end
221
+ nil
222
+ end
223
+
114
224
  # Routes indexed by HTTP method for O(1) method lookup
115
225
  def method_index
116
226
  @method_index ||= Hash.new { |h, k| h[k] = [] }
@@ -169,9 +279,26 @@ module Tina4
169
279
  nil
170
280
  end
171
281
 
282
+ # Register a class-based middleware globally.
283
+ # The class should define static before_* and/or after_* methods.
284
+ # Example:
285
+ # class AuthMiddleware
286
+ # def self.before_auth(request, response)
287
+ # unless request.headers["authorization"]
288
+ # return [request, response.json({ error: "Unauthorized" }, 401)]
289
+ # end
290
+ # [request, response]
291
+ # end
292
+ # end
293
+ # Tina4::Router.use(AuthMiddleware)
294
+ def use(klass)
295
+ Tina4::Middleware.use(klass)
296
+ end
297
+
172
298
  def clear!
173
299
  @routes = []
174
300
  @method_index = Hash.new { |h, k| h[k] = [] }
301
+ @ws_routes = []
175
302
  end
176
303
 
177
304
  def group(prefix, auth_handler: nil, middleware: [], &block)