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
@@ -171,7 +171,7 @@ module Tina4
171
171
 
172
172
  def run_loop(name, handler, options, ctx)
173
173
  max_retries = options.fetch(:max_retries, 3)
174
- sleep_interval = (ENV["TINA4_SERVICE_SLEEP"] || 1).to_f
174
+ sleep_interval = (ENV["TINA4_SERVICE_SLEEP"] || 5).to_i.to_f
175
175
 
176
176
  if options[:daemon]
177
177
  run_daemon(name, handler, options, ctx, max_retries)
data/lib/tina4/session.rb CHANGED
@@ -108,7 +108,8 @@ module Tina4
108
108
  end
109
109
 
110
110
  def cookie_header
111
- "#{@options[:cookie_name]}=#{@id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=#{@options[:max_age]}"
111
+ samesite = ENV["TINA4_SESSION_SAMESITE"] || "Lax"
112
+ "#{@options[:cookie_name]}=#{@id}; Path=/; HttpOnly; SameSite=#{samesite}; Max-Age=#{@options[:max_age]}"
112
113
  end
113
114
 
114
115
  private
@@ -135,6 +136,10 @@ module Tina4
135
136
  Tina4::SessionHandlers::RedisHandler.new(@options[:handler_options])
136
137
  when :mongo, :mongodb
137
138
  Tina4::SessionHandlers::MongoHandler.new(@options[:handler_options])
139
+ when :valkey
140
+ Tina4::SessionHandlers::ValkeyHandler.new(@options[:handler_options])
141
+ when :database, :db
142
+ Tina4::SessionHandlers::DatabaseHandler.new(@options[:handler_options])
138
143
  else
139
144
  Tina4::SessionHandlers::FileHandler.new(@options[:handler_options])
