tina4ruby 3.11.13 → 3.11.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +935 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -110
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -106
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2025 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +696 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/public/css/tina4.css +2463 -2463
  63. data/lib/tina4/public/css/tina4.min.css +1 -1
  64. data/lib/tina4/public/images/logo.svg +5 -5
  65. data/lib/tina4/public/js/frond.min.js +2 -2
  66. data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
  67. data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
  68. data/lib/tina4/public/js/tina4.min.js +92 -92
  69. data/lib/tina4/public/js/tina4js.min.js +48 -48
  70. data/lib/tina4/public/swagger/index.html +90 -90
  71. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  72. data/lib/tina4/query_builder.rb +380 -380
  73. data/lib/tina4/queue.rb +366 -366
  74. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  75. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  76. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  77. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  78. data/lib/tina4/rack_app.rb +817 -817
  79. data/lib/tina4/rate_limiter.rb +130 -130
  80. data/lib/tina4/request.rb +268 -255
  81. data/lib/tina4/response.rb +346 -346
  82. data/lib/tina4/response_cache.rb +551 -551
  83. data/lib/tina4/router.rb +406 -406
  84. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  85. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  86. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  87. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  88. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  89. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  90. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  91. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  92. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  93. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  94. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  95. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  96. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  97. data/lib/tina4/scss/tina4css/base.scss +1 -1
  98. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  99. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  100. data/lib/tina4/scss_compiler.rb +178 -178
  101. data/lib/tina4/seeder.rb +567 -567
  102. data/lib/tina4/service_runner.rb +303 -303
  103. data/lib/tina4/session.rb +297 -297
  104. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  105. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  106. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  107. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  108. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  109. data/lib/tina4/shutdown.rb +84 -84
  110. data/lib/tina4/sql_translation.rb +158 -158
  111. data/lib/tina4/swagger.rb +124 -124
  112. data/lib/tina4/template.rb +894 -894
  113. data/lib/tina4/templates/base.twig +26 -26
  114. data/lib/tina4/templates/errors/302.twig +14 -14
  115. data/lib/tina4/templates/errors/401.twig +9 -9
  116. data/lib/tina4/templates/errors/403.twig +29 -29
  117. data/lib/tina4/templates/errors/404.twig +29 -29
  118. data/lib/tina4/templates/errors/500.twig +38 -38
  119. data/lib/tina4/templates/errors/502.twig +9 -9
  120. data/lib/tina4/templates/errors/503.twig +12 -12
  121. data/lib/tina4/templates/errors/base.twig +37 -37
  122. data/lib/tina4/test_client.rb +159 -159
  123. data/lib/tina4/testing.rb +340 -340
  124. data/lib/tina4/validator.rb +174 -174
  125. data/lib/tina4/version.rb +1 -1
  126. data/lib/tina4/webserver.rb +312 -312
  127. data/lib/tina4/websocket.rb +343 -343
  128. data/lib/tina4/websocket_backplane.rb +190 -190
  129. data/lib/tina4/wsdl.rb +564 -564
  130. data/lib/tina4.rb +458 -458
  131. data/lib/tina4ruby.rb +4 -4
  132. metadata +3 -3
