tina4ruby 3.13.157 → 3.13.158
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/CHANGELOG.md +24 -0
- data/lib/tina4/database.rb +14 -2
- data/lib/tina4/database_url.rb +7 -3
- data/lib/tina4/dev_admin.rb +15 -5
- data/lib/tina4/dispatch_pipeline.rb +5 -0
- data/lib/tina4/frond.rb +80 -10
- data/lib/tina4/public/bundled-component-provenance.json +2 -2
- data/lib/tina4/public/js/frond.js +26 -7
- data/lib/tina4/public/js/frond.min.js +1 -1
- data/lib/tina4/query_builder.rb +32 -0
- data/lib/tina4/queue_backends/kafka_backend.rb +39 -7
- data/lib/tina4/shutdown.rb +13 -0
- data/lib/tina4/tcp.rb +573 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +5 -0
- data/lib/tina4.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9821daa34797e73cbeb2363f1df52e61a22a17eb2b7e44412483c72a02176866
|
|
4
|
+
data.tar.gz: 4eb81de452060f402677cecb522bd11417c968c50940a39cfbf7f9cd42b5700a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b3350a878be47194f468507e910d6975c347eb717d101aeb7d78c4cec836156ab3ee06c234ec63b1c7a84cb8b8e5fcc3d11f226d688813360f7102fe55a61abb
|
|
7
|
+
data.tar.gz: f065e3b6de1441f44bd6104f6b9eb2e46c5c5fb856043ec9e86abf7b6a738305130259cf6697239d54a9cd390a97ca77775466e0476c8272d863d672acafb15b
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,30 @@ number means the same thing everywhere.
|
|
|
6
6
|
**The authoritative release notes for every shipped version live in the documentation:**
|
|
7
7
|
https://tina4.com/ruby/36-releases
|
|
8
8
|
|
|
9
|
+
## 3.13.158 - 2026-11-05
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- Raw TCP service primitive (feature 142, ADR-0102): an in-process TCP listener with
|
|
13
|
+
on_connect/on_data/on_close callbacks and a connection registry for server->device push.
|
|
14
|
+
- ORM chainable `skip()` / `offset()` / `take()` on the query builder.
|
|
15
|
+
- CodeQL static security scanning in CI.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- Kafka queue backend commits consumer-group offsets durably, per record, and consumes
|
|
19
|
+
externally-produced records (ADR-0105).
|
|
20
|
+
- Frond `date` filter accepts both strftime and Twig/PHP `date()` letters (ADR-0103); an
|
|
21
|
+
unclosed `{% block %}` now fails loud (ADR-0104); `form.collect()` sends every selected
|
|
22
|
+
option of a `<select multiple>`.
|
|
23
|
+
|
|
24
|
+
### Security
|
|
25
|
+
- frond.js no longer sends the signed-in bearer token to another origin or accepts a
|
|
26
|
+
FreshToken from one, and no longer writes the token into a `formToken` field
|
|
27
|
+
(GHSA-q2gc-fpw5-3v3c).
|
|
28
|
+
- The dev-reload WebSocket is gated on the raw socket peer, not only the Host header (GHSA-ppcq-wpj3-px7m).
|
|
29
|
+
- Resolves additional advisories shipped in this release; full notes in the documentation.
|
|
30
|
+
- A scheme-less `TINA4_DATABASE_URL` no longer logs its password (GHSA-cxqw-5cwf-wv3r).
|
|
31
|
+
- The dev file-save endpoint follows a dangling symlink before the containment check (GHSA-3p39-mqxh-gjxf).
|
|
32
|
+
|
|
9
33
|
## 3.13.157 - 2026-11-02
|
|
10
34
|
|
|
11
35
|
### Added
|
data/lib/tina4/database.rb
CHANGED
|
@@ -530,7 +530,7 @@ module Tina4
|
|
|
530
530
|
Tina4::Log.info("Database connected: #{@driver_name}")
|
|
531
531
|
@connect_error = nil
|
|
532
532
|
rescue => e
|
|
533
|
-
Tina4::Log.error("Database connection failed: #{e.message}")
|
|
533
|
+
Tina4::Log.error("Database connection failed: #{scrub_connection_string(e.message)}")
|
|
534
534
|
@connected = false
|
|
535
535
|
# REMEMBER the cause. Logging alone is not enough: the driver's own
|
|
536
536
|
# connection stays nil, so the next call used to die inside the driver as
|
|
@@ -582,9 +582,21 @@ module Tina4
|
|
|
582
582
|
def connect_error_message
|
|
583
583
|
target = safe_connection_target
|
|
584
584
|
"Database not connected (#{@driver_name}#{target.empty? ? '' : " -> #{target}"}): " \
|
|
585
|
-
"#{@connect_error.class}: #{@connect_error.message}"
|
|
585
|
+
"#{@connect_error.class}: #{scrub_connection_string(@connect_error.message)}"
|
|
586
586
|
end
|
|
587
587
|
|
|
588
|
+
# The driver's message with the raw connection string taken out. libpq quotes
|
|
589
|
+
# a scheme-less value whole as the host it could not resolve -
|
|
590
|
+
# could not translate host name "postgres//u:pass@h/app" to address
|
|
591
|
+
# - password included.
|
|
592
|
+
def scrub_connection_string(text)
|
|
593
|
+
raw = @connection_string.to_s
|
|
594
|
+
return text.to_s if raw.empty?
|
|
595
|
+
|
|
596
|
+
text.to_s.gsub(raw) { Tina4::DatabaseUrl.redact(raw) }
|
|
597
|
+
end
|
|
598
|
+
private :scrub_connection_string
|
|
599
|
+
|
|
588
600
|
# The connection string with any password stripped, so a raised error can name
|
|
589
601
|
# the target without leaking a credential into a log or an HTTP 500 body.
|
|
590
602
|
#
|
data/lib/tina4/database_url.rb
CHANGED
|
@@ -180,9 +180,13 @@ module Tina4
|
|
|
180
180
|
def self.scrub(text)
|
|
181
181
|
head, separator, rest = text.to_s.partition("://")
|
|
182
182
|
|
|
183
|
-
# No "://" - a bare file path,
|
|
184
|
-
#
|
|
185
|
-
|
|
183
|
+
# No "://" - a bare file path, an ODBC/keyword string, or a URL that lost
|
|
184
|
+
# its ":" or a "/" (`postgres//u:pass@host/app`). An "@" means userinfo may
|
|
185
|
+
# be in front of it, so take the credential out the same way as below.
|
|
186
|
+
if separator.empty?
|
|
187
|
+
text = text.to_s
|
|
188
|
+
return scrub_keywords(text.include?("@") ? redact_authority(text) : text)
|
|
189
|
+
end
|
|
186
190
|
|
|
187
191
|
# A head that is not a scheme is unknown text, and unknown text may BE the
|
|
188
192
|
# secret (the measured Python case was `notaurl-with-SuperSecret123`).
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -1727,24 +1727,34 @@ module Tina4
|
|
|
1727
1727
|
root = real_path_of(File.expand_path(Dir.pwd))
|
|
1728
1728
|
resolved = real_path_of(File.expand_path(rel_path.to_s, root))
|
|
1729
1729
|
roots = [root] + extra_roots.compact.map { |r| real_path_of(File.expand_path(r.to_s)) }
|
|
1730
|
-
unless roots.any? { |r| resolved == r || resolved.start_with?(r.chomp("/") + "/") }
|
|
1730
|
+
unless resolved && roots.any? { |r| r && (resolved == r || resolved.start_with?(r.chomp("/") + "/")) }
|
|
1731
1731
|
raise ArgumentError, "path escapes project directory"
|
|
1732
1732
|
end
|
|
1733
1733
|
resolved
|
|
1734
1734
|
end
|
|
1735
1735
|
|
|
1736
1736
|
# Real path of +path+: the deepest existing ancestor is realpath'd and the
|
|
1737
|
-
# not-yet-existing tail (a new file being saved) is appended.
|
|
1738
|
-
|
|
1737
|
+
# not-yet-existing tail (a new file being saved) is appended. A dangling
|
|
1738
|
+
# symlink is followed to the place a write through it would land, the way
|
|
1739
|
+
# McpDevTools.real_path_of does. nil when it cannot be resolved (a link
|
|
1740
|
+
# loop, or an unreadable link), so the caller refuses it.
|
|
1741
|
+
def real_path_of(path, hops = 0)
|
|
1739
1742
|
existing = path
|
|
1740
1743
|
tail = []
|
|
1741
1744
|
until File.exist?(existing) || File.symlink?(existing) || existing == File.dirname(existing)
|
|
1742
1745
|
tail.unshift(File.basename(existing))
|
|
1743
1746
|
existing = File.dirname(existing)
|
|
1744
1747
|
end
|
|
1745
|
-
File.join(File.realpath(existing), *tail)
|
|
1748
|
+
return File.join(File.realpath(existing), *tail) if File.exist?(existing)
|
|
1749
|
+
return nil if hops >= 40
|
|
1750
|
+
|
|
1751
|
+
# A dangling symlink. Its target is joined as text, not normalised, so a
|
|
1752
|
+
# ".." after a linked component is resolved the way the OS will follow it.
|
|
1753
|
+
target = File.readlink(existing)
|
|
1754
|
+
base = target.start_with?("/") ? target : File.join(File.dirname(existing), target)
|
|
1755
|
+
real_path_of(File.join(base, *tail), hops + 1)
|
|
1746
1756
|
rescue SystemCallError
|
|
1747
|
-
|
|
1757
|
+
nil
|
|
1748
1758
|
end
|
|
1749
1759
|
|
|
1750
1760
|
# Project-relative form of a resolved path, for the secret denylist.
|
|
@@ -199,6 +199,11 @@ module Tina4
|
|
|
199
199
|
if ctx.path.start_with?("/__dev") && !Tina4::DevAdmin.dev_host_allowed?(ctx.env)
|
|
200
200
|
return [403, { "content-type" => "text/plain" }, ["Forbidden: host not allowed"]]
|
|
201
201
|
end
|
|
202
|
+
# ...and only from a raw socket peer the /__dev HTTP gate would let in, so
|
|
203
|
+
# a remote caller that claims "Host: localhost" cannot open it.
|
|
204
|
+
if ctx.path.start_with?("/__dev") && !Tina4::DevAdmin.dev_peer_allowed?((ctx.env["REMOTE_ADDR"] || "").to_s)
|
|
205
|
+
return [403, { "content-type" => "text/plain" }, ["Forbidden: peer not allowed"]]
|
|
206
|
+
end
|
|
202
207
|
|
|
203
208
|
ws_result = Tina4::Router.find_ws_route(ctx.path)
|
|
204
209
|
return nil unless ws_result
|
data/lib/tina4/frond.rb
CHANGED
|
@@ -28,6 +28,13 @@ module Tina4
|
|
|
28
28
|
class SafeString < String
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Raised when a Frond template is malformed and cannot be rendered safely
|
|
32
|
+
# (ADR-0104) -- an unclosed {% block %} or a cut-short {% endblock % whose
|
|
33
|
+
# closing %} is missing. Failing loud surfaces the real cause to a developer
|
|
34
|
+
# (or an AI agent) instead of a page served 200 with its body quietly dropped.
|
|
35
|
+
class FrondSyntaxError < StandardError
|
|
36
|
+
end
|
|
37
|
+
|
|
31
38
|
class Frond
|
|
32
39
|
# -- Class-level registries ------------------------------------------------
|
|
33
40
|
# Persist globals, filters, and tests across hot-reloads and across module
|
|
@@ -569,6 +576,62 @@ module Tina4
|
|
|
569
576
|
@allowed_tags.include?(tag)
|
|
570
577
|
end
|
|
571
578
|
|
|
579
|
+
PHP_MONTHS = %w[January February March April May June July August September October November December].freeze
|
|
580
|
+
# cwday indexing: Monday == 1 .. Sunday == 7, so PHP_DAYS[cwday - 1].
|
|
581
|
+
PHP_DAYS = %w[Monday Tuesday Wednesday Thursday Friday Saturday Sunday].freeze
|
|
582
|
+
|
|
583
|
+
# Format a date/time with Twig/PHP date() letters (English names) — ADR-0103.
|
|
584
|
+
#
|
|
585
|
+
# Letters are mapped character by character; a backslash escapes the next
|
|
586
|
+
# character, and any unmapped character is emitted literally (PHP's rule).
|
|
587
|
+
# English month/day names are hardcoded so the output matches every other
|
|
588
|
+
# Tina4 framework regardless of the host locale.
|
|
589
|
+
def self.php_date(dt, fmt)
|
|
590
|
+
hour = dt.respond_to?(:hour) ? dt.hour : 0
|
|
591
|
+
minute = dt.respond_to?(:min) ? dt.min : 0
|
|
592
|
+
second = dt.respond_to?(:sec) ? dt.sec : 0
|
|
593
|
+
h12 = hour % 12
|
|
594
|
+
h12 = 12 if h12.zero?
|
|
595
|
+
cwday = dt.cwday # 1 == Monday .. 7 == Sunday
|
|
596
|
+
wday = dt.wday # 0 == Sunday .. 6 == Saturday
|
|
597
|
+
out = +""
|
|
598
|
+
i = 0
|
|
599
|
+
n = fmt.length
|
|
600
|
+
while i < n
|
|
601
|
+
c = fmt[i]
|
|
602
|
+
if c == "\\" && i + 1 < n
|
|
603
|
+
out << fmt[i + 1]
|
|
604
|
+
i += 2
|
|
605
|
+
next
|
|
606
|
+
end
|
|
607
|
+
out << case c
|
|
608
|
+
when "Y" then format("%04d", dt.year)
|
|
609
|
+
when "y" then format("%02d", dt.year % 100)
|
|
610
|
+
when "m" then format("%02d", dt.month)
|
|
611
|
+
when "n" then dt.month.to_s
|
|
612
|
+
when "d" then format("%02d", dt.day)
|
|
613
|
+
when "j" then dt.day.to_s
|
|
614
|
+
when "H" then format("%02d", hour)
|
|
615
|
+
when "G" then hour.to_s
|
|
616
|
+
when "h" then format("%02d", h12)
|
|
617
|
+
when "g" then h12.to_s
|
|
618
|
+
when "i" then format("%02d", minute)
|
|
619
|
+
when "s" then format("%02d", second)
|
|
620
|
+
when "A" then hour < 12 ? "AM" : "PM"
|
|
621
|
+
when "a" then hour < 12 ? "am" : "pm"
|
|
622
|
+
when "D" then PHP_DAYS[cwday - 1][0, 3]
|
|
623
|
+
when "l" then PHP_DAYS[cwday - 1]
|
|
624
|
+
when "M" then PHP_MONTHS[dt.month - 1][0, 3]
|
|
625
|
+
when "F" then PHP_MONTHS[dt.month - 1]
|
|
626
|
+
when "N" then cwday.to_s
|
|
627
|
+
when "w" then wday.to_s
|
|
628
|
+
else c
|
|
629
|
+
end
|
|
630
|
+
i += 1
|
|
631
|
+
end
|
|
632
|
+
out
|
|
633
|
+
end
|
|
634
|
+
|
|
572
635
|
# Utility: HTML escape
|
|
573
636
|
def self.escape_html(str)
|
|
574
637
|
str.to_s.gsub(HTML_ESCAPE_RE, HTML_ESCAPE_MAP)
|
|
@@ -989,7 +1052,14 @@ module Tina4
|
|
|
989
1052
|
end
|
|
990
1053
|
end
|
|
991
1054
|
|
|
992
|
-
|
|
1055
|
+
unless matched
|
|
1056
|
+
# Unclosed {% block %} (or a cut-short {% endblock % whose %} is
|
|
1057
|
+
# missing, so the close regex never matches) -- fail loud
|
|
1058
|
+
# (tina4#286/#290) rather than silently dropping the body. Still
|
|
1059
|
+
# bounded: we raise, we never loop (tina4#271).
|
|
1060
|
+
raise FrondSyntaxError,
|
|
1061
|
+
"{% block #{name} %} is never closed (missing or malformed {% endblock %})"
|
|
1062
|
+
end
|
|
993
1063
|
end
|
|
994
1064
|
|
|
995
1065
|
blocks
|
|
@@ -1061,12 +1131,10 @@ module Tina4
|
|
|
1061
1131
|
end
|
|
1062
1132
|
|
|
1063
1133
|
if close_match.nil?
|
|
1064
|
-
#
|
|
1065
|
-
#
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
pos = len
|
|
1069
|
-
break
|
|
1134
|
+
# Unclosed {% block %} -- fail loud (tina4#286/#290), matching
|
|
1135
|
+
# extract_blocks, rather than silently keeping the rest verbatim.
|
|
1136
|
+
raise FrondSyntaxError,
|
|
1137
|
+
"{% block #{name} %} is never closed (missing or malformed {% endblock %})"
|
|
1070
1138
|
end
|
|
1071
1139
|
|
|
1072
1140
|
parent_content = source[content_start...close_match.begin(0)]
|
|
@@ -3195,12 +3263,14 @@ module Tina4
|
|
|
3195
3263
|
begin
|
|
3196
3264
|
if v.is_a?(String)
|
|
3197
3265
|
dt = DateTime.parse(v)
|
|
3198
|
-
dt.strftime(fmt)
|
|
3199
3266
|
elsif v.respond_to?(:strftime)
|
|
3200
|
-
v
|
|
3267
|
+
dt = v
|
|
3201
3268
|
else
|
|
3202
|
-
v.to_s
|
|
3269
|
+
return v.to_s
|
|
3203
3270
|
end
|
|
3271
|
+
# A strftime-style format (any with %) stays strftime — back-compatible;
|
|
3272
|
+
# any other format is Twig/PHP date() letters (ADR-0103).
|
|
3273
|
+
fmt.include?("%") ? dt.strftime(fmt) : Frond.php_date(dt, fmt)
|
|
3204
3274
|
rescue
|
|
3205
3275
|
v.to_s
|
|
3206
3276
|
end
|
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
{
|
|
10
10
|
"path": "js/frond.min.js",
|
|
11
11
|
"component": "tina4-css",
|
|
12
|
-
"sha256": "
|
|
12
|
+
"sha256": "1c46dc669fe6ebed980bef6ecab197b73fc0697488aff5746ee4fe6e8a64d7cd"
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
"path": "js/frond.js",
|
|
16
16
|
"component": "tina4-css",
|
|
17
|
-
"sha256": "
|
|
17
|
+
"sha256": "471cdcf1eae45142dce5a9755ce4d89e4e816989f3534ae2af56308ec4dcae65"
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
20
|
"path": "js/tina4.min.js",
|
|
@@ -23,6 +23,13 @@ var _frondModule = (() => {
|
|
|
23
23
|
frond: () => frond
|
|
24
24
|
});
|
|
25
25
|
var _token = null;
|
|
26
|
+
function _sameOrigin(url) {
|
|
27
|
+
try {
|
|
28
|
+
return new URL(url, window.location.href).origin === window.location.origin;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
26
33
|
function request(url, options) {
|
|
27
34
|
let opts;
|
|
28
35
|
if (typeof options === "function") {
|
|
@@ -33,7 +40,8 @@ var _frondModule = (() => {
|
|
|
33
40
|
const method = (opts.method || "GET").toUpperCase();
|
|
34
41
|
const xhr = new XMLHttpRequest();
|
|
35
42
|
xhr.open(method, url, true);
|
|
36
|
-
|
|
43
|
+
const sameOrigin = _sameOrigin(url);
|
|
44
|
+
if (_token !== null && sameOrigin) {
|
|
37
45
|
xhr.setRequestHeader("Authorization", "Bearer " + _token);
|
|
38
46
|
}
|
|
39
47
|
if (opts.headers) {
|
|
@@ -57,7 +65,8 @@ var _frondModule = (() => {
|
|
|
57
65
|
}
|
|
58
66
|
xhr.onload = function() {
|
|
59
67
|
const freshToken = xhr.getResponseHeader("FreshToken");
|
|
60
|
-
|
|
68
|
+
const answeredBySameOrigin = sameOrigin && (!xhr.responseURL || _sameOrigin(xhr.responseURL));
|
|
69
|
+
if (freshToken && freshToken !== "" && answeredBySameOrigin) {
|
|
61
70
|
_token = freshToken;
|
|
62
71
|
}
|
|
63
72
|
let content = xhr.response;
|
|
@@ -169,8 +178,12 @@ var _frondModule = (() => {
|
|
|
169
178
|
* Collect all form field values into a FormData object.
|
|
170
179
|
*
|
|
171
180
|
* Handles inputs, selects, textareas, file uploads (including
|
|
172
|
-
* multi-file), checkboxes, and radio buttons.
|
|
173
|
-
*
|
|
181
|
+
* multi-file), checkboxes, and radio buttons.
|
|
182
|
+
*
|
|
183
|
+
* A `formToken` field is sent as the server rendered it. frond never
|
|
184
|
+
* writes the bearer into it: the bearer travels in the Authorization
|
|
185
|
+
* header (own origin only), and the server accepts only a form token in
|
|
186
|
+
* the formToken slot.
|
|
174
187
|
*
|
|
175
188
|
* @param formId - DOM id of the form (without '#').
|
|
176
189
|
* @returns Populated FormData instance.
|
|
@@ -180,9 +193,6 @@ var _frondModule = (() => {
|
|
|
180
193
|
const elements = document.querySelectorAll("#" + formId + " select, #" + formId + " input, #" + formId + " textarea");
|
|
181
194
|
for (let i = 0; i < elements.length; i++) {
|
|
182
195
|
const el = elements[i];
|
|
183
|
-
if (el.name === "formToken" && _token !== null) {
|
|
184
|
-
el.value = _token;
|
|
185
|
-
}
|
|
186
196
|
if (!el.name) continue;
|
|
187
197
|
if (el.type === "file") {
|
|
188
198
|
const files = el.files;
|
|
@@ -204,6 +214,15 @@ var _frondModule = (() => {
|
|
|
204
214
|
} else if (el.type !== "radio") {
|
|
205
215
|
fd.append(el.name, "0");
|
|
206
216
|
}
|
|
217
|
+
} else if (el.type === "select-multiple") {
|
|
218
|
+
const options = el.selectedOptions;
|
|
219
|
+
for (let o = 0; o < options.length; o++) {
|
|
220
|
+
const option = options[o];
|
|
221
|
+
const group = option.parentElement;
|
|
222
|
+
if (option.disabled) continue;
|
|
223
|
+
if (group instanceof HTMLOptGroupElement && group.disabled) continue;
|
|
224
|
+
fd.append(el.name, option.value);
|
|
225
|
+
}
|
|
207
226
|
} else {
|
|
208
227
|
fd.append(el.name, el.value === "" ? "" : el.value);
|
|
209
228
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
var _frondModule=(()=>{var E=Object.defineProperty;var O=Object.getOwnPropertyDescriptor;var H=Object.getOwnPropertyNames;var q=Object.prototype.hasOwnProperty;var A=(t,s)=>{for(var e in s)E(t,e,{get:s[e],enumerable:!0})},D=(t,s,e,o)=>{if(s&&typeof s=="object"||typeof s=="function")for(let n of H(s))!q.call(t,n)&&n!==e&&E(t,n,{get:()=>s[n],enumerable:!(o=O(s,n))||o.enumerable});return t};var W=t=>D(E({},"__esModule",{value:!0}),t);var z={};A(z,{frond:()=>C});var y=null;function v(t,s){let e;typeof s=="function"?e={onSuccess:s}:e=s||{};let o=(e.method||"GET").toUpperCase(),n=new XMLHttpRequest;if(n.open(o,t,!0),y!==null&&n.setRequestHeader("Authorization","Bearer "+y),e.headers)for(let u in e.headers)Object.prototype.hasOwnProperty.call(e.headers,u)&&n.setRequestHeader(u,e.headers[u]);let c=null;e.body!==void 0&&e.body!==null&&(e.body instanceof FormData?c=e.body:typeof e.body=="object"?(c=JSON.stringify(e.body),n.setRequestHeader("Content-Type","application/json; charset=UTF-8")):typeof e.body=="string"&&(c=e.body,n.setRequestHeader("Content-Type","text/plain; charset=UTF-8"))),n.onload=function(){let u=n.getResponseHeader("FreshToken");u&&u!==""&&(y=u);let r=n.response;try{r=JSON.parse(r)}catch{}if(n.responseURL){let i=new URL(t,window.location.href).href;if(n.responseURL!==i){window.location.href=n.responseURL;return}}n.status>=200&&n.status<400?e.onSuccess&&e.onSuccess(r,n.status,n):e.onError&&e.onError(n.status,n)},n.onerror=function(){e.onError&&e.onError(n.status,n)},e.timeout!==0&&(n.timeout=e.timeout||3e4,n.ontimeout=function(){e.onError&&e.onError(n.status,n)}),n.send(c)}function h(t,s){if(!t)return"";let e=new DOMParser,o=t.includes("<html>")?t:"<body>"+t+"</body></html>",c=e.parseFromString(o,"text/html").querySelector("body"),u=c.querySelectorAll("script");if(u.forEach(function(r){r.remove()}),s!==null){let r=document.getElementById(s);return r&&(c.children.length>0?r.replaceChildren.apply(r,Array.from(c.children)):r.innerHTML=c.innerHTML,u.forEach(function(i){let l=document.createElement("script");l.type="text/javascript",l.async=!0,i.src?l.src=i.src:l.textContent=i.textContent,r.appendChild(l)})),""}return u.forEach(function(r){let i=document.createElement("script");i.type="text/javascript",i.async=!0,i.textContent=r.textContent,document.body.appendChild(i)}),c.innerHTML}function _(t,s,e,o){let n=s||"content";v(t,{method:"GET",onSuccess:function(c,u){if(document.getElementById(n)){let r=h(c,n);e&&e(r,c)}else e&&e(c)},onError:o})}function k(t,s,e,o,n){let c=e||"content";v(t,{method:"POST",body:s,onSuccess:function(u){let r="";if(u&&u.message!==void 0)r=h(u.message,c);else if(document.getElementById(c))r=h(u,c);else{o&&o(u);return}o&&o(r,u)},onError:n})}var x={collect:function(t){let s=new FormData,e=document.querySelectorAll("#"+t+" select, #"+t+" input, #"+t+" textarea");for(let o=0;o<e.length;o++){let n=e[o];if(n.name==="formToken"&&y!==null&&(n.value=y),!!n.name)if(n.type==="file"){let c=n.files;if(c)for(let u=0;u<c.length;u++){let r=c[u];if(r!==void 0){let i=n.name;c.length>1&&!i.includes("[")&&(i=i+"[]"),s.append(i,r,r.name)}}}else n.type==="checkbox"||n.type==="radio"?n.checked?s.append(n.name,n.value):n.type!=="radio"&&s.append(n.name,"0"):s.append(n.name,n.value===""?"":n.value)}return s},submit:function(t,s,e,o,n){let c=x.collect(t);k(s,c,e||"message",o,n)},show:function(t,s,e,o,n){let c=t.toUpperCase();(t==="create"||t==="edit")&&(c="GET"),t==="delete"&&(c="DELETE");let u=e||"form";v(s,{method:c,onSuccess:function(r){let i="";if(r&&r.message!==void 0)i=h(r.message,u);else if(document.getElementById(u))i=h(r,u);else{o&&o(r);return}o&&o(i)},onError:n})}};function M(t,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,protocols:[],onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},o=null,n=!1,c=e.reconnectDelay,u=0,r=null,i={message:[],open:[],close:[],error:[]},l={status:"connecting",send:function(f){if(!o||o.readyState!==WebSocket.OPEN)throw new Error("[frond] WebSocket is not connected");o.send(typeof f=="string"?f:JSON.stringify(f))},on:function(f,a){return i[f]||(i[f]=[]),i[f].push(a),function(){let d=i[f],g=d.indexOf(a);g>=0&&d.splice(g,1)}},close:function(f,a){n=!0,r&&(clearTimeout(r),r=null),o&&o.close(f||1e3,a||""),l.status="closed"}};function m(f){if(typeof f!="string")return f;try{return JSON.parse(f)}catch{return f}}function p(){!e.reconnect||u>=e.maxReconnectAttempts||(u++,l.status="reconnecting",r=setTimeout(function(){r=null,w()},c),c=Math.min(c*2,e.maxReconnectDelay))}function w(){l.status=u>0?"reconnecting":"connecting";try{o=new WebSocket(t,e.protocols)}catch{l.status="closed";return}o.onopen=function(){l.status="open",u=0,c=e.reconnectDelay,e.onOpen();for(let f of i.open)f()},o.onmessage=function(f){let a=m(f.data);for(let d of i.message)d(a)},o.onclose=function(f){l.status="closed",e.onClose(f.code,f.reason);for(let a of i.close)a(f.code,f.reason);n||p()},o.onerror=function(f){e.onError(f);for(let a of i.error)a(f)}}return w(),l}function I(t,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,events:[],json:!0,onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},o=null,n=!1,c=e.reconnectDelay,u=0,r=null,i={message:[],open:[],close:[],error:[]},l={status:"connecting",on:function(a,d){return i[a]||(i[a]=[]),i[a].push(d),function(){let g=i[a],T=g.indexOf(d);T>=0&&g.splice(T,1)}},close:function(){n=!0,r&&(clearTimeout(r),r=null),o&&(o.close(),o=null),l.status="closed"}};function m(a){if(!e.json)return a;try{return JSON.parse(a)}catch{return a}}function p(a,d){for(let g of i.message)g(a,d||void 0)}function w(){!e.reconnect||u>=e.maxReconnectAttempts||(u++,l.status="reconnecting",r=setTimeout(function(){r=null,f()},c),c=Math.min(c*2,e.maxReconnectDelay))}function f(){l.status=u>0?"reconnecting":"connecting";try{o=new EventSource(t)}catch{l.status="closed";return}o.onopen=function(){l.status="open",u=0,c=e.reconnectDelay,e.onOpen();for(let a of i.open)a(null)},o.onmessage=function(a){p(m(a.data),null)};for(let a of e.events)o.addEventListener(a,function(d){p(m(d.data),a)});o.onerror=function(a){e.onError(a);for(let d of i.error)d(a);if(o&&o.readyState===2){o=null,l.status="closed",e.onClose();for(let d of i.close)d(null);n||w()}}}return f(),l}var U={set:function(t,s,e){let o="";if(e){let n=new Date;n.setTime(n.getTime()+e*24*60*60*1e3),o="; expires="+n.toUTCString()}document.cookie=t+"="+(s||"")+o+"; path=/"},get:function(t){let s=t+"=",e=document.cookie.split(";");for(let o=0;o<e.length;o++){let n=e[o];for(;n.charAt(0)===" ";)n=n.substring(1);if(n.indexOf(s)===0)return n.substring(s.length)}return null},remove:function(t){document.cookie=t+"=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/"}};function j(t,s){let e=document.getElementById("message");if(!e)return;let o=s||"info";e.innerHTML='<div class="alert alert-'+o+' alert-dismissible">'+t+'<button type="button" class="btn-close" data-t4-dismiss="alert">×</button></div>'}function X(t,s,e,o){let n=window.screenLeft!==void 0?window.screenLeft:window.screenX,c=window.screenTop!==void 0?window.screenTop:window.screenY,u=window.innerWidth||document.documentElement.clientWidth||screen.width,r=window.innerHeight||document.documentElement.clientHeight||screen.height,i=u/window.screen.availWidth,l=(u-e)/2/i+n,m=(r-o)/2/i+c,p=window.open(t,s,"directories=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width="+e/i+",height="+o/i+",top="+m+",left="+l);return window.focus&&p&&p.focus(),p}function B(t){if(t.indexOf("No data available")>=0){window.alert("No data available for this report.");return}window.open(t,"_blank","toolbar=no,scrollbars=yes,resizable=yes,width=800,height=600,top=0,left=0")}function P(t,s,e,o){v(t,{method:"POST",body:{query:s,variables:e||{}},onSuccess:function(n){o&&o(n.data||null,n.errors||void 0)},onError:function(n){o&&o(null,[{message:"GraphQL request failed with status "+n}])}})}function b(t){let s=t.dataset;return s&&s.key?s.key:null}function F(t,s){let e=s.attributes;for(let n=0;n<e.length;n++){let c=e[n];t.getAttribute(c.name)!==c.value&&t.setAttribute(c.name,c.value)}Array.prototype.slice.call(t.attributes).forEach(function(n){s.hasAttribute(n.name)||t.removeAttribute(n.name)})}function G(t,s){let e=s.tagName;e==="INPUT"||e==="TEXTAREA"||e==="SELECT"||(F(t,s),t.children.length||s.children.length?L(t,s):t.innerHTML!==s.innerHTML&&(t.innerHTML=s.innerHTML))}function L(t,s){let e=Array.prototype.slice.call(t.children),o=Array.prototype.slice.call(s.children),n={};e.forEach(function(r){let i=b(r);i&&(n[i]=r)});let c=[];for(let r=0;r<o.length;r++){let i=o[r],l=b(i),m=null;l&&n[l]?m=n[l]:!l&&e[r]&&!b(e[r])&&e[r].tagName===i.tagName&&(m=e[r]),m&&m.tagName===i.tagName?(G(m,i),reused.push(m),c.push(m)):c.push(i)}let u=t.firstElementChild;for(let r=0;r<c.length;r++){let i=c[r];i===u?u=u.nextElementSibling:t.insertBefore(i,u)}e.forEach(function(r){c.indexOf(r)===-1&&r.parentNode===t&&t.removeChild(r)}),reused}function R(t,s){let e=document.createElement("div");if(e.innerHTML=s,!e.children.length||!t.children.length){t.innerHTML=s;return}L(t,e)}function J(t){return/^wss?:\/\//.test(t)?t:(typeof location<"u"&&location.protocol==="https:"?"wss":"ws")+"://"+location.host+t}function N(t,s){return t&&typeof t=="object"?t.type==="live"?s&&t.name&&t.name!==s?null:t.html!=null?String(t.html):null:null:typeof t=="string"?t:null}function S(t){if(typeof document>"u")return;let e=(t||document).querySelectorAll("[data-frond-live]");Array.prototype.slice.call(e).forEach(function(o){if(o.__frondLive)return;o.__frondLive=!0;let n=o.getAttribute("data-mode"),c=o.getAttribute("data-frond-live");if(n==="poll"){let u=o.getAttribute("data-src"),r=(parseInt(o.getAttribute("data-interval"),10)||5)*1e3,i=setInterval(function(){typeof document<"u"&&document.hidden||v(u,{method:"GET",onSuccess:function(l){R(o,typeof l=="string"?l:String(l))}})},r);o.__frondLiveStop=function(){clearInterval(i)}}else if(n==="ws"){let u=M(J(o.getAttribute("data-ws")));u.on("message",function(r){let i=N(r,c);i!==null&&R(o,i)}),o.__frondLiveStop=function(){u.close()}}else n==="sse"&&typeof console<"u"&&console.warn&&console.warn("[frond.live] sse transport is not wired yet (v1 supports poll and ws); block '"+c+"' shows first paint only. Use poll or ws.")})}var C={request:v,load:_,post:k,inject:h,form:x,ws:M,sse:I,live:S,cookie:U,message:j,popup:X,report:B,graphql:P,get token(){return y},set token(t){y=t}};typeof window<"u"&&(window.frond=C,typeof document<"u"&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){S()}):S()));return W(z);})();
|
|
1
|
+
var _frondModule=(()=>{var E=Object.defineProperty;var H=Object.getOwnPropertyDescriptor;var q=Object.getOwnPropertyNames;var A=Object.prototype.hasOwnProperty;var D=(t,r)=>{for(var e in r)E(t,e,{get:r[e],enumerable:!0})},_=(t,r,e,o)=>{if(r&&typeof r=="object"||typeof r=="function")for(let n of q(r))!A.call(t,n)&&n!==e&&E(t,n,{get:()=>r[n],enumerable:!(o=H(r,n))||o.enumerable});return t};var W=t=>_(E({},"__esModule",{value:!0}),t);var K={};D(K,{frond:()=>C});var v=null;function R(t){try{return new URL(t,window.location.href).origin===window.location.origin}catch{return!1}}function h(t,r){let e;typeof r=="function"?e={onSuccess:r}:e=r||{};let o=(e.method||"GET").toUpperCase(),n=new XMLHttpRequest;n.open(o,t,!0);let c=R(t);if(v!==null&&c&&n.setRequestHeader("Authorization","Bearer "+v),e.headers)for(let s in e.headers)Object.prototype.hasOwnProperty.call(e.headers,s)&&n.setRequestHeader(s,e.headers[s]);let u=null;e.body!==void 0&&e.body!==null&&(e.body instanceof FormData?u=e.body:typeof e.body=="object"?(u=JSON.stringify(e.body),n.setRequestHeader("Content-Type","application/json; charset=UTF-8")):typeof e.body=="string"&&(u=e.body,n.setRequestHeader("Content-Type","text/plain; charset=UTF-8"))),n.onload=function(){let s=n.getResponseHeader("FreshToken"),i=c&&(!n.responseURL||R(n.responseURL));s&&s!==""&&i&&(v=s);let l=n.response;try{l=JSON.parse(l)}catch{}if(n.responseURL){let f=new URL(t,window.location.href).href;if(n.responseURL!==f){window.location.href=n.responseURL;return}}n.status>=200&&n.status<400?e.onSuccess&&e.onSuccess(l,n.status,n):e.onError&&e.onError(n.status,n)},n.onerror=function(){e.onError&&e.onError(n.status,n)},e.timeout!==0&&(n.timeout=e.timeout||3e4,n.ontimeout=function(){e.onError&&e.onError(n.status,n)}),n.send(u)}function y(t,r){if(!t)return"";let e=new DOMParser,o=t.includes("<html>")?t:"<body>"+t+"</body></html>",c=e.parseFromString(o,"text/html").querySelector("body"),u=c.querySelectorAll("script");if(u.forEach(function(s){s.remove()}),r!==null){let s=document.getElementById(r);return s&&(c.children.length>0?s.replaceChildren.apply(s,Array.from(c.children)):s.innerHTML=c.innerHTML,u.forEach(function(i){let l=document.createElement("script");l.type="text/javascript",l.async=!0,i.src?l.src=i.src:l.textContent=i.textContent,s.appendChild(l)})),""}return u.forEach(function(s){let i=document.createElement("script");i.type="text/javascript",i.async=!0,i.textContent=s.textContent,document.body.appendChild(i)}),c.innerHTML}function U(t,r,e,o){let n=r||"content";h(t,{method:"GET",onSuccess:function(c,u){if(document.getElementById(n)){let s=y(c,n);e&&e(s,c)}else e&&e(c)},onError:o})}function L(t,r,e,o,n){let c=e||"content";h(t,{method:"POST",body:r,onSuccess:function(u){let s="";if(u&&u.message!==void 0)s=y(u.message,c);else if(document.getElementById(c))s=y(u,c);else{o&&o(u);return}o&&o(s,u)},onError:n})}var M={collect:function(t){let r=new FormData,e=document.querySelectorAll("#"+t+" select, #"+t+" input, #"+t+" textarea");for(let o=0;o<e.length;o++){let n=e[o];if(n.name)if(n.type==="file"){let c=n.files;if(c)for(let u=0;u<c.length;u++){let s=c[u];if(s!==void 0){let i=n.name;c.length>1&&!i.includes("[")&&(i=i+"[]"),r.append(i,s,s.name)}}}else if(n.type==="checkbox"||n.type==="radio")n.checked?r.append(n.name,n.value):n.type!=="radio"&&r.append(n.name,"0");else if(n.type==="select-multiple"){let c=n.selectedOptions;for(let u=0;u<c.length;u++){let s=c[u],i=s.parentElement;s.disabled||i instanceof HTMLOptGroupElement&&i.disabled||r.append(n.name,s.value)}}else r.append(n.name,n.value===""?"":n.value)}return r},submit:function(t,r,e,o,n){let c=M.collect(t);L(r,c,e||"message",o,n)},show:function(t,r,e,o,n){let c=t.toUpperCase();(t==="create"||t==="edit")&&(c="GET"),t==="delete"&&(c="DELETE");let u=e||"form";h(r,{method:c,onSuccess:function(s){let i="";if(s&&s.message!==void 0)i=y(s.message,u);else if(document.getElementById(u))i=y(s,u);else{o&&o(s);return}o&&o(i)},onError:n})}};function x(t,r){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,protocols:[],onOpen:function(){},onClose:function(){},onError:function(){},...r||{}},o=null,n=!1,c=e.reconnectDelay,u=0,s=null,i={message:[],open:[],close:[],error:[]},l={status:"connecting",send:function(d){if(!o||o.readyState!==WebSocket.OPEN)throw new Error("[frond] WebSocket is not connected");o.send(typeof d=="string"?d:JSON.stringify(d))},on:function(d,a){return i[d]||(i[d]=[]),i[d].push(a),function(){let p=i[d],g=p.indexOf(a);g>=0&&p.splice(g,1)}},close:function(d,a){n=!0,s&&(clearTimeout(s),s=null),o&&o.close(d||1e3,a||""),l.status="closed"}};function f(d){if(typeof d!="string")return d;try{return JSON.parse(d)}catch{return d}}function m(){!e.reconnect||u>=e.maxReconnectAttempts||(u++,l.status="reconnecting",s=setTimeout(function(){s=null,w()},c),c=Math.min(c*2,e.maxReconnectDelay))}function w(){l.status=u>0?"reconnecting":"connecting";try{o=new WebSocket(t,e.protocols)}catch{l.status="closed";return}o.onopen=function(){l.status="open",u=0,c=e.reconnectDelay,e.onOpen();for(let d of i.open)d()},o.onmessage=function(d){let a=f(d.data);for(let p of i.message)p(a)},o.onclose=function(d){l.status="closed",e.onClose(d.code,d.reason);for(let a of i.close)a(d.code,d.reason);n||m()},o.onerror=function(d){e.onError(d);for(let a of i.error)a(d)}}return w(),l}function I(t,r){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,events:[],json:!0,onOpen:function(){},onClose:function(){},onError:function(){},...r||{}},o=null,n=!1,c=e.reconnectDelay,u=0,s=null,i={message:[],open:[],close:[],error:[]},l={status:"connecting",on:function(a,p){return i[a]||(i[a]=[]),i[a].push(p),function(){let g=i[a],T=g.indexOf(p);T>=0&&g.splice(T,1)}},close:function(){n=!0,s&&(clearTimeout(s),s=null),o&&(o.close(),o=null),l.status="closed"}};function f(a){if(!e.json)return a;try{return JSON.parse(a)}catch{return a}}function m(a,p){for(let g of i.message)g(a,p||void 0)}function w(){!e.reconnect||u>=e.maxReconnectAttempts||(u++,l.status="reconnecting",s=setTimeout(function(){s=null,d()},c),c=Math.min(c*2,e.maxReconnectDelay))}function d(){l.status=u>0?"reconnecting":"connecting";try{o=new EventSource(t)}catch{l.status="closed";return}o.onopen=function(){l.status="open",u=0,c=e.reconnectDelay,e.onOpen();for(let a of i.open)a(null)},o.onmessage=function(a){m(f(a.data),null)};for(let a of e.events)o.addEventListener(a,function(p){m(f(p.data),a)});o.onerror=function(a){e.onError(a);for(let p of i.error)p(a);if(o&&o.readyState===2){o=null,l.status="closed",e.onClose();for(let p of i.close)p(null);n||w()}}}return d(),l}var j={set:function(t,r,e){let o="";if(e){let n=new Date;n.setTime(n.getTime()+e*24*60*60*1e3),o="; expires="+n.toUTCString()}document.cookie=t+"="+(r||"")+o+"; path=/"},get:function(t){let r=t+"=",e=document.cookie.split(";");for(let o=0;o<e.length;o++){let n=e[o];for(;n.charAt(0)===" ";)n=n.substring(1);if(n.indexOf(r)===0)return n.substring(r.length)}return null},remove:function(t){document.cookie=t+"=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/"}};function X(t,r){let e=document.getElementById("message");if(!e)return;let o=r||"info";e.innerHTML='<div class="alert alert-'+o+' alert-dismissible">'+t+'<button type="button" class="btn-close" data-t4-dismiss="alert">×</button></div>'}function B(t,r,e,o){let n=window.screenLeft!==void 0?window.screenLeft:window.screenX,c=window.screenTop!==void 0?window.screenTop:window.screenY,u=window.innerWidth||document.documentElement.clientWidth||screen.width,s=window.innerHeight||document.documentElement.clientHeight||screen.height,i=u/window.screen.availWidth,l=(u-e)/2/i+n,f=(s-o)/2/i+c,m=window.open(t,r,"directories=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width="+e/i+",height="+o/i+",top="+f+",left="+l);return window.focus&&m&&m.focus(),m}function P(t){if(t.indexOf("No data available")>=0){window.alert("No data available for this report.");return}window.open(t,"_blank","toolbar=no,scrollbars=yes,resizable=yes,width=800,height=600,top=0,left=0")}function F(t,r,e,o){h(t,{method:"POST",body:{query:r,variables:e||{}},onSuccess:function(n){o&&o(n.data||null,n.errors||void 0)},onError:function(n){o&&o(null,[{message:"GraphQL request failed with status "+n}])}})}function b(t){let r=t.dataset;return r&&r.key?r.key:null}function G(t,r){let e=r.attributes;for(let n=0;n<e.length;n++){let c=e[n];t.getAttribute(c.name)!==c.value&&t.setAttribute(c.name,c.value)}Array.prototype.slice.call(t.attributes).forEach(function(n){r.hasAttribute(n.name)||t.removeAttribute(n.name)})}function J(t,r){let e=r.tagName;e==="INPUT"||e==="TEXTAREA"||e==="SELECT"||(G(t,r),t.children.length||r.children.length?O(t,r):t.innerHTML!==r.innerHTML&&(t.innerHTML=r.innerHTML))}function O(t,r){let e=Array.prototype.slice.call(t.children),o=Array.prototype.slice.call(r.children),n={};e.forEach(function(s){let i=b(s);i&&(n[i]=s)});let c=[];for(let s=0;s<o.length;s++){let i=o[s],l=b(i),f=null;l&&n[l]?f=n[l]:!l&&e[s]&&!b(e[s])&&e[s].tagName===i.tagName&&(f=e[s]),f&&f.tagName===i.tagName?(J(f,i),reused.push(f),c.push(f)):c.push(i)}let u=t.firstElementChild;for(let s=0;s<c.length;s++){let i=c[s];i===u?u=u.nextElementSibling:t.insertBefore(i,u)}e.forEach(function(s){c.indexOf(s)===-1&&s.parentNode===t&&t.removeChild(s)}),reused}function k(t,r){let e=document.createElement("div");if(e.innerHTML=r,!e.children.length||!t.children.length){t.innerHTML=r;return}O(t,e)}function N(t){return/^wss?:\/\//.test(t)?t:(typeof location<"u"&&location.protocol==="https:"?"wss":"ws")+"://"+location.host+t}function z(t,r){return t&&typeof t=="object"?t.type==="live"?r&&t.name&&t.name!==r?null:t.html!=null?String(t.html):null:null:typeof t=="string"?t:null}function S(t){if(typeof document>"u")return;let e=(t||document).querySelectorAll("[data-frond-live]");Array.prototype.slice.call(e).forEach(function(o){if(o.__frondLive)return;o.__frondLive=!0;let n=o.getAttribute("data-mode"),c=o.getAttribute("data-frond-live");if(n==="poll"){let u=o.getAttribute("data-src"),s=(parseInt(o.getAttribute("data-interval"),10)||5)*1e3,i=setInterval(function(){typeof document<"u"&&document.hidden||h(u,{method:"GET",onSuccess:function(l){k(o,typeof l=="string"?l:String(l))}})},s);o.__frondLiveStop=function(){clearInterval(i)}}else if(n==="ws"){let u=x(N(o.getAttribute("data-ws")));u.on("message",function(s){let i=z(s,c);i!==null&&k(o,i)}),o.__frondLiveStop=function(){u.close()}}else n==="sse"&&typeof console<"u"&&console.warn&&console.warn("[frond.live] sse transport is not wired yet (v1 supports poll and ws); block '"+c+"' shows first paint only. Use poll or ws.")})}var C={request:h,load:U,post:L,inject:y,form:M,ws:x,sse:I,live:S,cookie:j,message:X,popup:B,report:P,graphql:F,get token(){return v},set token(t){v=t}};typeof window<"u"&&(window.frond=C,typeof document<"u"&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){S()}):S()));return W(K);})();
|
|
2
2
|
/* Frond v2.2.0 - tina4.com */
|
data/lib/tina4/query_builder.rb
CHANGED
|
@@ -186,6 +186,38 @@ module Tina4
|
|
|
186
186
|
self
|
|
187
187
|
end
|
|
188
188
|
|
|
189
|
+
# Set OFFSET (rows to skip) as its own chainable verb.
|
|
190
|
+
#
|
|
191
|
+
# `limit(n, offset)` buries the offset as a second positional arg; this
|
|
192
|
+
# exposes it on its own, so `.offset(20).limit(10)` reads naturally and
|
|
193
|
+
# matches the SQL keyword.
|
|
194
|
+
#
|
|
195
|
+
# @param count [Integer] Number of rows to skip.
|
|
196
|
+
# @return [self]
|
|
197
|
+
def offset(count)
|
|
198
|
+
@offset_val = count
|
|
199
|
+
self
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Alias for {#offset} — the Mongo/Eloquent name for the same thing.
|
|
203
|
+
#
|
|
204
|
+
# Mirrors the DocStore cursor's `skip` and the `:skip` key that {#to_mongo}
|
|
205
|
+
# already emits, so a query reads the same whether it targets SQL or MongoDB.
|
|
206
|
+
#
|
|
207
|
+
# @param count [Integer] Number of rows to skip.
|
|
208
|
+
# @return [self]
|
|
209
|
+
def skip(count)
|
|
210
|
+
offset(count)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Alias for {#limit} — the Eloquent name; pairs with {#skip}.
|
|
214
|
+
#
|
|
215
|
+
# @param count [Integer] Maximum rows to return.
|
|
216
|
+
# @return [self]
|
|
217
|
+
def take(count)
|
|
218
|
+
limit(count)
|
|
219
|
+
end
|
|
220
|
+
|
|
189
221
|
# Build and return the SQL string without executing.
|
|
190
222
|
#
|
|
191
223
|
# @return [String] The constructed SQL query.
|
|
@@ -42,6 +42,11 @@ module Tina4
|
|
|
42
42
|
}.merge(security)
|
|
43
43
|
@consumer = Rdkafka::Config.new(consumer_config).consumer
|
|
44
44
|
@subscribed_topics = []
|
|
45
|
+
# Records polled but not yet acknowledged, keyed by the id the Queue
|
|
46
|
+
# hands back to acknowledge(), valued by the rdkafka message (its
|
|
47
|
+
# topic/partition/offset). acknowledge() commits exactly THAT record's
|
|
48
|
+
# offset, never a single "last message" slot (ADR-0022 decision 3).
|
|
49
|
+
@in_flight = {}
|
|
45
50
|
rescue LoadError
|
|
46
51
|
raise "Kafka backend requires the 'rdkafka' gem. Install with: gem install rdkafka"
|
|
47
52
|
end
|
|
@@ -157,12 +162,28 @@ module Tina4
|
|
|
157
162
|
return nil unless msg
|
|
158
163
|
|
|
159
164
|
data = Tina4.parse_json(msg.payload)
|
|
160
|
-
|
|
165
|
+
coordinate = "#{topic}:#{msg.partition}:#{msg.offset}"
|
|
166
|
+
|
|
167
|
+
# A record carrying a "payload" key is a Tina4 envelope: keep its own id
|
|
168
|
+
# (falling back to the coordinate) and carry attempts/error back so a
|
|
169
|
+
# redelivery does not reset the count and re-produce a poison record
|
|
170
|
+
# forever. A record WITHOUT one is externally produced (Debezium/CDC and
|
|
171
|
+
# friends, no envelope, no id -- tina4#274): its whole value IS the
|
|
172
|
+
# payload, and the coordinate becomes its id. Either way the job gets the
|
|
173
|
+
# id acknowledge() maps back to this record's offset.
|
|
174
|
+
job =
|
|
175
|
+
if data.is_a?(Hash) && data.key?("payload")
|
|
176
|
+
id = data["id"].to_s.empty? ? coordinate : data["id"]
|
|
177
|
+
Tina4::Job.new(
|
|
178
|
+
topic: topic, payload: data["payload"], id: id,
|
|
179
|
+
attempts: data["attempts"] || 0, error: data["error"]
|
|
180
|
+
)
|
|
181
|
+
else
|
|
182
|
+
Tina4::Job.new(topic: topic, payload: data, id: coordinate, attempts: 0)
|
|
183
|
+
end
|
|
161
184
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
# redelivery, so a poison record would be re-produced forever.
|
|
165
|
-
Tina4::Job.from_payload(data)
|
|
185
|
+
@in_flight[job.id.to_s] = msg
|
|
186
|
+
job
|
|
166
187
|
rescue Rdkafka::RdkafkaError
|
|
167
188
|
nil
|
|
168
189
|
end
|
|
@@ -177,8 +198,19 @@ module Tina4
|
|
|
177
198
|
0
|
|
178
199
|
end
|
|
179
200
|
|
|
180
|
-
|
|
181
|
-
|
|
201
|
+
# Commit the offset JUST PAST the acknowledged record so the group never
|
|
202
|
+
# re-reads it, even across a restart (ADR-0022 decisions 2 + 4). The offset
|
|
203
|
+
# is committed for the specific record dequeue() tracked against this job's
|
|
204
|
+
# id -- NOT a bare @consumer.commit of the whole polled position, which
|
|
205
|
+
# would commit past records still in flight when jobs are completed out of
|
|
206
|
+
# order (decision 3). A job never dequeued (or already acked) is a no-op.
|
|
207
|
+
def acknowledge(message)
|
|
208
|
+
msg = @in_flight.delete(message.id.to_s)
|
|
209
|
+
return unless msg
|
|
210
|
+
|
|
211
|
+
tpl = Rdkafka::Consumer::TopicPartitionList.new
|
|
212
|
+
tpl.add_topic_and_partitions_with_offsets(msg.topic, msg.partition => msg.offset + 1)
|
|
213
|
+
@consumer.commit(tpl, false)
|
|
182
214
|
end
|
|
183
215
|
|
|
184
216
|
def requeue(message)
|
data/lib/tina4/shutdown.rb
CHANGED
|
@@ -106,10 +106,21 @@ module Tina4
|
|
|
106
106
|
# otherwise leak on every single shutdown.
|
|
107
107
|
def release_resources
|
|
108
108
|
close_websockets
|
|
109
|
+
stop_tcp_services
|
|
109
110
|
stop_background_tasks
|
|
110
111
|
close_database
|
|
111
112
|
end
|
|
112
113
|
|
|
114
|
+
# Drain live raw TCP service connections (feature 142) within the deadline,
|
|
115
|
+
# firing their on_close handlers, alongside the HTTP/WebSocket drain.
|
|
116
|
+
def stop_tcp_services
|
|
117
|
+
return unless defined?(Tina4::Tcp)
|
|
118
|
+
|
|
119
|
+
Tina4::Tcp.drain_all(timeout: @timeout.to_f)
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
Tina4::Log.error("Error draining TCP services: #{e.message}")
|
|
122
|
+
end
|
|
123
|
+
|
|
113
124
|
# Block until initiate_shutdown has finished every teardown step.
|
|
114
125
|
#
|
|
115
126
|
# stop_accepting unblocks the built-in server's accept loop immediately, so
|
|
@@ -158,6 +169,8 @@ module Tina4
|
|
|
158
169
|
return unless @server.respond_to?(:shutdown)
|
|
159
170
|
|
|
160
171
|
@server.shutdown
|
|
172
|
+
# Raw TCP listeners stop accepting at the same moment as HTTP.
|
|
173
|
+
Tina4::Tcp.stop_accepting_all if defined?(Tina4::Tcp)
|
|
161
174
|
Tina4::Log.info("Stopped accepting new connections")
|
|
162
175
|
rescue StandardError => e
|
|
163
176
|
Tina4::Log.error("Error closing the listening socket: #{e.message}")
|
data/lib/tina4/tcp.rb
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright (c) 2026 Code Infinity
|
|
4
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
6
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
7
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
8
|
+
|
|
9
|
+
require "socket"
|
|
10
|
+
|
|
11
|
+
module Tina4
|
|
12
|
+
# Raw TCP service — a user-facing, non-HTTP, long-lived binary TCP listener.
|
|
13
|
+
#
|
|
14
|
+
# Governed by ADR-0102 (feature 142), and the Python reference
|
|
15
|
+
# tina4_python/tcp. A Tina4 app exposes its own protocol — a GPS/GPRS
|
|
16
|
+
# car-tracker gateway (GT06/TK103/Concox/Teltonika) is the driving case — in
|
|
17
|
+
# the SAME process as its HTTP dashboard, reusing config, logging, ORM and
|
|
18
|
+
# queue.
|
|
19
|
+
#
|
|
20
|
+
# svc = Tina4::Tcp.service(5023, idle_timeout: 0, max_connections: 5000)
|
|
21
|
+
# svc.on_data do |conn, data|
|
|
22
|
+
# frame = parse(data) # the APP frames the stream
|
|
23
|
+
# conn.identify(frame.imei) if frame.login?
|
|
24
|
+
# conn.send(ack_for(frame)) # ACK on the same socket
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# # server -> device push from an HTTP route:
|
|
28
|
+
# Tina4::Tcp.get.send_to(imei, build_command("set_interval", 30))
|
|
29
|
+
#
|
|
30
|
+
# Ruby's thread-per-connection model satisfies D1 (in-process): the acceptor
|
|
31
|
+
# thread runs in the SAME VM as the HTTP server, so the device gateway and its
|
|
32
|
+
# dashboard share one process. The framework owns the socket hard-parts:
|
|
33
|
+
# lifecycle, max_connections refusal, an idle reaper (default OFF for
|
|
34
|
+
# long-lived device sockets), graceful-shutdown drain, bounded write
|
|
35
|
+
# backpressure, and single-owner bind under N workers.
|
|
36
|
+
#
|
|
37
|
+
# Non-goals in v1 (ADR-0102): no CRC/length framing helper beyond on_line, no
|
|
38
|
+
# TLS termination, no UDP, no replacement for the WebSocket surface.
|
|
39
|
+
|
|
40
|
+
# One live TCP connection — the uniform connection object (ADR-0102 D5). The
|
|
41
|
+
# SAME surface exists in all four frameworks so the primitive ports.
|
|
42
|
+
class TcpConnection
|
|
43
|
+
# The raw TCP peer address ("127.0.0.1:54321"), never a forwarded header.
|
|
44
|
+
attr_reader :remote_addr
|
|
45
|
+
# Per-connection mutable state bag — the app's parse buffer, auth flag.
|
|
46
|
+
attr_accessor :context
|
|
47
|
+
attr_reader :socket
|
|
48
|
+
# Stable registry key: the fd number captured at construction (see below).
|
|
49
|
+
attr_reader :fileno
|
|
50
|
+
|
|
51
|
+
def initialize(socket, service, remote_addr)
|
|
52
|
+
@socket = socket
|
|
53
|
+
# Cache the fd number now: once the socket is closed, #fileno raises
|
|
54
|
+
# IOError, and the registry still needs a stable key to remove by.
|
|
55
|
+
@fileno = begin
|
|
56
|
+
socket.fileno
|
|
57
|
+
rescue IOError, SystemCallError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
@service = service
|
|
61
|
+
@remote_addr = remote_addr
|
|
62
|
+
@id = nil # the registry id (IMEI), nil until identify
|
|
63
|
+
@context = {}
|
|
64
|
+
@closed = false
|
|
65
|
+
@close_fired = false
|
|
66
|
+
@last_activity = monotonic
|
|
67
|
+
@pending = +"".b
|
|
68
|
+
@line_buffer = +"".b
|
|
69
|
+
@write_lock = Mutex.new
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The registry id (the IMEI), or nil until identify is called.
|
|
73
|
+
def id
|
|
74
|
+
@id
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def closed?
|
|
78
|
+
@closed
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def last_activity
|
|
82
|
+
@last_activity
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def touch
|
|
86
|
+
@last_activity = monotonic
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Accessors the service's reader loop uses for on_line buffering.
|
|
90
|
+
def line_buffer
|
|
91
|
+
@line_buffer
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def line_buffer=(value)
|
|
95
|
+
@line_buffer = value
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Register this live connection under an app-chosen id (the IMEI). A second
|
|
99
|
+
# connection identifying with the same id SUPERSEDES the first in the
|
|
100
|
+
# registry — exactly a device that dropped and reconnected.
|
|
101
|
+
def identify(app_id)
|
|
102
|
+
@id = app_id.to_s
|
|
103
|
+
@service.register_id(@id, self)
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Write raw bytes to THIS socket. Returns true when queued/sent, false when
|
|
108
|
+
# the connection is closed or dropped for backpressure. A stalled reader
|
|
109
|
+
# (never draining) is bounded: once more than the service's max_backlog
|
|
110
|
+
# bytes are outstanding, the connection is dropped rather than buffered
|
|
111
|
+
# without end (mirrors the WebSocket TINA4_WS_MAX_BACKLOG posture).
|
|
112
|
+
def send(data)
|
|
113
|
+
return false if @closed
|
|
114
|
+
|
|
115
|
+
bytes = data.to_s.dup.force_encoding(Encoding::BINARY)
|
|
116
|
+
overflow = false
|
|
117
|
+
@write_lock.synchronize do
|
|
118
|
+
@pending << bytes
|
|
119
|
+
flush_pending
|
|
120
|
+
limit = @service.max_backlog
|
|
121
|
+
overflow = (limit.positive? && @pending.bytesize > limit)
|
|
122
|
+
end
|
|
123
|
+
return false if @closed
|
|
124
|
+
|
|
125
|
+
if overflow
|
|
126
|
+
Tina4::Log.warning(
|
|
127
|
+
"tcp_service '#{@service.name}': dropping a stalled reader — " \
|
|
128
|
+
"#{@pending.bytesize} bytes queued exceeds the #{@service.max_backlog}-byte backlog limit"
|
|
129
|
+
)
|
|
130
|
+
close
|
|
131
|
+
return false
|
|
132
|
+
end
|
|
133
|
+
true
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Flush the per-connection queue without blocking. Caller holds @write_lock.
|
|
137
|
+
def flush_pending
|
|
138
|
+
return if @socket.nil? || @pending.empty?
|
|
139
|
+
|
|
140
|
+
until @pending.empty?
|
|
141
|
+
written = @socket.write_nonblock(@pending, exception: false)
|
|
142
|
+
break if written == :wait_writable
|
|
143
|
+
|
|
144
|
+
@pending = @pending.byteslice(written..) || +"".b
|
|
145
|
+
end
|
|
146
|
+
rescue IOError, SystemCallError
|
|
147
|
+
@closed = true
|
|
148
|
+
close_socket
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Opportunistic flush from the reader loop (acquires the lock).
|
|
152
|
+
def try_flush
|
|
153
|
+
return if @closed
|
|
154
|
+
|
|
155
|
+
@write_lock.synchronize { flush_pending }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Close this connection. on_close fires exactly once (from the service's
|
|
159
|
+
# removal path) and the registry entry is removed.
|
|
160
|
+
def close
|
|
161
|
+
return if @closed
|
|
162
|
+
|
|
163
|
+
@closed = true
|
|
164
|
+
close_socket
|
|
165
|
+
@service.remove(self)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Fire on_close exactly once. Called by the service when the connection
|
|
169
|
+
# leaves (peer close, server close, reap, or drain).
|
|
170
|
+
def fire_close
|
|
171
|
+
return if @close_fired
|
|
172
|
+
|
|
173
|
+
@close_fired = true
|
|
174
|
+
@closed = true
|
|
175
|
+
handler = @service.callback(:on_close)
|
|
176
|
+
return if handler.nil?
|
|
177
|
+
|
|
178
|
+
begin
|
|
179
|
+
handler.call(self)
|
|
180
|
+
rescue StandardError => e
|
|
181
|
+
Tina4::Log.error("tcp_service '#{@service.name}' on_close error: #{e.message}")
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def close_socket
|
|
188
|
+
# Shut the socket down before closing so the peer sees EOF immediately.
|
|
189
|
+
# A plain close is enough on macOS, but on Linux the FIN can be deferred
|
|
190
|
+
# while the reader thread still holds the fd in IO.select, so the client
|
|
191
|
+
# never observes end-of-stream. shutdown(SHUT_RDWR) forces it.
|
|
192
|
+
@socket&.shutdown(Socket::SHUT_RDWR)
|
|
193
|
+
rescue IOError, SystemCallError
|
|
194
|
+
nil
|
|
195
|
+
ensure
|
|
196
|
+
begin
|
|
197
|
+
@socket&.close
|
|
198
|
+
rescue IOError, SystemCallError
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def monotonic
|
|
204
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# A named raw TCP listener and its connection registry (ADR-0102).
|
|
209
|
+
class TcpService
|
|
210
|
+
READ_CHUNK = 65_536
|
|
211
|
+
|
|
212
|
+
attr_reader :name, :port
|
|
213
|
+
|
|
214
|
+
def initialize(port, name: "default", on_connect: nil, on_data: nil,
|
|
215
|
+
on_close: nil, on_line: nil, idle_timeout: 0,
|
|
216
|
+
max_connections: nil, max_backlog: nil, &block)
|
|
217
|
+
@port = Integer(port)
|
|
218
|
+
@name = name.to_s
|
|
219
|
+
@callbacks = {
|
|
220
|
+
on_connect: on_connect,
|
|
221
|
+
on_data: on_data || block, # a bare block is the on_data handler
|
|
222
|
+
on_close: on_close,
|
|
223
|
+
on_line: on_line
|
|
224
|
+
}
|
|
225
|
+
# Default OFF: tracker sockets are deliberately long-lived and mostly idle
|
|
226
|
+
# between location packets, so a default reaper would kill healthy devices.
|
|
227
|
+
@idle_timeout = idle_timeout.to_f
|
|
228
|
+
@max_connections = max_connections.nil? ? nil : Integer(max_connections)
|
|
229
|
+
@max_backlog = self.class.resolve_max_backlog(max_backlog)
|
|
230
|
+
|
|
231
|
+
@connections = {} # socket.fileno => TcpConnection
|
|
232
|
+
@by_id = {} # app_id => TcpConnection
|
|
233
|
+
@mutex = Mutex.new
|
|
234
|
+
@server = nil
|
|
235
|
+
@owner = false
|
|
236
|
+
@bound_port = nil
|
|
237
|
+
@acceptor = nil
|
|
238
|
+
@readers = {} # object_id => Thread
|
|
239
|
+
@running = false
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def self.resolve_max_backlog(explicit)
|
|
243
|
+
default = 1 << 20 # 1 MiB
|
|
244
|
+
return (explicit.to_i.positive? ? explicit.to_i : default) unless explicit.nil?
|
|
245
|
+
|
|
246
|
+
raw = (ENV["TINA4_TCP_MAX_BACKLOG"] || "").strip
|
|
247
|
+
return default if raw.empty?
|
|
248
|
+
|
|
249
|
+
value = raw.to_i
|
|
250
|
+
value.positive? ? value : default
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def max_backlog
|
|
254
|
+
@max_backlog
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def callback(event)
|
|
258
|
+
@callbacks[event]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def owner?
|
|
262
|
+
@owner
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def bound_port
|
|
266
|
+
@bound_port || @port
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def count
|
|
270
|
+
@mutex.synchronize { @connections.size }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# ── Decorator-style setters (parity with the Python @svc.on_* handlers) ──
|
|
274
|
+
|
|
275
|
+
def on_connect(&block) = set_callback(:on_connect, block)
|
|
276
|
+
def on_data(&block) = set_callback(:on_data, block)
|
|
277
|
+
def on_close(&block) = set_callback(:on_close, block)
|
|
278
|
+
def on_line(&block) = set_callback(:on_line, block)
|
|
279
|
+
|
|
280
|
+
# ── Registry (D4 — server -> device push) ──────────────────────────────
|
|
281
|
+
|
|
282
|
+
def register_id(app_id, conn)
|
|
283
|
+
@mutex.synchronize { @by_id[app_id] = conn } # supersede: the live one wins
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# The live connection for an app id, or nil if unknown or closed.
|
|
287
|
+
def connection(app_id)
|
|
288
|
+
conn = @mutex.synchronize { @by_id[app_id.to_s] }
|
|
289
|
+
return nil if conn.nil? || conn.closed?
|
|
290
|
+
|
|
291
|
+
conn
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Write bytes to exactly the connection identified by app_id. A send to an
|
|
295
|
+
# unknown or closed id is a NO-OP that returns false — never a raise.
|
|
296
|
+
def send_to(app_id, data)
|
|
297
|
+
conn = connection(app_id)
|
|
298
|
+
return false if conn.nil?
|
|
299
|
+
|
|
300
|
+
conn.send(data)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Write bytes to every live connection. Returns how many received it.
|
|
304
|
+
def broadcast(data)
|
|
305
|
+
conns = @mutex.synchronize { @connections.values.dup }
|
|
306
|
+
conns.count { |conn| conn.send(data) }
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# ── Lifecycle ────────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
# Bind the listener on host:port and start accepting on a dedicated thread.
|
|
312
|
+
# SO_REUSEPORT is OFF, so under a multi-worker deployment exactly ONE worker
|
|
313
|
+
# wins the bind; a worker that finds the port already bound skips silently
|
|
314
|
+
# (single-owner, D1). Returns true when THIS call bound the port.
|
|
315
|
+
def start(host = "0.0.0.0")
|
|
316
|
+
return @owner unless @server.nil?
|
|
317
|
+
|
|
318
|
+
begin
|
|
319
|
+
@server = TCPServer.new(host, @port)
|
|
320
|
+
rescue Errno::EADDRINUSE, Errno::EADDRNOTAVAIL => e
|
|
321
|
+
@owner = false
|
|
322
|
+
Tina4::Log.info(
|
|
323
|
+
"tcp_service '#{@name}' port #{@port} is already bound by another worker — " \
|
|
324
|
+
"single-owner, this worker will not bind (#{e.class})"
|
|
325
|
+
)
|
|
326
|
+
return false
|
|
327
|
+
end
|
|
328
|
+
@owner = true
|
|
329
|
+
@running = true
|
|
330
|
+
@bound_port = @server.addr[1]
|
|
331
|
+
Tina4::Log.info("tcp_service '#{@name}' listening on #{host}:#{@bound_port}")
|
|
332
|
+
@acceptor = Thread.new { accept_loop }
|
|
333
|
+
true
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Stop accepting new connections (close the listener). Live connections stay.
|
|
337
|
+
def stop_accepting
|
|
338
|
+
@running = false
|
|
339
|
+
srv = @server
|
|
340
|
+
@server = nil
|
|
341
|
+
srv&.close
|
|
342
|
+
rescue IOError, SystemCallError
|
|
343
|
+
nil
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Close every live connection (firing on_close) and release the listener.
|
|
347
|
+
def drain(timeout: 5.0)
|
|
348
|
+
stop_accepting
|
|
349
|
+
@acceptor&.join(timeout)
|
|
350
|
+
conns = @mutex.synchronize { @connections.values.dup }
|
|
351
|
+
conns.each(&:close)
|
|
352
|
+
readers = @mutex.synchronize { @readers.values.dup }
|
|
353
|
+
deadline = monotonic + timeout
|
|
354
|
+
readers.each do |thread|
|
|
355
|
+
remaining = deadline - monotonic
|
|
356
|
+
thread.join(remaining.positive? ? remaining : 0)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
alias stop drain
|
|
361
|
+
|
|
362
|
+
# Remove a connection from the registry and fire on_close exactly once.
|
|
363
|
+
# Called by TcpConnection#close and by the reader loop on EOF.
|
|
364
|
+
def remove(conn)
|
|
365
|
+
tracked = false
|
|
366
|
+
@mutex.synchronize do
|
|
367
|
+
key = conn.fileno
|
|
368
|
+
if key && @connections[key].equal?(conn)
|
|
369
|
+
@connections.delete(key)
|
|
370
|
+
tracked = true
|
|
371
|
+
else
|
|
372
|
+
found = @connections.key(conn)
|
|
373
|
+
unless found.nil?
|
|
374
|
+
@connections.delete(found)
|
|
375
|
+
tracked = true
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
# Only drop the id when THIS connection still owns it; a newer
|
|
379
|
+
# connection that superseded it keeps the id.
|
|
380
|
+
@by_id.delete(conn.id) if conn.id && @by_id[conn.id].equal?(conn)
|
|
381
|
+
end
|
|
382
|
+
conn.fire_close if tracked
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
private
|
|
386
|
+
|
|
387
|
+
def set_callback(event, block)
|
|
388
|
+
@callbacks[event] = block if block
|
|
389
|
+
self
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def accept_loop
|
|
393
|
+
while @running
|
|
394
|
+
begin
|
|
395
|
+
socket = @server&.accept
|
|
396
|
+
rescue IOError, Errno::EBADF, Errno::ECONNABORTED
|
|
397
|
+
break # listener closed by stop_accepting
|
|
398
|
+
end
|
|
399
|
+
break if socket.nil?
|
|
400
|
+
|
|
401
|
+
handle_accept(socket)
|
|
402
|
+
end
|
|
403
|
+
rescue StandardError => e
|
|
404
|
+
Tina4::Log.error("tcp_service '#{@name}' accept loop error: #{e.message}")
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def handle_accept(socket)
|
|
408
|
+
# Refuse past the limit BEFORE the connection joins the registry.
|
|
409
|
+
over_limit = @mutex.synchronize do
|
|
410
|
+
!@max_connections.nil? && @connections.size >= @max_connections
|
|
411
|
+
end
|
|
412
|
+
if over_limit
|
|
413
|
+
socket.close rescue nil
|
|
414
|
+
return
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
conn = TcpConnection.new(socket, self, peer_address(socket))
|
|
418
|
+
@mutex.synchronize { @connections[conn.fileno] = conn }
|
|
419
|
+
|
|
420
|
+
fire(:on_connect, conn)
|
|
421
|
+
|
|
422
|
+
reader = Thread.new { reader_loop(conn) }
|
|
423
|
+
@mutex.synchronize { @readers[conn.object_id] = reader }
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def reader_loop(conn)
|
|
427
|
+
socket = conn.socket
|
|
428
|
+
line_handler = @callbacks[:on_line]
|
|
429
|
+
sweep = @idle_timeout.positive? ? [@idle_timeout / 2.0, 1.0].min.clamp(0.05, 1.0) : nil
|
|
430
|
+
loop do
|
|
431
|
+
break if conn.closed?
|
|
432
|
+
|
|
433
|
+
ready = IO.select([socket], nil, nil, sweep)
|
|
434
|
+
if ready.nil?
|
|
435
|
+
# Idle sweep: close a connection silent past the timeout.
|
|
436
|
+
if @idle_timeout.positive? && (monotonic - conn.last_activity) >= @idle_timeout
|
|
437
|
+
conn.close
|
|
438
|
+
break
|
|
439
|
+
end
|
|
440
|
+
next
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
data = socket.read_nonblock(READ_CHUNK, exception: false)
|
|
444
|
+
next if data == :wait_readable
|
|
445
|
+
break if data.nil? # EOF — peer closed
|
|
446
|
+
|
|
447
|
+
conn.touch
|
|
448
|
+
dispatch_data(conn, data, line_handler)
|
|
449
|
+
conn.try_flush
|
|
450
|
+
end
|
|
451
|
+
rescue IOError, EOFError, Errno::ECONNRESET, Errno::EBADF
|
|
452
|
+
nil
|
|
453
|
+
rescue StandardError => e
|
|
454
|
+
Tina4::Log.error("tcp_service '#{@name}' handler error: #{e.message}")
|
|
455
|
+
ensure
|
|
456
|
+
@mutex.synchronize { @readers.delete(conn.object_id) }
|
|
457
|
+
remove(conn)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def dispatch_data(conn, data, line_handler)
|
|
461
|
+
on_data = @callbacks[:on_data]
|
|
462
|
+
# Raw bytes, exactly as received — never newline-split, never decoded.
|
|
463
|
+
if on_data
|
|
464
|
+
begin
|
|
465
|
+
on_data.call(conn, data)
|
|
466
|
+
rescue StandardError => e
|
|
467
|
+
Tina4::Log.error("tcp_service '#{@name}' on_data error: #{e.message}")
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
return unless line_handler
|
|
472
|
+
|
|
473
|
+
buffer = conn.line_buffer
|
|
474
|
+
buffer << data
|
|
475
|
+
while (index = buffer.index("\n"))
|
|
476
|
+
line = buffer.byteslice(0, index)
|
|
477
|
+
buffer = buffer.byteslice(index + 1..) || +"".b
|
|
478
|
+
line = line.chomp("\r")
|
|
479
|
+
begin
|
|
480
|
+
line_handler.call(conn, line)
|
|
481
|
+
rescue StandardError => e
|
|
482
|
+
Tina4::Log.error("tcp_service '#{@name}' on_line error: #{e.message}")
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
conn.line_buffer = buffer
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def fire(event, conn)
|
|
489
|
+
handler = @callbacks[event]
|
|
490
|
+
return if handler.nil?
|
|
491
|
+
|
|
492
|
+
begin
|
|
493
|
+
handler.call(conn)
|
|
494
|
+
rescue StandardError => e
|
|
495
|
+
Tina4::Log.error("tcp_service '#{@name}' #{event} error: #{e.message}")
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def peer_address(socket)
|
|
500
|
+
addr = socket.peeraddr(false)
|
|
501
|
+
"#{addr[3]}:#{addr[1]}"
|
|
502
|
+
rescue StandardError
|
|
503
|
+
""
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def monotonic
|
|
507
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Registry facade and the server-driven lifecycle hooks.
|
|
512
|
+
module Tcp
|
|
513
|
+
@services = {}
|
|
514
|
+
@mutex = Mutex.new
|
|
515
|
+
|
|
516
|
+
class << self
|
|
517
|
+
# Register a named raw TCP service; the server binds it on boot (D1).
|
|
518
|
+
def service(port, **options, &block)
|
|
519
|
+
name = (options[:name] || "default").to_s
|
|
520
|
+
svc = TcpService.new(port, **options, &block)
|
|
521
|
+
@mutex.synchronize { @services[name] = svc }
|
|
522
|
+
svc
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# The registry for a named service, for server -> device push. Raises for
|
|
526
|
+
# an unknown service name — a programming error, unlike a send to an
|
|
527
|
+
# unknown device id, which is a runtime no-op.
|
|
528
|
+
def get(name = "default")
|
|
529
|
+
svc = @mutex.synchronize { @services[name.to_s] }
|
|
530
|
+
return svc unless svc.nil?
|
|
531
|
+
|
|
532
|
+
known = @services.keys.empty? ? "(none registered)" : @services.keys.join(", ")
|
|
533
|
+
raise KeyError, "no tcp_service named '#{name}' is registered; known services: #{known}"
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def registered
|
|
537
|
+
@mutex.synchronize { @services.values.dup }
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# Drop every registered service (test isolation; not used in production).
|
|
541
|
+
def clear_registry
|
|
542
|
+
@mutex.synchronize { @services = {} }
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Start every registered service on boot (called by the web server).
|
|
546
|
+
def start_all(host = "0.0.0.0")
|
|
547
|
+
registered.each do |svc|
|
|
548
|
+
svc.start(host)
|
|
549
|
+
rescue StandardError => e
|
|
550
|
+
Tina4::Log.error("tcp_service '#{svc.name}' failed to start: #{e.message}")
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# Stop every service accepting (shutdown step 1).
|
|
555
|
+
def stop_accepting_all
|
|
556
|
+
registered.each do |svc|
|
|
557
|
+
svc.stop_accepting
|
|
558
|
+
rescue StandardError => e
|
|
559
|
+
Tina4::Log.error("tcp_service '#{svc.name}' stop-accepting failed: #{e.message}")
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Drain and release every service's connections (shutdown drain step).
|
|
564
|
+
def drain_all(timeout: 5.0)
|
|
565
|
+
registered.each do |svc|
|
|
566
|
+
svc.drain(timeout: timeout)
|
|
567
|
+
rescue StandardError => e
|
|
568
|
+
Tina4::Log.error("tcp_service '#{svc.name}' drain failed: #{e.message}")
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
end
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4/webserver.rb
CHANGED
|
@@ -130,6 +130,11 @@ module Tina4
|
|
|
130
130
|
# rescue stays out of #start's cyclomatic complexity.
|
|
131
131
|
bind_auxiliary_ai_port
|
|
132
132
|
|
|
133
|
+
# Raw TCP services (feature 142, ADR-0102 D1) run their acceptor threads in
|
|
134
|
+
# THIS process, beside the HTTP server — one process serves both the device
|
|
135
|
+
# gateway and its dashboard. Shutdown drains them (see Tina4::Shutdown).
|
|
136
|
+
Tina4::Tcp.start_all(@host)
|
|
137
|
+
|
|
133
138
|
@server.start
|
|
134
139
|
|
|
135
140
|
# Shutdown closes the listener FIRST, so #start returns as soon as the
|
data/lib/tina4.rb
CHANGED
|
@@ -48,6 +48,7 @@ require_relative "tina4/localization"
|
|
|
48
48
|
require_relative "tina4/container"
|
|
49
49
|
require_relative "tina4/service"
|
|
50
50
|
require_relative "tina4/service_runner"
|
|
51
|
+
require_relative "tina4/tcp"
|
|
51
52
|
require_relative "tina4/events"
|
|
52
53
|
require_relative "tina4/plan"
|
|
53
54
|
require_relative "tina4/project_index"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tina4ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.13.
|
|
4
|
+
version: 3.13.158
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tina4 Team
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-11-
|
|
11
|
+
date: 2026-11-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: sqlite3
|
|
@@ -354,6 +354,7 @@ files:
|
|
|
354
354
|
- lib/tina4/sso.rb
|
|
355
355
|
- lib/tina4/ssrf.rb
|
|
356
356
|
- lib/tina4/swagger.rb
|
|
357
|
+
- lib/tina4/tcp.rb
|
|
357
358
|
- lib/tina4/template.rb
|
|
358
359
|
- lib/tina4/templates/base.twig
|
|
359
360
|
- lib/tina4/templates/crud/form.twig
|