tina4ruby 3.10.91 → 3.10.92
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/lib/tina4/ai.rb +2 -2
- data/lib/tina4/auto_crud.rb +27 -0
- data/lib/tina4/cli.rb +1 -1
- data/lib/tina4/dev_admin.rb +18 -1
- data/lib/tina4/env.rb +1 -1
- data/lib/tina4/error_overlay.rb +6 -6
- data/lib/tina4/html_element.rb +22 -0
- data/lib/tina4/log.rb +5 -5
- data/lib/tina4/mcp.rb +1 -1
- data/lib/tina4/messenger.rb +26 -1
- data/lib/tina4/middleware.rb +28 -0
- data/lib/tina4/orm.rb +4 -10
- data/lib/tina4/query_builder.rb +2 -2
- data/lib/tina4/rack_app.rb +3 -3
- data/lib/tina4/rate_limiter.rb +8 -1
- data/lib/tina4/request.rb +2 -2
- data/lib/tina4/response.rb +75 -9
- data/lib/tina4/scss_compiler.rb +51 -7
- data/lib/tina4/service_runner.rb +1 -1
- data/lib/tina4/swagger.rb +3 -2
- data/lib/tina4/testing.rb +137 -10
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +11 -0
- data/lib/tina4/websocket.rb +37 -16
- data/lib/tina4/websocket_backplane.rb +2 -2
- data/lib/tina4/wsdl.rb +2 -1
- data/lib/tina4.rb +4 -4
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 872c1e0d5dbae634259ad2772d69630f7160784967c03cb0d5ec68da3b122673
|
|
4
|
+
data.tar.gz: c08ab7a877b583e3141dc523125bc236e6e548f2acc5b757114074b3547b27c2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2657a571cda5d98dcd0e46a0db670b6bcb027d23376d8b198413ad3b9c2f8bc56bec600fff1f77d71a0cf33ca51e632c3f4923f3caf67a8b40fd80125432f3b3
|
|
7
|
+
data.tar.gz: ecca10a04db72af6d1e7f42d60c1509c9d98ccb516d2635ecfa78bc48d2def7ac63dbb14092fa27e9c668fea1f2c57982c280210e56f755a6d9ccc122f326747
|
data/lib/tina4/ai.rb
CHANGED
|
@@ -29,7 +29,7 @@ module Tina4
|
|
|
29
29
|
# @param root [String] project root directory
|
|
30
30
|
# @param tool [Hash] tool entry from AI_TOOLS
|
|
31
31
|
# @return [Boolean]
|
|
32
|
-
def
|
|
32
|
+
def is_installed(root, tool)
|
|
33
33
|
File.exist?(File.join(File.expand_path(root), tool[:context_file]))
|
|
34
34
|
end
|
|
35
35
|
|
|
@@ -44,7 +44,7 @@ module Tina4
|
|
|
44
44
|
|
|
45
45
|
puts "\n Tina4 AI Context Installer\n"
|
|
46
46
|
AI_TOOLS.each_with_index do |tool, i|
|
|
47
|
-
marker =
|
|
47
|
+
marker = is_installed(root, tool) ? " #{green}[installed]#{reset}" : ""
|
|
48
48
|
puts format(" %d. %-20s %s%s", i + 1, tool[:description], tool[:context_file], marker)
|
|
49
49
|
end
|
|
50
50
|
|
data/lib/tina4/auto_crud.rb
CHANGED
|
@@ -194,10 +194,37 @@ module Tina4
|
|
|
194
194
|
}, swagger_meta: { summary: "Delete #{pretty_name}", tags: [table.to_s] })
|
|
195
195
|
end
|
|
196
196
|
|
|
197
|
+
# Discover ORM model classes from a directory and register them.
|
|
198
|
+
#
|
|
199
|
+
# @param models_dir [String] directory to scan (default "src/orm")
|
|
200
|
+
# @param prefix [String] URL prefix for generated routes (default "/api")
|
|
201
|
+
# @return [Array<String>] list of discovered model class names
|
|
202
|
+
def discover(models_dir = "src/orm", prefix: "/api")
|
|
203
|
+
discovered = []
|
|
204
|
+
return discovered unless Dir.exist?(models_dir)
|
|
205
|
+
|
|
206
|
+
Dir.glob(File.join(models_dir, "*.rb")).each do |file|
|
|
207
|
+
require_relative File.expand_path(file)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Find all ORM subclasses that have a table_name
|
|
211
|
+
ObjectSpace.each_object(Class).select { |c| c < Tina4::ORM rescue false }.each do |klass|
|
|
212
|
+
next unless klass.respond_to?(:table_name) && klass.table_name
|
|
213
|
+
register(klass)
|
|
214
|
+
discovered << klass.name
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
generate_routes(prefix: prefix) unless discovered.empty?
|
|
218
|
+
discovered
|
|
219
|
+
end
|
|
220
|
+
|
|
197
221
|
def clear!
|
|
198
222
|
@models = []
|
|
199
223
|
end
|
|
200
224
|
|
|
225
|
+
# Alias for parity with other frameworks
|
|
226
|
+
alias_method :clear, :clear!
|
|
227
|
+
|
|
201
228
|
private
|
|
202
229
|
|
|
203
230
|
# Parse sort parameter: "-name,created_at" => "name DESC, created_at ASC"
|
data/lib/tina4/cli.rb
CHANGED
|
@@ -204,7 +204,7 @@ module Tina4
|
|
|
204
204
|
|
|
205
205
|
app = Tina4::RackApp.new(root_dir: root_dir)
|
|
206
206
|
|
|
207
|
-
is_debug = Tina4::Env.
|
|
207
|
+
is_debug = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
|
|
208
208
|
|
|
209
209
|
# Use Puma only when explicitly requested via --production flag
|
|
210
210
|
# WEBrick is used for development (supports dev toolbar/reload)
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -122,6 +122,7 @@ module Tina4
|
|
|
122
122
|
def initialize
|
|
123
123
|
@mutex = Mutex.new
|
|
124
124
|
@errors = nil # lazy-loaded
|
|
125
|
+
@registered = false
|
|
125
126
|
@store_path = File.join(
|
|
126
127
|
Dir.tmpdir,
|
|
127
128
|
"tina4_dev_errors_#{Digest::MD5.hexdigest(Dir.pwd)}.json"
|
|
@@ -232,10 +233,26 @@ module Tina4
|
|
|
232
233
|
end
|
|
233
234
|
end
|
|
234
235
|
|
|
236
|
+
# Register Ruby error handlers to feed the tracker.
|
|
237
|
+
# Installs an at_exit hook that captures unhandled exceptions.
|
|
238
|
+
# Safe to call multiple times — only registers once.
|
|
239
|
+
def register
|
|
240
|
+
return if @registered
|
|
241
|
+
|
|
242
|
+
@registered = true
|
|
243
|
+
tracker = self
|
|
244
|
+
at_exit do
|
|
245
|
+
if (exc = $!) && !exc.is_a?(SystemExit)
|
|
246
|
+
tracker.capture_exception(exc)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
235
251
|
# Reset (for testing).
|
|
236
252
|
def reset!
|
|
237
253
|
@mutex.synchronize do
|
|
238
254
|
@errors = {}
|
|
255
|
+
@registered = false
|
|
239
256
|
File.delete(@store_path) if File.exist?(@store_path)
|
|
240
257
|
end
|
|
241
258
|
end
|
|
@@ -294,7 +311,7 @@ module Tina4
|
|
|
294
311
|
end
|
|
295
312
|
|
|
296
313
|
def enabled?
|
|
297
|
-
Tina4::Env.
|
|
314
|
+
Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
|
|
298
315
|
end
|
|
299
316
|
|
|
300
317
|
# Handle a /__dev request; returns [status, headers, body] or nil if not a dev path
|
data/lib/tina4/env.rb
CHANGED
|
@@ -16,7 +16,7 @@ module Tina4
|
|
|
16
16
|
#
|
|
17
17
|
# Accepts: "true", "True", "TRUE", "1", "yes", "Yes", "YES", "on", "On", "ON".
|
|
18
18
|
# Everything else is falsy (including empty string, nil, not set).
|
|
19
|
-
def self.
|
|
19
|
+
def self.is_truthy(val)
|
|
20
20
|
%w[true 1 yes on].include?(val.to_s.strip.downcase)
|
|
21
21
|
end
|
|
22
22
|
|
data/lib/tina4/error_overlay.rb
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
# begin
|
|
9
9
|
# handler.call(request, response)
|
|
10
10
|
# rescue => e
|
|
11
|
-
# Tina4::ErrorOverlay.
|
|
11
|
+
# Tina4::ErrorOverlay.render_error_overlay(e, request: env)
|
|
12
12
|
# end
|
|
13
13
|
#
|
|
14
14
|
# Only activate when TINA4_DEBUG is true.
|
|
15
|
-
# In production, call Tina4::ErrorOverlay.
|
|
15
|
+
# In production, call Tina4::ErrorOverlay.render_production_error instead.
|
|
16
16
|
|
|
17
17
|
module Tina4
|
|
18
18
|
module ErrorOverlay
|
|
@@ -38,7 +38,7 @@ module Tina4
|
|
|
38
38
|
# @param exception [Exception] the caught exception
|
|
39
39
|
# @param request [Hash, nil] optional request details (Rack env or custom hash)
|
|
40
40
|
# @return [String] complete HTML page
|
|
41
|
-
def
|
|
41
|
+
def render_error_overlay(exception, request: nil)
|
|
42
42
|
exc_type = exception.class.name
|
|
43
43
|
exc_msg = exception.message
|
|
44
44
|
|
|
@@ -113,7 +113,7 @@ module Tina4
|
|
|
113
113
|
end
|
|
114
114
|
|
|
115
115
|
# Render a safe, generic error page for production.
|
|
116
|
-
def
|
|
116
|
+
def render_production_error(status_code: 500, message: "Internal Server Error", path: "")
|
|
117
117
|
# Determine color based on status code
|
|
118
118
|
code_color = case status_code
|
|
119
119
|
when 403 then "#f59e0b"
|
|
@@ -155,8 +155,8 @@ module Tina4
|
|
|
155
155
|
end
|
|
156
156
|
|
|
157
157
|
# Return true if TINA4_DEBUG is enabled.
|
|
158
|
-
def
|
|
159
|
-
Tina4::Env.
|
|
158
|
+
def is_debug_mode
|
|
159
|
+
Tina4::Env.is_truthy(ENV.fetch("TINA4_DEBUG", ""))
|
|
160
160
|
end
|
|
161
161
|
|
|
162
162
|
private
|
data/lib/tina4/html_element.rb
CHANGED
|
@@ -145,4 +145,26 @@ module Tina4
|
|
|
145
145
|
def self.html_helpers
|
|
146
146
|
HtmlHelpers
|
|
147
147
|
end
|
|
148
|
+
|
|
149
|
+
# Inject _div(), _p(), _a(), etc. helper methods into the given namespace (hash or object).
|
|
150
|
+
#
|
|
151
|
+
# Usage:
|
|
152
|
+
# h = {}
|
|
153
|
+
# Tina4.add_html_helpers(h)
|
|
154
|
+
# h[:_div].call({ class: "card" }, h[:_p].call("Hello"))
|
|
155
|
+
#
|
|
156
|
+
def self.add_html_helpers(namespace)
|
|
157
|
+
helper = Object.new.extend(HtmlHelpers)
|
|
158
|
+
|
|
159
|
+
HtmlElement::HTML_TAGS.each do |tag|
|
|
160
|
+
name = "_#{tag}"
|
|
161
|
+
fn = helper.method(name.to_sym)
|
|
162
|
+
|
|
163
|
+
if namespace.is_a?(Hash)
|
|
164
|
+
namespace[name.to_sym] = fn
|
|
165
|
+
else
|
|
166
|
+
namespace.define_singleton_method(name.to_sym, &fn)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
148
170
|
end
|
data/lib/tina4/log.rb
CHANGED
|
@@ -30,7 +30,7 @@ module Tina4
|
|
|
30
30
|
class << self
|
|
31
31
|
attr_reader :log_dir
|
|
32
32
|
|
|
33
|
-
def
|
|
33
|
+
def configure(root_dir = Dir.pwd)
|
|
34
34
|
@log_dir = File.join(root_dir, "logs")
|
|
35
35
|
FileUtils.mkdir_p(@log_dir)
|
|
36
36
|
|
|
@@ -55,7 +55,7 @@ module Tina4
|
|
|
55
55
|
@mutex.synchronize { @request_id = nil }
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
def
|
|
58
|
+
def get_request_id
|
|
59
59
|
@mutex.synchronize { @request_id }
|
|
60
60
|
end
|
|
61
61
|
|
|
@@ -87,7 +87,7 @@ module Tina4
|
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
def log(level, message, context = {})
|
|
90
|
-
|
|
90
|
+
configure unless @initialized
|
|
91
91
|
@current_context = context.is_a?(Hash) ? context : {}
|
|
92
92
|
|
|
93
93
|
formatted = format_line(level, message)
|
|
@@ -135,7 +135,7 @@ module Tina4
|
|
|
135
135
|
def format_line(level, message)
|
|
136
136
|
level_str = severity_to_level(level)
|
|
137
137
|
ts = utc_timestamp
|
|
138
|
-
rid =
|
|
138
|
+
rid = get_request_id
|
|
139
139
|
rid_str = rid ? " [#{rid}]" : ""
|
|
140
140
|
ctx = @current_context && !@current_context.empty? ? " #{JSON.generate(@current_context)}" : ""
|
|
141
141
|
"#{ts} [#{level_str.ljust(7)}]#{rid_str} #{message}#{ctx}"
|
|
@@ -148,7 +148,7 @@ module Tina4
|
|
|
148
148
|
level: level_str,
|
|
149
149
|
message: message
|
|
150
150
|
}
|
|
151
|
-
rid =
|
|
151
|
+
rid = get_request_id
|
|
152
152
|
entry[:request_id] = rid if rid
|
|
153
153
|
entry[:context] = @current_context if @current_context && !@current_context.empty?
|
|
154
154
|
JSON.generate(entry)
|
data/lib/tina4/mcp.rb
CHANGED
data/lib/tina4/messenger.rb
CHANGED
|
@@ -194,6 +194,31 @@ module Tina4
|
|
|
194
194
|
[]
|
|
195
195
|
end
|
|
196
196
|
|
|
197
|
+
# Mark a message as read (set \Seen flag).
|
|
198
|
+
#
|
|
199
|
+
# @param uid [String, Integer] message UID
|
|
200
|
+
# @param folder [String] IMAP folder name
|
|
201
|
+
def mark_read(uid, folder: "INBOX")
|
|
202
|
+
imap_connect do |imap|
|
|
203
|
+
imap.select(folder)
|
|
204
|
+
imap.uid_store(uid.to_i, "+FLAGS", [:Seen])
|
|
205
|
+
end
|
|
206
|
+
rescue => e
|
|
207
|
+
Tina4::Log.error("IMAP mark_read failed: #{e.message}")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Test IMAP connectivity without reading messages.
|
|
211
|
+
#
|
|
212
|
+
# @return [Hash] { success: Boolean, message: String }
|
|
213
|
+
def test_imap_connection
|
|
214
|
+
imap_connect do |_imap|
|
|
215
|
+
# Connection succeeded
|
|
216
|
+
end
|
|
217
|
+
{ success: true, message: "Connected to #{@imap_host}:#{@imap_port}" }
|
|
218
|
+
rescue => e
|
|
219
|
+
{ success: false, message: "IMAP connection failed: #{e.message}" }
|
|
220
|
+
end
|
|
221
|
+
|
|
197
222
|
private
|
|
198
223
|
|
|
199
224
|
# ── SMTP helpers ─────────────────────────────────────────────────────
|
|
@@ -515,7 +540,7 @@ module Tina4
|
|
|
515
540
|
# Factory: returns a DevMailbox-intercepting messenger in dev mode,
|
|
516
541
|
# or a real Messenger in production.
|
|
517
542
|
def self.create_messenger(**options)
|
|
518
|
-
dev_mode = Tina4::Env.
|
|
543
|
+
dev_mode = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
|
|
519
544
|
|
|
520
545
|
smtp_configured = (ENV["TINA4_MAIL_HOST"] && !ENV["TINA4_MAIL_HOST"].empty?) ||
|
|
521
546
|
(ENV["SMTP_HOST"] && !ENV["SMTP_HOST"].empty?)
|
data/lib/tina4/middleware.rb
CHANGED
|
@@ -156,6 +156,12 @@ module Tina4
|
|
|
156
156
|
}
|
|
157
157
|
end
|
|
158
158
|
|
|
159
|
+
def is_preflight(request)
|
|
160
|
+
request.method&.upcase == "OPTIONS" &&
|
|
161
|
+
request.headers["origin"] &&
|
|
162
|
+
request.headers["access-control-request-method"]
|
|
163
|
+
end
|
|
164
|
+
|
|
159
165
|
def resolve_origin(request, config)
|
|
160
166
|
request_origin = request.headers["origin"] || request.headers["referer"]
|
|
161
167
|
|
|
@@ -219,6 +225,28 @@ module Tina4
|
|
|
219
225
|
[request, response]
|
|
220
226
|
end
|
|
221
227
|
|
|
228
|
+
def check(ip)
|
|
229
|
+
limit = (ENV["TINA4_RATE_LIMIT"] || 100).to_i
|
|
230
|
+
window = (ENV["TINA4_RATE_WINDOW"] || 60).to_i
|
|
231
|
+
now = Time.now
|
|
232
|
+
|
|
233
|
+
@mutex.synchronize do
|
|
234
|
+
@store[ip] ||= []
|
|
235
|
+
entries = @store[ip]
|
|
236
|
+
entries.reject! { |t| t < now - window }
|
|
237
|
+
|
|
238
|
+
remaining = [limit - entries.length, 0].max
|
|
239
|
+
reset_at = entries.empty? ? window : (entries.first + window - now).ceil
|
|
240
|
+
|
|
241
|
+
if entries.length >= limit
|
|
242
|
+
return [false, { limit: limit, remaining: 0, reset: reset_at, window: window }]
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
entries << now
|
|
246
|
+
[true, { limit: limit, remaining: remaining - 1, reset: window, window: window }]
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
222
250
|
# Allow resetting state (useful in tests)
|
|
223
251
|
def reset!
|
|
224
252
|
@mutex.synchronize { @store.clear }
|
data/lib/tina4/orm.rb
CHANGED
|
@@ -124,7 +124,7 @@ module Tina4
|
|
|
124
124
|
#
|
|
125
125
|
# @return [Tina4::QueryBuilder]
|
|
126
126
|
def query # -> QueryBuilder
|
|
127
|
-
QueryBuilder.
|
|
127
|
+
QueryBuilder.from_table(table_name, db: db)
|
|
128
128
|
end
|
|
129
129
|
|
|
130
130
|
# Find records by filter dict. Always returns an array.
|
|
@@ -732,7 +732,7 @@ module Tina4
|
|
|
732
732
|
|
|
733
733
|
# ── Imperative relationship methods (ad-hoc, like Python/PHP/Node) ──
|
|
734
734
|
|
|
735
|
-
def
|
|
735
|
+
def has_one(related_class, foreign_key: nil)
|
|
736
736
|
pk = self.class.primary_key_field || :id
|
|
737
737
|
pk_value = __send__(pk)
|
|
738
738
|
return nil unless pk_value
|
|
@@ -744,7 +744,7 @@ module Tina4
|
|
|
744
744
|
result ? related_class.from_hash(result) : nil
|
|
745
745
|
end
|
|
746
746
|
|
|
747
|
-
def
|
|
747
|
+
def has_many(related_class, foreign_key: nil, limit: 100, offset: 0)
|
|
748
748
|
pk = self.class.primary_key_field || :id
|
|
749
749
|
pk_value = __send__(pk)
|
|
750
750
|
return [] unless pk_value
|
|
@@ -757,18 +757,12 @@ module Tina4
|
|
|
757
757
|
results.map { |row| related_class.from_hash(row) }
|
|
758
758
|
end
|
|
759
759
|
|
|
760
|
-
def
|
|
760
|
+
def belongs_to(related_class, foreign_key: nil)
|
|
761
761
|
fk = foreign_key || "#{related_class.name.split('::').last.downcase}_id"
|
|
762
762
|
fk_value = respond_to?(fk.to_sym) ? __send__(fk.to_sym) : nil
|
|
763
763
|
return nil unless fk_value
|
|
764
764
|
|
|
765
765
|
related_class.find_by_id(fk_value)
|
|
766
766
|
end
|
|
767
|
-
|
|
768
|
-
# Instance-level aliases matching Python/PHP/Node.js naming
|
|
769
|
-
# These are imperative relationship queries (not class-level declarations)
|
|
770
|
-
alias imperative_has_one query_has_one
|
|
771
|
-
alias imperative_has_many query_has_many
|
|
772
|
-
alias imperative_belongs_to query_belongs_to
|
|
773
767
|
end
|
|
774
768
|
end
|
data/lib/tina4/query_builder.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Tina4
|
|
|
5
5
|
#
|
|
6
6
|
# Usage:
|
|
7
7
|
# # Standalone
|
|
8
|
-
# result = Tina4::QueryBuilder.
|
|
8
|
+
# result = Tina4::QueryBuilder.from_table("users", db: db)
|
|
9
9
|
# .select("id", "name")
|
|
10
10
|
# .where("active = ?", [1])
|
|
11
11
|
# .order_by("name ASC")
|
|
@@ -39,7 +39,7 @@ module Tina4
|
|
|
39
39
|
# @param table_name [String] The database table name.
|
|
40
40
|
# @param db [Object, nil] Optional database connection.
|
|
41
41
|
# @return [QueryBuilder]
|
|
42
|
-
def self.
|
|
42
|
+
def self.from_table(table_name, db: nil)
|
|
43
43
|
new(table_name, db: db)
|
|
44
44
|
end
|
|
45
45
|
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -589,7 +589,7 @@ module Tina4
|
|
|
589
589
|
Tina4::Log.error(error.backtrace&.first(10)&.join("\n"))
|
|
590
590
|
if dev_mode?
|
|
591
591
|
# Rich error overlay with stack trace, source context, and line numbers
|
|
592
|
-
body = Tina4::ErrorOverlay.
|
|
592
|
+
body = Tina4::ErrorOverlay.render_error_overlay(error, request: env)
|
|
593
593
|
else
|
|
594
594
|
body = Tina4::Template.render_error(500, {
|
|
595
595
|
"error_message" => "#{error.message}\n#{error.backtrace&.first(10)&.join("\n")}",
|
|
@@ -600,7 +600,7 @@ module Tina4
|
|
|
600
600
|
end
|
|
601
601
|
|
|
602
602
|
def dev_mode?
|
|
603
|
-
Tina4::Env.
|
|
603
|
+
Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
|
|
604
604
|
end
|
|
605
605
|
|
|
606
606
|
def websocket_upgrade?(env)
|
|
@@ -652,7 +652,7 @@ module Tina4
|
|
|
652
652
|
method = request_info[:method]
|
|
653
653
|
path = request_info[:path]
|
|
654
654
|
matched_pattern = request_info[:matched_pattern]
|
|
655
|
-
request_id = Tina4::Log.
|
|
655
|
+
request_id = Tina4::Log.get_request_id || "-"
|
|
656
656
|
route_count = Tina4::Router.routes.length
|
|
657
657
|
|
|
658
658
|
ai_badge = ai_port ? '<span style="background:#7c3aed;color:#fff;font-size:10px;padding:1px 6px;border-radius:3px;font-weight:bold;">AI PORT</span>' : ""
|
data/lib/tina4/rate_limiter.rb
CHANGED
|
@@ -86,8 +86,15 @@ module Tina4
|
|
|
86
86
|
true
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
+
# Standardized middleware hook — enforces rate limiting before the route handler.
|
|
90
|
+
def before_rate_limit(request, response)
|
|
91
|
+
ip = request.respond_to?(:ip) ? request.ip : "unknown"
|
|
92
|
+
apply(ip, response)
|
|
93
|
+
[request, response]
|
|
94
|
+
end
|
|
95
|
+
|
|
89
96
|
# Reset tracking for a specific IP (useful for testing)
|
|
90
|
-
def reset
|
|
97
|
+
def reset(ip = nil)
|
|
91
98
|
@mutex.synchronize do
|
|
92
99
|
if ip
|
|
93
100
|
@store.delete(ip)
|
data/lib/tina4/request.rb
CHANGED
|
@@ -126,8 +126,8 @@ module Tina4
|
|
|
126
126
|
end
|
|
127
127
|
|
|
128
128
|
# Look up a param by symbol or string key (indifferent access shortcut).
|
|
129
|
-
def param(key)
|
|
130
|
-
params[key.to_s] || params[key.to_sym]
|
|
129
|
+
def param(key, default = nil)
|
|
130
|
+
params[key.to_s] || params[key.to_sym] || default
|
|
131
131
|
end
|
|
132
132
|
|
|
133
133
|
def [](key)
|
data/lib/tina4/response.rb
CHANGED
|
@@ -3,6 +3,37 @@ require "json"
|
|
|
3
3
|
require "uri"
|
|
4
4
|
|
|
5
5
|
module Tina4
|
|
6
|
+
# ---------------------------------------------------------------------------
|
|
7
|
+
# Global Frond template engine registry
|
|
8
|
+
# ---------------------------------------------------------------------------
|
|
9
|
+
@_global_frond = nil
|
|
10
|
+
@_framework_frond = nil
|
|
11
|
+
|
|
12
|
+
# Return the global Frond engine, creating a default if needed.
|
|
13
|
+
def self.get_frond
|
|
14
|
+
@_global_frond ||= Tina4::Frond.new(template_dir: "src/templates")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Return the singleton Frond engine for built-in framework templates.
|
|
18
|
+
def self.get_framework_frond
|
|
19
|
+
framework_dir = ::File.join(::File.dirname(__FILE__), "templates")
|
|
20
|
+
if @_framework_frond.nil? && ::File.directory?(framework_dir)
|
|
21
|
+
@_framework_frond = Tina4::Frond.new(template_dir: framework_dir)
|
|
22
|
+
end
|
|
23
|
+
# Sync custom filters/globals from the user engine
|
|
24
|
+
if @_framework_frond
|
|
25
|
+
user_engine = get_frond
|
|
26
|
+
@_framework_frond.instance_variable_get(:@filters).merge!(user_engine.instance_variable_get(:@filters))
|
|
27
|
+
@_framework_frond.instance_variable_get(:@globals).merge!(user_engine.instance_variable_get(:@globals))
|
|
28
|
+
end
|
|
29
|
+
@_framework_frond
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Register a pre-configured Frond engine for response.render().
|
|
33
|
+
def self.set_frond(engine)
|
|
34
|
+
@_global_frond = engine
|
|
35
|
+
end
|
|
36
|
+
|
|
6
37
|
class Response
|
|
7
38
|
MIME_TYPES = {
|
|
8
39
|
".html" => "text/html", ".htm" => "text/html",
|
|
@@ -123,12 +154,38 @@ module Tina4
|
|
|
123
154
|
def render(template_path, data = {}, status: 200, template_dir: nil)
|
|
124
155
|
@status_code = status
|
|
125
156
|
@headers["content-type"] = HTML_CONTENT_TYPE
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
157
|
+
|
|
158
|
+
engine = template_dir ? Tina4::Frond.new(template_dir: template_dir) : Tina4.get_frond
|
|
159
|
+
|
|
160
|
+
# Try user templates first
|
|
161
|
+
begin
|
|
162
|
+
@body = engine.render(template_path, data)
|
|
163
|
+
return self
|
|
164
|
+
rescue Errno::ENOENT
|
|
165
|
+
# Not found in user templates — try framework templates
|
|
166
|
+
rescue => e
|
|
167
|
+
@body = "<pre>Template error: #{e.message}</pre>"
|
|
168
|
+
@status_code = 500
|
|
169
|
+
return self
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Fallback: framework templates
|
|
173
|
+
fw_engine = Tina4.get_framework_frond
|
|
174
|
+
if fw_engine
|
|
175
|
+
begin
|
|
176
|
+
@body = fw_engine.render(template_path, data)
|
|
177
|
+
return self
|
|
178
|
+
rescue Errno::ENOENT
|
|
179
|
+
# Not found in framework templates either
|
|
180
|
+
rescue => e
|
|
181
|
+
@body = "<pre>Template error: #{e.message}</pre>"
|
|
182
|
+
@status_code = 500
|
|
183
|
+
return self
|
|
184
|
+
end
|
|
131
185
|
end
|
|
186
|
+
|
|
187
|
+
@body = "<pre>Template not found: #{template_path}</pre>"
|
|
188
|
+
@status_code = 404
|
|
132
189
|
self
|
|
133
190
|
end
|
|
134
191
|
|
|
@@ -152,9 +209,9 @@ module Tina4
|
|
|
152
209
|
# Build a standard error envelope hash (class method).
|
|
153
210
|
#
|
|
154
211
|
# Usage:
|
|
155
|
-
# response.json(Tina4::Response.
|
|
212
|
+
# response.json(Tina4::Response.error_response("NOT_FOUND", "Resource not found", 404), status: 404)
|
|
156
213
|
#
|
|
157
|
-
def self.
|
|
214
|
+
def self.error_response(code, message, status = 400)
|
|
158
215
|
{ error: true, code: code, message: message, status: status }
|
|
159
216
|
end
|
|
160
217
|
|
|
@@ -231,8 +288,17 @@ module Tina4
|
|
|
231
288
|
self
|
|
232
289
|
end
|
|
233
290
|
|
|
234
|
-
#
|
|
235
|
-
def send
|
|
291
|
+
# Finalize and return the response — matches Python/Node API.
|
|
292
|
+
def send(data = nil, status_code: nil, content_type: nil)
|
|
293
|
+
if data
|
|
294
|
+
if data.is_a?(Hash) || data.is_a?(Array)
|
|
295
|
+
return json(data, status_code || 200)
|
|
296
|
+
end
|
|
297
|
+
@headers["content-type"] = content_type if content_type
|
|
298
|
+
@body = data.to_s
|
|
299
|
+
@status_code = status_code if status_code
|
|
300
|
+
return self
|
|
301
|
+
end
|
|
236
302
|
to_rack
|
|
237
303
|
end
|
|
238
304
|
|
data/lib/tina4/scss_compiler.rb
CHANGED
|
@@ -6,7 +6,37 @@ module Tina4
|
|
|
6
6
|
SCSS_DIRS = %w[src/scss scss src/styles styles].freeze
|
|
7
7
|
CSS_OUTPUT = "public/css"
|
|
8
8
|
|
|
9
|
+
# Module-level state for import paths and variables
|
|
10
|
+
@import_paths = []
|
|
11
|
+
@variables = {}
|
|
12
|
+
|
|
9
13
|
class << self
|
|
14
|
+
# Add a search path for @import resolution.
|
|
15
|
+
def add_import_path(path)
|
|
16
|
+
@import_paths ||= []
|
|
17
|
+
@import_paths << path
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Set a variable that will be available during compilation.
|
|
21
|
+
def set_variable(name, value)
|
|
22
|
+
@variables ||= {}
|
|
23
|
+
name = name.sub(/^\$/, "")
|
|
24
|
+
@variables[name] = value
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Compile an SCSS string to CSS.
|
|
28
|
+
def compile(source)
|
|
29
|
+
@variables ||= {}
|
|
30
|
+
@import_paths ||= []
|
|
31
|
+
# Inject preset variables
|
|
32
|
+
unless @variables.empty?
|
|
33
|
+
var_block = @variables.map { |k, v| "$#{k}: #{v};" }.join("\n")
|
|
34
|
+
source = "#{var_block}\n#{source}"
|
|
35
|
+
end
|
|
36
|
+
basic_compile(source, @import_paths.first || Dir.pwd)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Compile all .scss files from known directories.
|
|
10
40
|
def compile_all(root_dir = Dir.pwd)
|
|
11
41
|
output_dir = File.join(root_dir, CSS_OUTPUT)
|
|
12
42
|
FileUtils.mkdir_p(output_dir)
|
|
@@ -22,20 +52,28 @@ module Tina4
|
|
|
22
52
|
end
|
|
23
53
|
end
|
|
24
54
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
55
|
+
# Compile a single SCSS file. If output_dir is provided, writes CSS there.
|
|
56
|
+
# Always returns the compiled CSS string.
|
|
57
|
+
def compile_file(scss_file, output_dir = nil, base_dir = nil)
|
|
58
|
+
base_dir ||= File.dirname(scss_file)
|
|
30
59
|
scss_content = File.read(scss_file)
|
|
31
60
|
css_content = compile_scss(scss_content, File.dirname(scss_file))
|
|
32
|
-
File.write(css_file, css_content)
|
|
33
61
|
|
|
34
|
-
|
|
62
|
+
if output_dir
|
|
63
|
+
relative = scss_file.sub(base_dir, "").sub(/\.scss$/, ".css")
|
|
64
|
+
css_file = File.join(output_dir, relative)
|
|
65
|
+
FileUtils.mkdir_p(File.dirname(css_file))
|
|
66
|
+
File.write(css_file, css_content)
|
|
67
|
+
Tina4::Log.debug("Compiled SCSS: #{scss_file} -> #{css_file}")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
css_content
|
|
35
71
|
rescue => e
|
|
36
72
|
Tina4::Log.error("SCSS compilation failed: #{scss_file} - #{e.message}")
|
|
73
|
+
""
|
|
37
74
|
end
|
|
38
75
|
|
|
76
|
+
# Compile an SCSS content string with a base directory for import resolution.
|
|
39
77
|
def compile_scss(content, base_dir)
|
|
40
78
|
# Try sassc gem first
|
|
41
79
|
begin
|
|
@@ -84,6 +122,12 @@ module Tina4
|
|
|
84
122
|
File.join(base_dir, "_#{import_path}.scss"),
|
|
85
123
|
File.join(base_dir, import_path)
|
|
86
124
|
]
|
|
125
|
+
# Also search additional import paths
|
|
126
|
+
(@import_paths || []).each do |search_path|
|
|
127
|
+
candidates << File.join(search_path, "#{import_path}.scss")
|
|
128
|
+
candidates << File.join(search_path, "_#{import_path}.scss")
|
|
129
|
+
candidates << File.join(search_path, import_path)
|
|
130
|
+
end
|
|
87
131
|
found = candidates.find { |c| File.exist?(c) }
|
|
88
132
|
if found
|
|
89
133
|
imported = File.read(found)
|
data/lib/tina4/service_runner.rb
CHANGED
data/lib/tina4/swagger.rb
CHANGED
|
@@ -4,9 +4,10 @@ require "json"
|
|
|
4
4
|
module Tina4
|
|
5
5
|
module Swagger
|
|
6
6
|
class << self
|
|
7
|
-
def generate
|
|
7
|
+
def generate(routes = [])
|
|
8
8
|
spec = base_spec
|
|
9
|
-
Tina4::Router.routes
|
|
9
|
+
route_list = routes.empty? ? Tina4::Router.routes : routes
|
|
10
|
+
route_list.each do |route|
|
|
10
11
|
add_route_to_spec(spec, route)
|
|
11
12
|
end
|
|
12
13
|
spec
|
data/lib/tina4/testing.rb
CHANGED
|
@@ -15,6 +15,7 @@ module Tina4
|
|
|
15
15
|
|
|
16
16
|
def reset!
|
|
17
17
|
@suites = []
|
|
18
|
+
@inline_registry = []
|
|
18
19
|
@results = { passed: 0, failed: 0, errors: 0, tests: [] }
|
|
19
20
|
end
|
|
20
21
|
|
|
@@ -24,43 +25,159 @@ module Tina4
|
|
|
24
25
|
suites << suite
|
|
25
26
|
end
|
|
26
27
|
|
|
27
|
-
def run_all
|
|
28
|
+
def run_all(quiet: false, failfast: false)
|
|
28
29
|
reset_results
|
|
29
30
|
suites.each do |suite|
|
|
30
|
-
run_suite(suite)
|
|
31
|
+
run_suite(suite, quiet: quiet, failfast: failfast)
|
|
32
|
+
break if failfast && results[:failed] > 0
|
|
31
33
|
end
|
|
32
|
-
|
|
34
|
+
# Run inline-registered tests
|
|
35
|
+
inline_registry.each do |entry|
|
|
36
|
+
run_inline_entry(entry, quiet: quiet)
|
|
37
|
+
break if failfast && results[:failed] > 0
|
|
38
|
+
end
|
|
39
|
+
print_results unless quiet
|
|
33
40
|
results
|
|
34
41
|
end
|
|
35
42
|
|
|
43
|
+
# ── Inline testing (parity with Python/PHP/Node decorator pattern) ──
|
|
44
|
+
|
|
45
|
+
# Assertion builder: assert_equal(args, expected)
|
|
46
|
+
def assert_equal(args, expected)
|
|
47
|
+
{ type: :equal, args: args, expected: expected }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Assertion builder: assert_raises(exception_class, args)
|
|
51
|
+
def assert_raises(exception_class, args)
|
|
52
|
+
{ type: :raises, exception: exception_class, args: args }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Assertion builder: assert_true(args)
|
|
56
|
+
def assert_true(args)
|
|
57
|
+
{ type: :true, args: args }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Assertion builder: assert_false(args)
|
|
61
|
+
def assert_false(args)
|
|
62
|
+
{ type: :false, args: args }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Register a callable with inline assertions (mirrors Python's @tests decorator).
|
|
66
|
+
#
|
|
67
|
+
# Tina4::Testing.tests(
|
|
68
|
+
# Tina4::Testing.assert_equal([5, 3], 8),
|
|
69
|
+
# Tina4::Testing.assert_raises(ArgumentError, [nil]),
|
|
70
|
+
# ) { |a, b| raise ArgumentError, "b required" if b.nil?; a + b }
|
|
71
|
+
#
|
|
72
|
+
def tests(*assertions, name: nil, &block)
|
|
73
|
+
raise ArgumentError, "tests requires a block" unless block_given?
|
|
74
|
+
inline_registry << {
|
|
75
|
+
fn: block,
|
|
76
|
+
name: name || "anonymous",
|
|
77
|
+
assertions: assertions
|
|
78
|
+
}
|
|
79
|
+
block
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def inline_registry
|
|
83
|
+
@inline_registry ||= []
|
|
84
|
+
end
|
|
85
|
+
|
|
36
86
|
private
|
|
37
87
|
|
|
88
|
+
def run_inline_entry(entry, quiet: false)
|
|
89
|
+
fn = entry[:fn]
|
|
90
|
+
name = entry[:name]
|
|
91
|
+
puts "\n #{name}" unless entry[:assertions].empty? || quiet
|
|
92
|
+
|
|
93
|
+
entry[:assertions].each do |assertion|
|
|
94
|
+
args = assertion[:args]
|
|
95
|
+
case assertion[:type]
|
|
96
|
+
when :equal
|
|
97
|
+
begin
|
|
98
|
+
result = fn.call(*args)
|
|
99
|
+
if result == assertion[:expected]
|
|
100
|
+
results[:passed] += 1
|
|
101
|
+
puts " \e[32m✓\e[0m #{name}(#{args.inspect}) == #{assertion[:expected].inspect}" unless quiet
|
|
102
|
+
else
|
|
103
|
+
results[:failed] += 1
|
|
104
|
+
puts " \e[31m✗\e[0m #{name}(#{args.inspect}) expected #{assertion[:expected].inspect}, got #{result.inspect}" unless quiet
|
|
105
|
+
end
|
|
106
|
+
rescue => e
|
|
107
|
+
results[:errors] += 1
|
|
108
|
+
puts " \e[33m!\e[0m #{name}(#{args.inspect}) raised #{e.class}: #{e.message}" unless quiet
|
|
109
|
+
end
|
|
110
|
+
when :raises
|
|
111
|
+
begin
|
|
112
|
+
fn.call(*args)
|
|
113
|
+
results[:failed] += 1
|
|
114
|
+
puts " \e[31m✗\e[0m #{name}(#{args.inspect}) expected #{assertion[:exception]} but none raised" unless quiet
|
|
115
|
+
rescue assertion[:exception]
|
|
116
|
+
results[:passed] += 1
|
|
117
|
+
puts " \e[32m✓\e[0m #{name}(#{args.inspect}) raises #{assertion[:exception]}" unless quiet
|
|
118
|
+
rescue => e
|
|
119
|
+
results[:failed] += 1
|
|
120
|
+
puts " \e[31m✗\e[0m #{name}(#{args.inspect}) expected #{assertion[:exception]}, got #{e.class}" unless quiet
|
|
121
|
+
end
|
|
122
|
+
when :true
|
|
123
|
+
begin
|
|
124
|
+
result = fn.call(*args)
|
|
125
|
+
if result
|
|
126
|
+
results[:passed] += 1
|
|
127
|
+
puts " \e[32m✓\e[0m #{name}(#{args.inspect}) is truthy" unless quiet
|
|
128
|
+
else
|
|
129
|
+
results[:failed] += 1
|
|
130
|
+
puts " \e[31m✗\e[0m #{name}(#{args.inspect}) expected truthy, got #{result.inspect}" unless quiet
|
|
131
|
+
end
|
|
132
|
+
rescue => e
|
|
133
|
+
results[:errors] += 1
|
|
134
|
+
puts " \e[33m!\e[0m #{name}(#{args.inspect}) raised #{e.class}: #{e.message}" unless quiet
|
|
135
|
+
end
|
|
136
|
+
when :false
|
|
137
|
+
begin
|
|
138
|
+
result = fn.call(*args)
|
|
139
|
+
if !result
|
|
140
|
+
results[:passed] += 1
|
|
141
|
+
puts " \e[32m✓\e[0m #{name}(#{args.inspect}) is falsy" unless quiet
|
|
142
|
+
else
|
|
143
|
+
results[:failed] += 1
|
|
144
|
+
puts " \e[31m✗\e[0m #{name}(#{args.inspect}) expected falsy, got #{result.inspect}" unless quiet
|
|
145
|
+
end
|
|
146
|
+
rescue => e
|
|
147
|
+
results[:errors] += 1
|
|
148
|
+
puts " \e[33m!\e[0m #{name}(#{args.inspect}) raised #{e.class}: #{e.message}" unless quiet
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
38
154
|
def reset_results
|
|
39
155
|
@results = { passed: 0, failed: 0, errors: 0, tests: [] }
|
|
40
156
|
end
|
|
41
157
|
|
|
42
|
-
def run_suite(suite)
|
|
43
|
-
puts "\n #{suite.name}"
|
|
158
|
+
def run_suite(suite, quiet: false, failfast: false)
|
|
159
|
+
puts "\n #{suite.name}" unless quiet
|
|
44
160
|
suite.tests.each do |test|
|
|
45
|
-
run_test(suite, test)
|
|
161
|
+
run_test(suite, test, quiet: quiet)
|
|
162
|
+
break if failfast && results[:failed] > 0
|
|
46
163
|
end
|
|
47
164
|
end
|
|
48
165
|
|
|
49
|
-
def run_test(suite, test)
|
|
166
|
+
def run_test(suite, test, quiet: false)
|
|
50
167
|
suite.run_before_each
|
|
51
168
|
context = TestContext.new
|
|
52
169
|
context.instance_eval(&test[:block])
|
|
53
170
|
results[:passed] += 1
|
|
54
171
|
results[:tests] << { name: test[:name], status: :passed, suite: suite.name }
|
|
55
|
-
puts " \e[32m✓\e[0m #{test[:name]}"
|
|
172
|
+
puts " \e[32m✓\e[0m #{test[:name]}" unless quiet
|
|
56
173
|
rescue TestFailure => e
|
|
57
174
|
results[:failed] += 1
|
|
58
175
|
results[:tests] << { name: test[:name], status: :failed, suite: suite.name, message: e.message }
|
|
59
|
-
puts " \e[31m✗\e[0m #{test[:name]}: #{e.message}"
|
|
176
|
+
puts " \e[31m✗\e[0m #{test[:name]}: #{e.message}" unless quiet
|
|
60
177
|
rescue => e
|
|
61
178
|
results[:errors] += 1
|
|
62
179
|
results[:tests] << { name: test[:name], status: :error, suite: suite.name, message: e.message }
|
|
63
|
-
puts " \e[33m!\e[0m #{test[:name]}: #{e.message}"
|
|
180
|
+
puts " \e[33m!\e[0m #{test[:name]}: #{e.message}" unless quiet
|
|
64
181
|
ensure
|
|
65
182
|
suite.run_after_each
|
|
66
183
|
end
|
|
@@ -143,6 +260,16 @@ module Tina4
|
|
|
143
260
|
true
|
|
144
261
|
end
|
|
145
262
|
|
|
263
|
+
def assert_true(value, message = nil)
|
|
264
|
+
msg = message || "Expected truthy, got #{value.inspect}"
|
|
265
|
+
raise TestFailure, msg unless value
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def assert_false(value, message = nil)
|
|
269
|
+
msg = message || "Expected falsy, got #{value.inspect}"
|
|
270
|
+
raise TestFailure, msg if value
|
|
271
|
+
end
|
|
272
|
+
|
|
146
273
|
def assert_match(pattern, string, message = nil)
|
|
147
274
|
msg = message || "Expected #{string.inspect} to match #{pattern.inspect}"
|
|
148
275
|
raise TestFailure, msg unless pattern.match?(string)
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4/webserver.rb
CHANGED
|
@@ -276,5 +276,16 @@ module Tina4
|
|
|
276
276
|
@ai_thread&.join(5)
|
|
277
277
|
@server&.shutdown
|
|
278
278
|
end
|
|
279
|
+
|
|
280
|
+
# Dispatch a Rack-style env through the Tina4 app and return [status, headers, body].
|
|
281
|
+
#
|
|
282
|
+
# Useful for testing and embedding — does not require a running server.
|
|
283
|
+
# Cross-framework parity with Python and Node.js.
|
|
284
|
+
#
|
|
285
|
+
# @param env [Hash] A Rack environment hash
|
|
286
|
+
# @return [Array] Rack-style response triple [status, headers, body]
|
|
287
|
+
def handle(env)
|
|
288
|
+
@app.call(env)
|
|
289
|
+
end
|
|
279
290
|
end
|
|
280
291
|
end
|
data/lib/tina4/websocket.rb
CHANGED
|
@@ -5,8 +5,32 @@ require "base64"
|
|
|
5
5
|
require "set"
|
|
6
6
|
|
|
7
7
|
module Tina4
|
|
8
|
+
WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-5AB5DC11AD37"
|
|
9
|
+
|
|
10
|
+
# Compute Sec-WebSocket-Accept from Sec-WebSocket-Key per RFC 6455.
|
|
11
|
+
def self.compute_accept_key(key)
|
|
12
|
+
Base64.strict_encode64(Digest::SHA1.digest("#{key}#{WEBSOCKET_GUID}"))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Build a WebSocket frame (server→client, never masked).
|
|
16
|
+
def self.build_frame(opcode, data, fin: true)
|
|
17
|
+
first_byte = (fin ? 0x80 : 0x00) | opcode
|
|
18
|
+
frame = [first_byte].pack("C")
|
|
19
|
+
length = data.bytesize
|
|
20
|
+
|
|
21
|
+
if length < 126
|
|
22
|
+
frame += [length].pack("C")
|
|
23
|
+
elsif length < 65536
|
|
24
|
+
frame += [126, length].pack("Cn")
|
|
25
|
+
else
|
|
26
|
+
frame += [127, length].pack("CQ>")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
frame + data
|
|
30
|
+
end
|
|
31
|
+
|
|
8
32
|
class WebSocket
|
|
9
|
-
GUID =
|
|
33
|
+
GUID = WEBSOCKET_GUID
|
|
10
34
|
|
|
11
35
|
attr_reader :connections
|
|
12
36
|
|
|
@@ -68,6 +92,16 @@ module Tina4
|
|
|
68
92
|
end
|
|
69
93
|
end
|
|
70
94
|
|
|
95
|
+
def send_to(conn_id, message)
|
|
96
|
+
conn = @connections[conn_id]
|
|
97
|
+
conn&.send_text(message)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def close(conn_id, code: 1000, reason: "")
|
|
101
|
+
conn = @connections[conn_id]
|
|
102
|
+
conn&.close(code: code, reason: reason)
|
|
103
|
+
end
|
|
104
|
+
|
|
71
105
|
# ── Rooms ──────────────────────────────────────────────────
|
|
72
106
|
|
|
73
107
|
def join_room_for(conn_id, room_name)
|
|
@@ -99,9 +133,7 @@ module Tina4
|
|
|
99
133
|
key = env["HTTP_SEC_WEBSOCKET_KEY"]
|
|
100
134
|
return unless key
|
|
101
135
|
|
|
102
|
-
accept =
|
|
103
|
-
Digest::SHA1.digest("#{key}#{GUID}")
|
|
104
|
-
)
|
|
136
|
+
accept = Tina4.compute_accept_key(key)
|
|
105
137
|
|
|
106
138
|
response = "HTTP/1.1 101 Switching Protocols\r\n" \
|
|
107
139
|
"Upgrade: websocket\r\n" \
|
|
@@ -247,18 +279,7 @@ module Tina4
|
|
|
247
279
|
end
|
|
248
280
|
|
|
249
281
|
def build_frame(opcode, data)
|
|
250
|
-
|
|
251
|
-
length = data.bytesize
|
|
252
|
-
|
|
253
|
-
if length < 126
|
|
254
|
-
frame += [length].pack("C")
|
|
255
|
-
elsif length < 65536
|
|
256
|
-
frame += [126, length].pack("Cn")
|
|
257
|
-
else
|
|
258
|
-
frame += [127, length].pack("CQ>")
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
frame + data
|
|
282
|
+
Tina4.build_frame(opcode, data)
|
|
262
283
|
end
|
|
263
284
|
end
|
|
264
285
|
end
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
# TINA4_WS_BACKPLANE_URL — Connection string (default: redis://localhost:6379)
|
|
12
12
|
#
|
|
13
13
|
# Usage:
|
|
14
|
-
# backplane = Tina4::WebSocketBackplane.
|
|
14
|
+
# backplane = Tina4::WebSocketBackplane.create_backplane
|
|
15
15
|
# if backplane
|
|
16
16
|
# backplane.subscribe("chat") { |msg| relay_to_local(msg) }
|
|
17
17
|
# backplane.publish("chat", '{"user":"A","text":"hello"}')
|
|
@@ -49,7 +49,7 @@ module Tina4
|
|
|
49
49
|
#
|
|
50
50
|
# This keeps backplane usage entirely optional — callers simply check
|
|
51
51
|
# +if backplane+ before publishing.
|
|
52
|
-
def self.
|
|
52
|
+
def self.create_backplane(url: nil)
|
|
53
53
|
backend = ENV.fetch("TINA4_WS_BACKPLANE", "").strip.downcase
|
|
54
54
|
|
|
55
55
|
case backend
|
data/lib/tina4/wsdl.rb
CHANGED
|
@@ -155,7 +155,8 @@ module Tina4
|
|
|
155
155
|
|
|
156
156
|
# ── WSDL generation ──────────────────────────────────────────────────
|
|
157
157
|
|
|
158
|
-
def generate_wsdl
|
|
158
|
+
def generate_wsdl(endpoint_url = "")
|
|
159
|
+
@service_url = endpoint_url unless endpoint_url.empty?
|
|
159
160
|
service_name = self.class.name ? self.class.name.split("::").last : "AnonymousService"
|
|
160
161
|
tns = "urn:#{service_name}"
|
|
161
162
|
|
data/lib/tina4.rb
CHANGED
|
@@ -120,7 +120,7 @@ module Tina4
|
|
|
120
120
|
color = is_tty ? "\e[31m" : ""
|
|
121
121
|
reset = is_tty ? "\e[0m" : ""
|
|
122
122
|
|
|
123
|
-
is_debug = Tina4::Env.
|
|
123
|
+
is_debug = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
|
|
124
124
|
log_level = (ENV["TINA4_LOG_LEVEL"] || "[TINA4_LOG_ALL]").upcase
|
|
125
125
|
display = (host == "0.0.0.0" || host == "::") ? "localhost" : host
|
|
126
126
|
|
|
@@ -161,7 +161,7 @@ module Tina4
|
|
|
161
161
|
Tina4::Env.load_env(root_dir)
|
|
162
162
|
|
|
163
163
|
# Setup debug logging
|
|
164
|
-
Tina4::Log.
|
|
164
|
+
Tina4::Log.configure(root_dir)
|
|
165
165
|
Tina4::Log.info("Tina4 Ruby v#{VERSION} initializing...")
|
|
166
166
|
|
|
167
167
|
# Setup auth keys
|
|
@@ -240,7 +240,7 @@ module Tina4
|
|
|
240
240
|
url = "http://#{display_host}:#{port}"
|
|
241
241
|
|
|
242
242
|
app = Tina4::RackApp.new(root_dir: root_dir)
|
|
243
|
-
is_debug = Tina4::Env.
|
|
243
|
+
is_debug = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
|
|
244
244
|
|
|
245
245
|
# Try Puma first (production-grade), fall back to WEBrick
|
|
246
246
|
if !is_debug
|
|
@@ -390,7 +390,7 @@ module Tina4
|
|
|
390
390
|
Tina4::Container.singleton(name, &block)
|
|
391
391
|
end
|
|
392
392
|
|
|
393
|
-
def
|
|
393
|
+
def resolve(name)
|
|
394
394
|
Tina4::Container.get(name)
|
|
395
395
|
end
|
|
396
396
|
|