@@ -1,312 +1,312 @@
1
- # frozen_string_literal: true
2
-
3
- module Tina4
4
- class WebServer
5
- def initialize(app, host: "0.0.0.0", port: 7147)
6
- @app = app
7
- @host = host
8
- @port = port
9
- end
10
-
11
- # Kill whatever process is listening on *port*.
12
- # Uses lsof on macOS/Linux and netstat + taskkill on Windows.
13
- # Raises RuntimeError if the port cannot be freed.
14
- def free_port(port)
15
- puts " Port #{port} in use — killing existing process..."
16
-
17
- if RUBY_PLATFORM =~ /mswin|mingw|cygwin/
18
- output = `netstat -ano 2>&1`
19
- pid = nil
20
- output.each_line do |line|
21
- if line.include?(":#{port}") && (line.include?("LISTENING") || line.include?("ESTABLISHED"))
22
- parts = line.strip.split(/\s+/)
23
- candidate = parts.last
24
- if candidate =~ /^\d+$/
25
- pid = candidate
26
- break
27
- end
28
- end
29
- end
30
- if pid
31
- system("taskkill /PID #{pid} /F")
32
- else
33
- raise "Could not free port #{port}: no PID found"
34
- end
35
- else
36
- pids = `lsof -ti :#{port} 2>/dev/null`.strip.split("\n")
37
- if pids.empty?
38
- return # Nothing found — port may have freed itself
39
- end
40
- pids.each do |pid|
41
- pid = pid.strip
42
- next unless pid =~ /^\d+$/
43
- begin
44
- Process.kill("TERM", pid.to_i)
45
- rescue Errno::ESRCH
46
- # Process already gone
47
- end
48
- end
49
- end
50
-
51
- # Give the OS a moment to reclaim the port
52
- sleep(0.5)
53
- puts " Port #{port} freed"
54
- end
55
-
56
- def start
57
- is_managed = ARGV.include?('--managed')
58
- unless is_managed || ENV['TINA4_OVERRIDE_CLIENT'] == 'true'
59
- puts
60
- puts '=' * 60
61
- puts
62
- puts ' Tina4 must be started with the tina4 CLI:'
63
- puts
64
- puts ' tina4 serve (development)'
65
- puts ' tina4 serve --production (production)'
66
- puts
67
- puts ' Install: cargo install tina4'
68
- puts ' Docs: https://tina4.com'
69
- puts
70
- puts ' To run directly, add to .env:'
71
- puts ' TINA4_OVERRIDE_CLIENT=true'
72
- puts
73
- puts '=' * 60
74
- puts
75
- exit 1
76
- end
77
-
78
- require "webrick"
79
- require "stringio"
80
- require "socket"
81
-
82
- # Ensure the main port is available — kill whatever is on it if needed
83
- begin
84
- test = TCPServer.new("0.0.0.0", @port)
85
- test.close
86
- rescue Errno::EADDRINUSE
87
- free_port(@port)
88
- # Verify the port is now free; raise if still occupied
89
- begin
90
- test = TCPServer.new("0.0.0.0", @port)
91
- test.close
92
- rescue Errno::EADDRINUSE
93
- raise "Could not free port #{@port}"
94
- end
95
- end
96
-
97
- Tina4.print_banner(host: @host, port: @port)
98
- Tina4::Log.info("Starting Tina4 WEBrick server on http://#{@host}:#{@port}")
99
- @server = WEBrick::HTTPServer.new(
100
- BindAddress: @host,
101
- Port: @port,
102
- Logger: WEBrick::Log.new(File::NULL),
103
- AccessLog: []
104
- )
105
-
106
- # Setup graceful shutdown with WEBrick server reference
107
- Tina4::Shutdown.setup(server: @server)
108
-
109
- # Use a custom servlet that passes ALL methods (including OPTIONS) to Rack
110
- rack_app = @app
111
- servlet = Class.new(WEBrick::HTTPServlet::AbstractServlet) do
112
- define_method(:initialize) do |server, app|
113
- super(server)
114
- @app = app
115
- end
116
-
117
- %w[GET POST PUT DELETE PATCH HEAD OPTIONS].each do |http_method|
118
- define_method("do_#{http_method}") do |webrick_req, webrick_res|
119
- handle_request(webrick_req, webrick_res)
120
- end
121
- end
122
-
123
- define_method(:handle_request) do |webrick_req, webrick_res|
124
- # Reject new requests during shutdown
125
- if Tina4::Shutdown.shutting_down?
126
- webrick_res.status = 503
127
- webrick_res.body = '{"error":"Service shutting down"}'
128
- webrick_res["content-type"] = "application/json"
129
- return
130
- end
131
-
132
- Tina4::Shutdown.track_request do
133
- env = build_rack_env(webrick_req)
134
- status, headers, body = @app.call(env)
135
-
136
- webrick_res.status = status
137
- headers.each do |key, value|
138
- if key.downcase == "set-cookie"
139
- Array(value.split("\n")).each { |c| webrick_res.cookies << WEBrick::Cookie.parse_set_cookie(c) }
140
- else
141
- webrick_res[key] = value
142
- end
143
- end
144
-
145
- response_body = ""
146
- body.each { |chunk| response_body += chunk }
147
- webrick_res.body = response_body
148
- end
149
- end
150
-
151
- define_method(:build_rack_env) do |req|
152
- input = StringIO.new(req.body || "")
153
- env = {
154
- "REQUEST_METHOD" => req.request_method,
155
- "PATH_INFO" => req.path,
156
- "QUERY_STRING" => req.query_string || "",
157
- "SERVER_NAME" => webrick_req_host,
158
- "SERVER_PORT" => webrick_req_port,
159
- "CONTENT_TYPE" => req.content_type || "",
160
- "CONTENT_LENGTH" => (req.content_length rescue 0).to_s,
161
- "REMOTE_ADDR" => req.peeraddr&.last || "127.0.0.1",
162
- "rack.input" => input,
163
- "rack.errors" => $stderr,
164
- "rack.url_scheme" => "http",
165
- "rack.version" => [1, 3],
166
- "rack.multithread" => true,
167
- "rack.multiprocess" => false,
168
- "rack.run_once" => false
169
- }
170
-
171
- req.header.each do |key, values|
172
- env_key = "HTTP_#{key.upcase.gsub('-', '_')}"
173
- env[env_key] = values.join(", ")
174
- end
175
-
176
- env
177
- end
178
- end
179
-
180
- # Store host/port for the servlet's build_rack_env
181
- host = @host
182
- port = @port.to_s
183
- servlet.define_method(:webrick_req_host) { host }
184
- servlet.define_method(:webrick_req_port) { port }
185
-
186
- @server.mount("/", servlet, rack_app)
187
-
188
- # Test port (port + 1000) — stable, no-browser
189
- @ai_server = nil
190
- @ai_thread = nil
191
- no_ai_port = %w[true 1 yes].include?(ENV.fetch("TINA4_NO_AI_PORT", "").downcase)
192
- is_debug = %w[true 1 yes].include?(ENV.fetch("TINA4_DEBUG", "").downcase)
193
-
194
- if is_debug && !no_ai_port
195
- ai_port = @port + 1000
196
- begin
197
- test = TCPServer.new("0.0.0.0", ai_port)
198
- test.close
199
-
200
- @ai_server = WEBrick::HTTPServer.new(
201
- BindAddress: @host,
202
- Port: ai_port,
203
- Logger: WEBrick::Log.new(File::NULL),
204
- AccessLog: []
205
- )
206
-
207
- # Wrap the rack app so AI-port requests are tagged
208
- ai_rack_app = Tina4::AiPortRackApp.new(@app)
209
-
210
- # Build a servlet identical to the main one but bound to the AI port host/port
211
- ai_host = @host
212
- ai_port_str = ai_port.to_s
213
- ai_servlet = Class.new(WEBrick::HTTPServlet::AbstractServlet) do
214
- define_method(:initialize) do |server, app|
215
- super(server)
216
- @app = app
217
- end
218
-
219
- %w[GET POST PUT DELETE PATCH HEAD OPTIONS].each do |http_method|
220
- define_method("do_#{http_method}") do |webrick_req, webrick_res|
221
- handle_request(webrick_req, webrick_res)
222
- end
223
- end
224
-
225
- define_method(:handle_request) do |webrick_req, webrick_res|
226
- if Tina4::Shutdown.shutting_down?
227
- webrick_res.status = 503
228
- webrick_res.body = '{"error":"Service shutting down"}'
229
- webrick_res["content-type"] = "application/json"
230
- return
231
- end
232
-
233
- Tina4::Shutdown.track_request do
234
- env = build_rack_env(webrick_req)
235
- status, headers, body = @app.call(env)
236
-
237
- webrick_res.status = status
238
- headers.each do |key, value|
239
- if key.downcase == "set-cookie"
240
- Array(value.split("\n")).each { |c| webrick_res.cookies << WEBrick::Cookie.parse_set_cookie(c) }
241
- else
242
- webrick_res[key] = value
243
- end
244
- end
245
-
246
- response_body = ""
247
- body.each { |chunk| response_body += chunk }
248
- webrick_res.body = response_body
249
- end
250
- end
251
-
252
- define_method(:build_rack_env) do |req|
253
- input = StringIO.new(req.body || "")
254
- env = {
255
- "REQUEST_METHOD" => req.request_method,
256
- "PATH_INFO" => req.path,
257
- "QUERY_STRING" => req.query_string || "",
258
- "SERVER_NAME" => webrick_req_host,
259
- "SERVER_PORT" => webrick_req_port,
260
- "CONTENT_TYPE" => req.content_type || "",
261
- "CONTENT_LENGTH" => (req.content_length rescue 0).to_s,
262
- "REMOTE_ADDR" => req.peeraddr&.last || "127.0.0.1",
263
- "rack.input" => input,
264
- "rack.errors" => $stderr,
265
- "rack.url_scheme" => "http",
266
- "rack.version" => [1, 3],
267
- "rack.multithread" => true,
268
- "rack.multiprocess" => false,
269
- "rack.run_once" => false
270
- }
271
-
272
- req.header.each do |key, values|
273
- env_key = "HTTP_#{key.upcase.gsub('-', '_')}"
274
- env[env_key] = values.join(", ")
275
- end
276
-
277
- env
278
- end
279
- end
280
-
281
- ai_servlet.define_method(:webrick_req_host) { ai_host }
282
- ai_servlet.define_method(:webrick_req_port) { ai_port_str }
283
-
284
- @ai_server.mount("/", ai_servlet, ai_rack_app)
285
- @ai_thread = Thread.new { @ai_server.start }
286
- puts " Test Port: http://localhost:#{ai_port} (stable — no hot-reload)"
287
- rescue Errno::EADDRINUSE
288
- puts " Test Port: SKIPPED (port #{ai_port} in use)"
289
- end
290
- end
291
-
292
- @server.start
293
- end
294
-
295
- def stop
296
- @ai_server&.shutdown
297
- @ai_thread&.join(5)
298
- @server&.shutdown
299
- end
300
-
301
- # Dispatch a Rack-style env through the Tina4 app and return [status, headers, body].
302
- #
303
- # Useful for testing and embedding — does not require a running server.
304
- # Cross-framework parity with Python and Node.js.
305
- #
306
- # @param env [Hash] A Rack environment hash
307
- # @return [Array] Rack-style response triple [status, headers, body]
308
- def handle(env)
309
- @app.call(env)
310
- end
311
- end
312
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ class WebServer
5
+ def initialize(app, host: "0.0.0.0", port: 7147)
6
+ @app = app
7
+ @host = host
8
+ @port = port
9
+ end
10
+
11
+ # Kill whatever process is listening on *port*.
12
+ # Uses lsof on macOS/Linux and netstat + taskkill on Windows.
13
+ # Raises RuntimeError if the port cannot be freed.
14
+ def free_port(port)
15
+ puts " Port #{port} in use — killing existing process..."
16
+
17
+ if RUBY_PLATFORM =~ /mswin|mingw|cygwin/
18
+ output = `netstat -ano 2>&1`
19
+ pid = nil
20
+ output.each_line do |line|
21
+ if line.include?(":#{port}") && (line.include?("LISTENING") || line.include?("ESTABLISHED"))
22
+ parts = line.strip.split(/\s+/)
23
+ candidate = parts.last
24
+ if candidate =~ /^\d+$/
25
+ pid = candidate
26
+ break
27
+ end
28
+ end
29
+ end
30
+ if pid
31
+ system("taskkill /PID #{pid} /F")
32
+ else
33
+ raise "Could not free port #{port}: no PID found"
34
+ end
35
+ else
36
+ pids = `lsof -ti :#{port} 2>/dev/null`.strip.split("\n")
37
+ if pids.empty?
38
+ return # Nothing found — port may have freed itself
39
+ end
40
+ pids.each do |pid|
41
+ pid = pid.strip
42
+ next unless pid =~ /^\d+$/
43
+ begin
44
+ Process.kill("TERM", pid.to_i)
45
+ rescue Errno::ESRCH
46
+ # Process already gone
47
+ end
48
+ end
49
+ end
50
+
51
+ # Give the OS a moment to reclaim the port
52
+ sleep(0.5)
53
+ puts " Port #{port} freed"
54
+ end
55
+
56
+ def start
57
+ is_managed = ARGV.include?('--managed')
58
+ unless is_managed || ENV['TINA4_OVERRIDE_CLIENT'] == 'true'
59
+ puts
60
+ puts '=' * 60
61
+ puts
62
+ puts ' Tina4 must be started with the tina4 CLI:'
63
+ puts
64
+ puts ' tina4 serve (development)'
65
+ puts ' tina4 serve --production (production)'
66
+ puts
67
+ puts ' Install: cargo install tina4'
68
+ puts ' Docs: https://tina4.com'
69
+ puts
70
+ puts ' To run directly, add to .env:'
71
+ puts ' TINA4_OVERRIDE_CLIENT=true'
72
+ puts
73
+ puts '=' * 60
74
+ puts
75
+ exit 1
76
+ end
77
+
78
+ require "webrick"
79
+ require "stringio"
80
+ require "socket"
81
+
82
+ # Ensure the main port is available — kill whatever is on it if needed
83
+ begin
84
+ test = TCPServer.new("0.0.0.0", @port)
85
+ test.close
86
+ rescue Errno::EADDRINUSE
87
+ free_port(@port)
88
+ # Verify the port is now free; raise if still occupied
89
+ begin
90
+ test = TCPServer.new("0.0.0.0", @port)
91
+ test.close
92
+ rescue Errno::EADDRINUSE
93
+ raise "Could not free port #{@port}"
94
+ end
95
+ end
96
+
97
+ Tina4.print_banner(host: @host, port: @port)
98
+ Tina4::Log.info("Starting Tina4 WEBrick server on http://#{@host}:#{@port}")
99
+ @server = WEBrick::HTTPServer.new(
100
+ BindAddress: @host,
101
+ Port: @port,
102
+ Logger: WEBrick::Log.new(File::NULL),
103
+ AccessLog: []
104
+ )
105
+
106
+ # Setup graceful shutdown with WEBrick server reference
107
+ Tina4::Shutdown.setup(server: @server)
108
+
109
+ # Use a custom servlet that passes ALL methods (including OPTIONS) to Rack
110
+ rack_app = @app
111
+ servlet = Class.new(WEBrick::HTTPServlet::AbstractServlet) do
112
+ define_method(:initialize) do |server, app|
113
+ super(server)
114
+ @app = app
115
+ end
116
+
117
+ %w[GET POST PUT DELETE PATCH HEAD OPTIONS].each do |http_method|
118
+ define_method("do_#{http_method}") do |webrick_req, webrick_res|
119
+ handle_request(webrick_req, webrick_res)
120
+ end
121
+ end
122
+
123
+ define_method(:handle_request) do |webrick_req, webrick_res|
124
+ # Reject new requests during shutdown
125
+ if Tina4::Shutdown.shutting_down?
126
+ webrick_res.status = 503
127
+ webrick_res.body = '{"error":"Service shutting down"}'
128
+ webrick_res["content-type"] = "application/json"
129
+ return
130
+ end
131
+
132
+ Tina4::Shutdown.track_request do
133
+ env = build_rack_env(webrick_req)
134
+ status, headers, body = @app.call(env)
135
+
136
+ webrick_res.status = status
137
+ headers.each do |key, value|
138
+ if key.downcase == "set-cookie"
139
+ Array(value.split("\n")).each { |c| webrick_res.cookies << WEBrick::Cookie.parse_set_cookie(c) }
140
+ else
141
+ webrick_res[key] = value
142
+ end
143
+ end
144
+
145
+ response_body = ""
146
+ body.each { |chunk| response_body += chunk }
147
+ webrick_res.body = response_body
148
+ end
149
+ end
150
+
151
+ define_method(:build_rack_env) do |req|
152
+ input = StringIO.new(req.body || "")
153
+ env = {
154
+ "REQUEST_METHOD" => req.request_method,
155
+ "PATH_INFO" => req.path,
156
+ "QUERY_STRING" => req.query_string || "",
157
+ "SERVER_NAME" => webrick_req_host,
158
+ "SERVER_PORT" => webrick_req_port,
159
+ "CONTENT_TYPE" => req.content_type || "",
160
+ "CONTENT_LENGTH" => (req.content_length rescue 0).to_s,
161
+ "REMOTE_ADDR" => req.peeraddr&.last || "127.0.0.1",
162
+ "rack.input" => input,
163
+ "rack.errors" => $stderr,
164
+ "rack.url_scheme" => "http",
165
+ "rack.version" => [1, 3],
166
+ "rack.multithread" => true,
167
+ "rack.multiprocess" => false,
168
+ "rack.run_once" => false
169
+ }
170
+
171
+ req.header.each do |key, values|
172
+ env_key = "HTTP_#{key.upcase.gsub('-', '_')}"
173
+ env[env_key] = values.join(", ")
174
+ end
175
+
176
+ env
177
+ end
178
+ end
179
+
180
+ # Store host/port for the servlet's build_rack_env
181
+ host = @host
182
+ port = @port.to_s
183
+ servlet.define_method(:webrick_req_host) { host }
184
+ servlet.define_method(:webrick_req_port) { port }
185
+
186
+ @server.mount("/", servlet, rack_app)
187
+
188
+ # Test port (port + 1000) — stable, no-browser
189
+ @ai_server = nil
190
+ @ai_thread = nil
191
+ no_ai_port = %w[true 1 yes].include?(ENV.fetch("TINA4_NO_AI_PORT", "").downcase)
192
+ is_debug = %w[true 1 yes].include?(ENV.fetch("TINA4_DEBUG", "").downcase)
193
+
194
+ if is_debug && !no_ai_port
195
+ ai_port = @port + 1000
196
+ begin
197
+ test = TCPServer.new("0.0.0.0", ai_port)
198
+ test.close
199
+
200
+ @ai_server = WEBrick::HTTPServer.new(
201
+ BindAddress: @host,
202
+ Port: ai_port,
203
+ Logger: WEBrick::Log.new(File::NULL),
204
+ AccessLog: []
205
+ )
206
+
207
+ # Wrap the rack app so AI-port requests are tagged
208
+ ai_rack_app = Tina4::AiPortRackApp.new(@app)
209
+
210
+ # Build a servlet identical to the main one but bound to the AI port host/port
211
+ ai_host = @host
212
+ ai_port_str = ai_port.to_s
213
+ ai_servlet = Class.new(WEBrick::HTTPServlet::AbstractServlet) do
214
+ define_method(:initialize) do |server, app|
215
+ super(server)
216
+ @app = app
217
+ end
218
+
219
+ %w[GET POST PUT DELETE PATCH HEAD OPTIONS].each do |http_method|
220
+ define_method("do_#{http_method}") do |webrick_req, webrick_res|
221
+ handle_request(webrick_req, webrick_res)
222
+ end
223
+ end
224
+
225
+ define_method(:handle_request) do |webrick_req, webrick_res|
226
+ if Tina4::Shutdown.shutting_down?
227
+ webrick_res.status = 503
228
+ webrick_res.body = '{"error":"Service shutting down"}'
229
+ webrick_res["content-type"] = "application/json"
230
+ return
231
+ end
232
+
233
+ Tina4::Shutdown.track_request do
234
+ env = build_rack_env(webrick_req)
235
+ status, headers, body = @app.call(env)
236
+
237
+ webrick_res.status = status
238
+ headers.each do |key, value|
239
+ if key.downcase == "set-cookie"
240
+ Array(value.split("\n")).each { |c| webrick_res.cookies << WEBrick::Cookie.parse_set_cookie(c) }
241
+ else
242
+ webrick_res[key] = value
243
+ end
244
+ end
245
+
246
+ response_body = ""
247
+ body.each { |chunk| response_body += chunk }
248
+ webrick_res.body = response_body
249
+ end
250
+ end
251
+
252
+ define_method(:build_rack_env) do |req|
253
+ input = StringIO.new(req.body || "")
254
+ env = {
255
+ "REQUEST_METHOD" => req.request_method,
256
+ "PATH_INFO" => req.path,
257
+ "QUERY_STRING" => req.query_string || "",
258
+ "SERVER_NAME" => webrick_req_host,
259
+ "SERVER_PORT" => webrick_req_port,
260
+ "CONTENT_TYPE" => req.content_type || "",
261
+ "CONTENT_LENGTH" => (req.content_length rescue 0).to_s,
262
+ "REMOTE_ADDR" => req.peeraddr&.last || "127.0.0.1",
263
+ "rack.input" => input,
264
+ "rack.errors" => $stderr,
265
+ "rack.url_scheme" => "http",
266
+ "rack.version" => [1, 3],
267
+ "rack.multithread" => true,
268
+ "rack.multiprocess" => false,
269
+ "rack.run_once" => false
270
+ }
271
+
272
+ req.header.each do |key, values|
273
+ env_key = "HTTP_#{key.upcase.gsub('-', '_')}"
274
+ env[env_key] = values.join(", ")
275
+ end
276
+
277
+ env
278
+ end
279
+ end
280
+
281
+ ai_servlet.define_method(:webrick_req_host) { ai_host }
282
+ ai_servlet.define_method(:webrick_req_port) { ai_port_str }
283
+
284
+ @ai_server.mount("/", ai_servlet, ai_rack_app)
285
+ @ai_thread = Thread.new { @ai_server.start }
286
+ puts " Test Port: http://localhost:#{ai_port} (stable — no hot-reload)"
287
+ rescue Errno::EADDRINUSE
288
+ puts " Test Port: SKIPPED (port #{ai_port} in use)"
289
+ end
290
+ end
291
+
292
+ @server.start
293
+ end
294
+
295
+ def stop
296
+ @ai_server&.shutdown
297
+ @ai_thread&.join(5)
298
+ @server&.shutdown
299
+ end
300
+
301
+ # Dispatch a Rack-style env through the Tina4 app and return [status, headers, body].
302
+ #
303
+ # Useful for testing and embedding — does not require a running server.
304
+ # Cross-framework parity with Python and Node.js.
305
+ #
306
+ # @param env [Hash] A Rack environment hash
307
+ # @return [Array] Rack-style response triple [status, headers, body]
308
+ def handle(env)
309
+ @app.call(env)
310
+ end
311
+ end
312
+ end