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.
- checksums.yaml +4 -4
- data/README.md +120 -32
- data/lib/tina4/auth.rb +137 -27
- data/lib/tina4/auto_crud.rb +55 -3
- data/lib/tina4/cli.rb +228 -28
- data/lib/tina4/cors.rb +1 -1
- data/lib/tina4/database.rb +230 -26
- data/lib/tina4/database_result.rb +122 -8
- data/lib/tina4/dev_mailbox.rb +1 -1
- data/lib/tina4/env.rb +1 -1
- data/lib/tina4/frond.rb +314 -7
- 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/localization.rb +1 -1
- data/lib/tina4/messenger.rb +111 -33
- data/lib/tina4/middleware.rb +349 -1
- data/lib/tina4/migration.rb +132 -11
- data/lib/tina4/orm.rb +149 -18
- data/lib/tina4/public/js/tina4-dev-admin.min.js +1 -1
- data/lib/tina4/public/js/tina4js.min.js +47 -0
- data/lib/tina4/query_builder.rb +374 -0
- data/lib/tina4/queue.rb +219 -61
- data/lib/tina4/queue_backends/lite_backend.rb +42 -7
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -0
- data/lib/tina4/rack_app.rb +200 -11
- data/lib/tina4/request.rb +14 -1
- data/lib/tina4/response.rb +26 -0
- data/lib/tina4/response_cache.rb +446 -29
- data/lib/tina4/router.rb +127 -0
- data/lib/tina4/service_runner.rb +1 -1
- data/lib/tina4/session.rb +6 -1
- data/lib/tina4/session_handlers/database_handler.rb +66 -0
- data/lib/tina4/swagger.rb +1 -1
- data/lib/tina4/templates/errors/404.twig +2 -2
- data/lib/tina4/templates/errors/500.twig +1 -1
- data/lib/tina4/validator.rb +174 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +23 -4
- data/lib/tina4/websocket_backplane.rb +118 -0
- data/lib/tina4.rb +126 -5
- metadata +40 -3
data/lib/tina4/service_runner.rb
CHANGED
|
@@ -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"] ||
|
|
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
|
-
|
|
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
|
|
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
|
|
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; }
|
|
@@ -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
data/lib/tina4/websocket.rb
CHANGED
|
@@ -44,7 +44,8 @@ module Tina4
|
|
|
44
44
|
socket.write(response)
|
|
45
45
|
|
|
46
46
|
conn_id = SecureRandom.hex(16)
|
|
47
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
298
|
-
|
|
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
|
|