140
145
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Tina4
6
+ module SessionHandlers
7
+ class DatabaseHandler
8
+ TABLE_NAME = "tina4_session"
9
+
10
+ CREATE_TABLE_SQL = <<~SQL
11
+ CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
12
+ session_id VARCHAR(255) PRIMARY KEY,
13
+ data TEXT NOT NULL,
14
+ expires_at REAL NOT NULL
15
+ )
16
+ SQL
17
+
18
+ def initialize(options = {})
19
+ @ttl = options[:ttl] || 86400
20
+ @db = options[:db] || Tina4::Database.new(ENV["DATABASE_URL"])
21
+ ensure_table
22
+ end
23
+
24
+ def read(session_id)
25
+ row = @db.fetch_one("SELECT data, expires_at FROM #{TABLE_NAME} WHERE session_id = ?", [session_id])
26
+ return nil unless row
27
+
28
+ expires_at = row["expires_at"].to_f
29
+ if expires_at > 0 && expires_at < Time.now.to_f
30
+ destroy(session_id)
31
+ return nil
32
+ end
33
+
34
+ JSON.parse(row["data"])
35
+ rescue JSON::ParserError
36
+ nil
37
+ end
38
+
39
+ def write(session_id, data)
40
+ expires_at = @ttl > 0 ? Time.now.to_f + @ttl : 0.0
41
+ json_data = JSON.generate(data)
42
+
43
+ existing = @db.fetch_one("SELECT session_id FROM #{TABLE_NAME} WHERE session_id = ?", [session_id])
44
+ if existing
45
+ @db.execute("UPDATE #{TABLE_NAME} SET data = ?, expires_at = ? WHERE session_id = ?", [json_data, expires_at, session_id])
46
+ else
47
+ @db.execute("INSERT INTO #{TABLE_NAME} (session_id, data, expires_at) VALUES (?, ?, ?)", [session_id, json_data, expires_at])
48
+ end
49
+ end
50
+
51
+ def destroy(session_id)
52
+ @db.execute("DELETE FROM #{TABLE_NAME} WHERE session_id = ?", [session_id])
53
+ end
54
+
55
+ def cleanup
56
+ @db.execute("DELETE FROM #{TABLE_NAME} WHERE expires_at > 0 AND expires_at < ?", [Time.now.to_f])
57
+ end
58
+
59
+ private
60
+
61
+ def ensure_table
62
+ @db.execute(CREATE_TABLE_SQL)
63
+ end
64
+ end
65
+ end
66
+ end
data/lib/tina4/swagger.rb CHANGED
@@ -18,7 +18,7 @@ module Tina4
18
18
  {
19
19
  "openapi" => "3.0.3",
20
20
  "info" => {
21
- "title" => ENV["PROJECT_NAME"] || "Tina4 Ruby API",
21
+ "title" => ENV["SWAGGER_TITLE"] || ENV["PROJECT_NAME"] || "Tina4 API",
22
22
  "version" => ENV["VERSION"] || Tina4::VERSION,
23
23
  "description" => "Auto-generated API documentation"
24
24
  },
@@ -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; }
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ # Request body validator with chainable rules.
5
+ #
6
+ # Usage:
7
+ # validator = Tina4::Validator.new(request.body)
8
+ # validator.required("name", "email")
9
+ # .email("email")
10
+ # .min_length("name", 2)
11
+ # .max_length("name", 100)
12
+ # .integer("age")
13
+ # .min("age", 0)
14
+ # .max("age", 150)
15
+ # .in_list("role", ["admin", "user", "guest"])
16
+ # .regex("phone", /^\+?[\d\s\-]+$/)
17
+ #
18
+ # unless validator.is_valid?
19
+ # return response.error("VALIDATION_FAILED", validator.errors.first[:message], 400)
20
+ # end
21
+ #
22
+ class Validator
23
+ attr_reader :validation_errors
24
+
25
+ EMAIL_REGEX = /\A[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\z/
26
+
27
+ def initialize(data = {})
28
+ @data = data.is_a?(Hash) ? data : {}
29
+ # Normalise keys to strings for consistent lookup
30
+ @data = @data.transform_keys(&:to_s) unless @data.empty?
31
+ @validation_errors = []
32
+ end
33
+
34
+ # Check that one or more fields are present and non-empty.
35
+ def required(*fields)
36
+ fields.each do |field|
37
+ key = field.to_s
38
+ value = @data[key]
39
+ if value.nil? || (value.is_a?(String) && value.strip.empty?)
40
+ @validation_errors << { field: key, message: "#{key} is required" }
41
+ end
42
+ end
43
+ self
44
+ end
45
+
46
+ # Check that a field contains a valid email address.
47
+ def email(field)
48
+ key = field.to_s
49
+ value = @data[key]
50
+ return self if value.nil?
51
+
52
+ unless value.is_a?(String) && value.match?(EMAIL_REGEX)
53
+ @validation_errors << { field: key, message: "#{key} must be a valid email address" }
54
+ end
55
+ self
56
+ end
57
+
58
+ # Check that a string field has at least +length+ characters.
59
+ def min_length(field, length)
60
+ key = field.to_s
61
+ value = @data[key]
62
+ return self if value.nil?
63
+
64
+ unless value.is_a?(String) && value.length >= length
65
+ @validation_errors << { field: key, message: "#{key} must be at least #{length} characters" }
66
+ end
67
+ self
68
+ end
69
+
70
+ # Check that a string field has at most +length+ characters.
71
+ def max_length(field, length)
72
+ key = field.to_s
73
+ value = @data[key]
74
+ return self if value.nil?
75
+
76
+ unless value.is_a?(String) && value.length <= length
77
+ @validation_errors << { field: key, message: "#{key} must be at most #{length} characters" }
78
+ end
79
+ self
80
+ end
81
+
82
+ # Check that a field is an integer (or can be parsed as one).
83
+ def integer(field)
84
+ key = field.to_s
85
+ value = @data[key]
86
+ return self if value.nil?
87
+
88
+ if value.is_a?(Integer)
89
+ return self
90
+ end
91
+
92
+ begin
93
+ Integer(value)
94
+ rescue ArgumentError, TypeError
95
+ @validation_errors << { field: key, message: "#{key} must be an integer" }
96
+ end
97
+ self
98
+ end
99
+
100
+ # Check that a numeric field is >= +minimum+.
101
+ def min(field, minimum)
102
+ key = field.to_s
103
+ value = @data[key]
104
+ return self if value.nil?
105
+
106
+ begin
107
+ num = Float(value)
108
+ rescue ArgumentError, TypeError
109
+ return self
110
+ end
111
+
112
+ if num < minimum
113
+ @validation_errors << { field: key, message: "#{key} must be at least #{minimum}" }
114
+ end
115
+ self
116
+ end
117
+
118
+ # Check that a numeric field is <= +maximum+.
119
+ def max(field, maximum)
120
+ key = field.to_s
121
+ value = @data[key]
122
+ return self if value.nil?
123
+
124
+ begin
125
+ num = Float(value)
126
+ rescue ArgumentError, TypeError
127
+ return self
128
+ end
129
+
130
+ if num > maximum
131
+ @validation_errors << { field: key, message: "#{key} must be at most #{maximum}" }
132
+ end
133
+ self
134
+ end
135
+
136
+ # Check that a field's value is one of the allowed values.
137
+ def in_list(field, allowed)
138
+ key = field.to_s
139
+ value = @data[key]
140
+ return self if value.nil?
141
+
142
+ unless allowed.include?(value)
143
+ @validation_errors << { field: key, message: "#{key} must be one of #{allowed}" }
144
+ end
145
+ self
146
+ end
147
+
148
+ # Check that a field matches a regular expression.
149
+ def regex(field, pattern)
150
+ key = field.to_s
151
+ value = @data[key]
152
+ return self if value.nil?
153
+
154
+ regexp = pattern.is_a?(Regexp) ? pattern : Regexp.new(pattern)
155
+ unless value.is_a?(String) && value.match?(regexp)
156
+ @validation_errors << { field: key, message: "#{key} does not match the required format" }
157
+ end
158
+ self
159
+ end
160
+
161
+ # Return the list of validation errors (empty if valid).
162
+ def errors
163
+ @validation_errors.dup
164
+ end
165
+
166
+ # Return true if no validation errors have been recorded.
167
+ def is_valid?
168
+ @validation_errors.empty?
169
+ end
170
+
171
+ # Alias for is_valid? (Ruby convention)
172
+ alias_method :valid?, :is_valid?
173
+ end
174
+ end
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.9.2"
5
5
  end
@@ -44,7 +44,8 @@ module Tina4
44
44
  socket.write(response)
45
45
 
46
46
  conn_id = SecureRandom.hex(16)
47
- connection = WebSocketConnection.new(conn_id, socket)
47
+ ws_path = env["REQUEST_PATH"] || env["PATH_INFO"] || "/"
48
+ connection = WebSocketConnection.new(conn_id, socket, ws_server: self, path: ws_path)
48
49
  @connections[conn_id] = connection
49
50
 
50
51
  emit(:open, connection)
@@ -74,9 +75,10 @@ module Tina4
74
75
  end
75
76
  end
76
77
 
77
- def broadcast(message, exclude: nil)
78
+ def broadcast(message, exclude: nil, path: nil)
78
79
  @connections.each do |id, conn|
79
80
  next if exclude && id == exclude
81
+ next if path && conn.path != path
80
82
  conn.send_text(message)
81
83
  end
82
84
  end
@@ -90,13 +92,28 @@ module Tina4
90
92
 
91
93
  class WebSocketConnection
92
94
  attr_reader :id
95
+ attr_accessor :params, :path
93
96
 
94
- def initialize(id, socket)
97
+ def initialize(id, socket, ws_server: nil, path: "/")
95
98
  @id = id
96
99
  @socket = socket
100
+ @params = {}
101
+ @ws_server = ws_server
102
+ @path = path
97
103
  end
98
104
 
99
- def send_text(message)
105
+ # Broadcast a message to all other connections on the same path
106
+ def broadcast(message, include_self: false)
107
+ return unless @ws_server
108
+
109
+ @ws_server.connections.each do |cid, conn|
110
+ next if !include_self && cid == @id
111
+ next if conn.path != @path
112
+ conn.send_text(message)
113
+ end
114
+ end
115
+
116
+ def send(message)
100
117
  data = message.encode("UTF-8")
101
118
  frame = build_frame(0x1, data)
102
119
  @socket.write(frame)
@@ -104,6 +121,8 @@ module Tina4
104
121
  # Connection closed
105
122
  end
106
123
 
124
+ alias_method :send_text, :send
125
+
107
126
  def send_pong(data)
108
127
  frame = build_frame(0xA, data || "")
109
128
  @socket.write(frame)
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ # WebSocket Backplane Abstraction for Tina4 Ruby.
4
+ #
5
+ # Enables broadcasting WebSocket messages across multiple server instances
6
+ # using a shared pub/sub channel (e.g. Redis). Without a backplane configured,
7
+ # broadcast() only reaches connections on the local process.
8
+ #
9
+ # Configuration via environment variables:
10
+ # TINA4_WS_BACKPLANE — Backend type: "redis", "nats", or "" (default: none)
11
+ # TINA4_WS_BACKPLANE_URL — Connection string (default: redis://localhost:6379)
12
+ #
13
+ # Usage:
14
+ # backplane = Tina4::WebSocketBackplane.create
15
+ # if backplane
16
+ # backplane.subscribe("chat") { |msg| relay_to_local(msg) }
17
+ # backplane.publish("chat", '{"user":"A","text":"hello"}')
18
+ # end
19
+
20
+ module Tina4
21
+ # Base backplane interface for scaling WebSocket broadcast across instances.
22
+ #
23
+ # Subclasses implement publish/subscribe over a shared message bus so that
24
+ # every server instance receives every broadcast, not just the originator.
25
+ class WebSocketBackplane
26
+ # Publish a message to all instances listening on +channel+.
27
+ def publish(channel, message)
28
+ raise NotImplementedError, "#{self.class}#publish not implemented"
29
+ end
30
+
31
+ # Subscribe to +channel+. The block is called with each incoming message.
32
+ # Runs in a background thread.
33
+ def subscribe(channel, &block)
34
+ raise NotImplementedError, "#{self.class}#subscribe not implemented"
35
+ end
36
+
37
+ # Stop listening on +channel+.
38
+ def unsubscribe(channel)
39
+ raise NotImplementedError, "#{self.class}#unsubscribe not implemented"
40
+ end
41
+
42
+ # Tear down connections and background threads.
43
+ def close
44
+ raise NotImplementedError, "#{self.class}#close not implemented"
45
+ end
46
+
47
+ # Factory that reads TINA4_WS_BACKPLANE and returns the appropriate
48
+ # backplane instance, or +nil+ if no backplane is configured.
49
+ #
50
+ # This keeps backplane usage entirely optional — callers simply check
51
+ # +if backplane+ before publishing.
52
+ def self.create(url: nil)
53
+ backend = ENV.fetch("TINA4_WS_BACKPLANE", "").strip.downcase
54
+
55
+ case backend
56
+ when "redis"
57
+ RedisBackplane.new(url: url)
58
+ when "nats"
59
+ raise NotImplementedError, "NATS backplane is on the roadmap but not yet implemented."
60
+ when ""
61
+ nil
62
+ else
63
+ raise ArgumentError, "Unknown TINA4_WS_BACKPLANE value: '#{backend}'"
64
+ end
65
+ end
66
+ end
67
+
68
+ # Redis pub/sub backplane.
69
+ #
70
+ # Requires the +redis+ gem (+gem install redis+). The require is deferred
71
+ # so the rest of Tina4 works fine without it installed — an error is raised
72
+ # only when this class is actually instantiated.
73
+ class RedisBackplane < WebSocketBackplane
74
+ def initialize(url: nil)
75
+ begin
76
+ require "redis"
77
+ rescue LoadError
78
+ raise LoadError,
79
+ "The 'redis' gem is required for RedisBackplane. " \
80
+ "Install it with: gem install redis"
81
+ end
82
+
83
+ @url = url || ENV.fetch("TINA4_WS_BACKPLANE_URL", "redis://localhost:6379")
84
+ @redis = Redis.new(url: @url)
85
+ @subscriber = Redis.new(url: @url)
86
+ @threads = {}
87
+ @running = true
88
+ end
89
+
90
+ def publish(channel, message)
91
+ @redis.publish(channel, message)
92
+ end
93
+
94
+ def subscribe(channel, &block)
95
+ @threads[channel] = Thread.new do
96
+ @subscriber.subscribe(channel) do |on|
97
+ on.message do |_chan, msg|
98
+ block.call(msg) if @running
99
+ end
100
+ end
101
+ end
102
+ end
103
+
104
+ def unsubscribe(channel)
105
+ @subscriber.unsubscribe(channel)
106
+ thread = @threads.delete(channel)
107
+ thread&.join(1)
108
+ end
109
+
110
+ def close
111
+ @running = false
112
+ @threads.each_value { |t| t.kill }
113
+ @threads.clear
114
+ @subscriber.close
115
+ @redis.close
116
+ end
117
+ end
118
+ end
data/lib/tina4.rb CHANGED
@@ -22,6 +22,7 @@ require_relative "tina4/database"
22
22
  require_relative "tina4/database_result"
23
23
  require_relative "tina4/field_types"
24
24
  require_relative "tina4/orm"
25
+ require_relative "tina4/query_builder"
25
26
  require_relative "tina4/migration"
26
27
  require_relative "tina4/auto_crud"
27
28
  require_relative "tina4/database/sqlite3_adapter"
@@ -63,7 +64,8 @@ module Tina4
63
64
  autoload :FileHandler, File.expand_path("tina4/session_handlers/file_handler", __dir__)
64
65
  autoload :RedisHandler, File.expand_path("tina4/session_handlers/redis_handler", __dir__)
65
66
  autoload :MongoHandler, File.expand_path("tina4/session_handlers/mongo_handler", __dir__)
66
- autoload :ValkeyHandler, File.expand_path("tina4/session_handlers/valkey_handler", __dir__)
67
+ autoload :ValkeyHandler, File.expand_path("tina4/session_handlers/valkey_handler", __dir__)
68
+ autoload :DatabaseHandler, File.expand_path("tina4/session_handlers/database_handler", __dir__)
67
69
  end
68
70
 
69
71
  # ── Lazy-loaded: queue backends ───────────────────────────────────────
@@ -71,6 +73,7 @@ module Tina4
71
73
  autoload :LiteBackend, File.expand_path("tina4/queue_backends/lite_backend", __dir__)
72
74
  autoload :RabbitmqBackend, File.expand_path("tina4/queue_backends/rabbitmq_backend", __dir__)
73
75
  autoload :KafkaBackend, File.expand_path("tina4/queue_backends/kafka_backend", __dir__)
76
+ autoload :MongoBackend, File.expand_path("tina4/queue_backends/mongo_backend", __dir__)
74
77
  end
75
78
 
76
79
  # ── Lazy-loaded: web server ───────────────────────────────────────────
@@ -106,7 +109,7 @@ module Tina4
106
109
  class << self
107
110
  attr_accessor :root_dir, :database
108
111
 
109
- def print_banner(host: "0.0.0.0", port: 7147)
112
+ def print_banner(host: "0.0.0.0", port: 7147, server_name: nil)
110
113
  is_tty = $stdout.respond_to?(:isatty) && $stdout.isatty
111
114
  color = is_tty ? "\e[31m" : ""
112
115
  reset = is_tty ? "\e[0m" : ""
@@ -115,10 +118,24 @@ module Tina4
115
118
  log_level = (ENV["TINA4_LOG_LEVEL"] || "[TINA4_LOG_ALL]").upcase
116
119
  display = (host == "0.0.0.0" || host == "::") ? "localhost" : host
117
120
 
121
+ # Auto-detect server name if not provided
122
+ if server_name.nil?
123
+ if is_debug
124
+ server_name = "WEBrick"
125
+ else
126
+ begin
127
+ require "puma"
128
+ server_name = "puma"
129
+ rescue LoadError
130
+ server_name = "WEBrick"
131
+ end
132
+ end
133
+ end
134
+
118
135
  puts "#{color}#{BANNER}#{reset}"
119
136
  puts " Tina4 Ruby v#{VERSION} — This is not a framework"
120
137
  puts ""
121
- puts " Server: http://#{display}:#{port}"
138
+ puts " Server: http://#{display}:#{port} (#{server_name})"
122
139
  puts " Swagger: http://localhost:#{port}/swagger"
123
140
  puts " Dashboard: http://localhost:#{port}/__dev"
124
141
  puts " Debug: #{is_debug ? 'ON' : 'OFF'} (Log level: #{log_level})"
@@ -155,6 +172,104 @@ module Tina4
155
172
  Tina4::Log.info("Tina4 initialized successfully")
156
173
  end
157
174
 
175
+ # Initialize and start the web server.
176
+ # This is the primary entry point for app.rb files:
177
+ # Tina4.initialize!(__dir__)
178
+ # Tina4.run!
179
+ # Or combined: Tina4.run!(__dir__)
180
+ def find_available_port(start, max_tries = 10)
181
+ require "socket"
182
+ max_tries.times do |offset|
183
+ port = start + offset
184
+ begin
185
+ server = TCPServer.new("127.0.0.1", port)
186
+ server.close
187
+ return port
188
+ rescue Errno::EADDRINUSE, Errno::EACCES
189
+ next
190
+ end
191
+ end
192
+ start
193
+ end
194
+
195
+ def open_browser(url)
196
+ require "rbconfig"
197
+ Thread.new do
198
+ sleep 2
199
+ case RbConfig::CONFIG["host_os"]
200
+ when /darwin/i then system("open", url)
201
+ when /mswin|mingw/i then system("start", url)
202
+ else system("xdg-open", url)
203
+ end
204
+ end
205
+ end
206
+
207
+ def run!(root_dir = nil, port: nil, host: nil, debug: nil)
208
+ # Handle legacy call: run!(port: 7147) where root_dir receives the hash
209
+ if root_dir.is_a?(Hash)
210
+ port ||= root_dir[:port]
211
+ host ||= root_dir[:host]
212
+ debug = root_dir[:debug] if debug.nil? && root_dir.key?(:debug)
213
+ root_dir = nil
214
+ end
215
+ root_dir ||= Dir.pwd
216
+
217
+ ENV["PORT"] = port.to_s if port
218
+ ENV["HOST"] = host.to_s if host
219
+ ENV["TINA4_DEBUG"] = debug.to_s unless debug.nil?
220
+
221
+ initialize!(root_dir) unless @root_dir
222
+
223
+ host = ENV.fetch("HOST", ENV.fetch("TINA4_HOST", "0.0.0.0"))
224
+ port = ENV.fetch("PORT", ENV.fetch("TINA4_PORT", "7147")).to_i
225
+
226
+ actual_port = find_available_port(port)
227
+ if actual_port != port
228
+ Tina4::Log.info("Port #{port} in use, using #{actual_port}")
229
+ port = actual_port
230
+ end
231
+
232
+ display_host = (host == "0.0.0.0" || host == "::") ? "localhost" : host
233
+ url = "http://#{display_host}:#{port}"
234
+
235
+ app = Tina4::RackApp.new(root_dir: root_dir)
236
+ is_debug = Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
237
+
238
+ # Try Puma first (production-grade), fall back to WEBrick
239
+ if !is_debug
240
+ begin
241
+ require "puma"
242
+ require "puma/configuration"
243
+ require "puma/launcher"
244
+
245
+ config = Puma::Configuration.new do |user_config|
246
+ user_config.bind "tcp://#{host}:#{port}"
247
+ user_config.app app
248
+ user_config.threads 0, 16
249
+ user_config.workers 0
250
+ user_config.environment "production"
251
+ user_config.log_requests false
252
+ user_config.quiet
253
+ end
254
+
255
+ Tina4::Log.info("Production server: puma")
256
+ Tina4::Shutdown.setup
257
+
258
+ open_browser(url)
259
+ launcher = Puma::Launcher.new(config)
260
+ launcher.run
261
+ return
262
+ rescue LoadError
263
+ # Puma not installed, fall through to WEBrick
264
+ end
265
+ end
266
+
267
+ Tina4::Log.info("Development server: WEBrick")
268
+ open_browser(url)
269
+ server = Tina4::WebServer.new(app, host: host, port: port)
270
+ server.start
271
+ end
272
+
158
273
  # DSL methods for route registration
159
274
  # GET is public by default (matching tina4_python behavior)
160
275
  # POST/PUT/PATCH/DELETE are secured by default — use auth: false to make public
@@ -225,6 +340,11 @@ module Tina4
225
340
  Tina4::Router.group(prefix, auth_handler: auth, &block)
226
341
  end
227
342
 
343
+ # WebSocket route registration
344
+ def websocket(path, &block)
345
+ Tina4::Router.websocket(path, &block)
346
+ end
347
+
228
348
  # Middleware hooks
229
349
  def before(pattern = nil, &block)
230
350
  Tina4::Middleware.before(pattern, &block)
@@ -294,8 +414,9 @@ module Tina4
294
414
  end
295
415
 
296
416
  def auto_discover(root_dir)
297
- route_dirs = %w[routes src/routes src/api api]
298
- route_dirs.each do |dir|
417
+ # src/ prefixed directories take priority over root-level ones
418
+ discover_dirs = %w[src/routes routes src/api api src/orm orm]
419
+ discover_dirs.each do |dir|
299
420
  full_dir = File.join(root_dir, dir)
300
421
  next unless Dir.exist?(full_dir)
301
422