forest_admin_rpc_agent 1.16.10 → 1.18.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6408ab0ded814ec3a63a5053aa6ca40eb69e9cfa9ce86791e1aabda1f510326d
4
- data.tar.gz: 96290792e468c9bbf1d47492297981b354ff3dc0a7dc7a177092b8b4cd8c9ce4
3
+ metadata.gz: 8ae3dd638af2e91737911aa2436d2b25c11d556863ed638b08ebfbb95ee61cdd
4
+ data.tar.gz: 1e6e61aa06ee5015b272364e9ce6f49c6e6ffb218a16ac6f981a7d1219d31767
5
5
  SHA512:
6
- metadata.gz: a25d113f6e9c719f9ba52873ac718c437856be8fdf1d35358667033ee7e80103a82353886c0c5efbf458abba2ece3ce913e2e930c350c84e0e7f74055751f8ed
7
- data.tar.gz: 25ab7a181d0f2a3b3b585d5c7ae0f078d7c02c25f1cbe64c4d6e9e0914b169c7e633f7576d299a958f67b6dcfac47e7d2f4826ea531be2b66fd757e8fb77ee9d
6
+ metadata.gz: 8589283a71fb4124c12c1efa1d30bdac436cd2bb595a2c309bea55b327e639bf25cddbb3775e353d6b528f7e167e56729a295ec49d8d5aa7967da86759e1dc2e
7
+ data.tar.gz: c3419b02182fec4b8ac8078df6efc4d56abbf5dea07120fe6770e50278ad1c00f41e486464c34d7115757988773562eff0b7d19502fb89650d77d8cc59976794
@@ -1,19 +1,260 @@
1
+ require 'digest'
2
+ require 'fileutils'
3
+
1
4
  module ForestAdminRpcAgent
2
- class Agent < ForestAdminAgent::Builder::AgentFactory
3
- attr_reader :rpc_collections
5
+ class Agent < ForestAdminAgent::Builder::AgentFactory # rubocop:disable Metrics/ClassLength
6
+ include ForestAdminAgent::Http::Exceptions
7
+
8
+ attr_reader :rpc_collections, :cached_schema, :cached_schema_hash
4
9
 
5
10
  def setup(options)
6
11
  super
7
12
  @rpc_collections = []
13
+ @cached_schema = nil
14
+ @cached_schema_hash = nil
8
15
  end
9
16
 
10
- def send_schema(_force: nil)
11
- ForestAdminRpcAgent::Facades::Container.logger.log('Info', 'Started as RPC agent, schema not sent.')
17
+ def send_schema(force: false)
18
+ if should_skip_schema_update? && !force
19
+ log_schema_skip
20
+ load_and_cache_schema
21
+ return
22
+ end
23
+
24
+ schema_path = ForestAdminRpcAgent::Facades::Container.cache(:schema_path)
25
+
26
+ if ForestAdminRpcAgent::Facades::Container.cache(:is_production)
27
+ unless schema_path && File.exist?(schema_path)
28
+ raise InternalServerError.new(
29
+ 'Schema file not found in production',
30
+ details: { schema_path: schema_path }
31
+ )
32
+ end
33
+
34
+ load_and_cache_schema_from_file(schema_path)
35
+
36
+ ForestAdminRpcAgent::Facades::Container.logger.log(
37
+ 'Info',
38
+ 'RPC agent running in production mode, using existing schema file.'
39
+ )
40
+ else
41
+ generate_and_cache_schema(schema_path)
42
+
43
+ ForestAdminRpcAgent::Facades::Container.logger.log(
44
+ 'Info',
45
+ "RPC agent schema generated and saved to #{schema_path}"
46
+ )
47
+ end
48
+
49
+ ForestAdminRpcAgent::Facades::Container.logger.log(
50
+ 'Info',
51
+ 'RPC agent does not send schema to Forest Admin servers.'
52
+ )
12
53
  end
13
54
 
14
55
  def mark_collections_as_rpc(*names)
15
56
  @rpc_collections.push(*names)
16
57
  self
17
58
  end
59
+
60
+ # Returns the cached schema for the /rpc-schema route
61
+ # Falls back to building schema from datasource if not cached
62
+ def rpc_schema
63
+ return @cached_schema if @cached_schema
64
+
65
+ build_and_cache_schema_from_datasource
66
+ @cached_schema
67
+ end
68
+
69
+ # Check if provided hash matches the cached schema hash
70
+ def schema_hash_matches?(provided_hash)
71
+ return false unless @cached_schema_hash && provided_hash
72
+
73
+ @cached_schema_hash == provided_hash
74
+ end
75
+
76
+ private
77
+
78
+ def should_skip_schema_update?
79
+ ForestAdminRpcAgent::Facades::Container.cache(:skip_schema_update) == true
80
+ end
81
+
82
+ def log_schema_skip
83
+ logger = ForestAdminRpcAgent::Facades::Container.logger
84
+ logger.log('Warn', '[ForestAdmin] Schema update skipped (skip_schema_update flag is true)')
85
+ environment = ForestAdminRpcAgent::Facades::Container.cache(:is_production) ? 'production' : 'development'
86
+ logger.log('Info', "[ForestAdmin] RPC agent running in #{environment} mode")
87
+ end
88
+
89
+ def load_and_cache_schema
90
+ schema_path = ForestAdminRpcAgent::Facades::Container.cache(:schema_path)
91
+
92
+ if ForestAdminRpcAgent::Facades::Container.cache(:is_production) && schema_path && File.exist?(schema_path)
93
+ load_and_cache_schema_from_file(schema_path)
94
+ else
95
+ # In development with skip_schema_update, still build from datasource
96
+ build_and_cache_schema_from_datasource
97
+ end
98
+ end
99
+
100
+ def load_and_cache_schema_from_file(_schema_path)
101
+ # File exists but RPC schema needs internal format - build from datasource
102
+ # The file is kept for reference/frontend but RPC always uses internal format
103
+ datasource = @container.resolve(:datasource)
104
+ @cached_schema = build_rpc_schema_from_datasource(datasource)
105
+ compute_and_cache_hash
106
+ end
107
+
108
+ def generate_and_cache_schema(schema_path)
109
+ datasource = @container.resolve(:datasource)
110
+
111
+ # Generate frontend schema for file (used by Forest Admin)
112
+ generated = ForestAdminAgent::Utils::Schema::SchemaEmitter.generate(datasource)
113
+ meta = ForestAdminAgent::Utils::Schema::SchemaEmitter.meta
114
+
115
+ schema = {
116
+ meta: meta,
117
+ collections: generated
118
+ }
119
+
120
+ FileUtils.mkdir_p(File.dirname(schema_path))
121
+ File.write(schema_path, format_schema_json(schema))
122
+
123
+ # Build RPC schema in internal format (used by master agent)
124
+ @cached_schema = build_rpc_schema_from_datasource(datasource)
125
+ compute_and_cache_hash
126
+ end
127
+
128
+ def build_and_cache_schema_from_datasource
129
+ datasource = @container.resolve(:datasource)
130
+
131
+ @cached_schema = build_rpc_schema_from_datasource(datasource)
132
+ compute_and_cache_hash
133
+ end
134
+
135
+ def build_rpc_schema_from_datasource(datasource)
136
+ schema = customizer.schema
137
+
138
+ # Serialize collections with internal schema format (fields as hash with :type keys)
139
+ collections = datasource.collections.map { |_name, collection| serialize_collection_for_rpc(collection) }
140
+ schema[:collections] = collections.sort_by { |c| c[:name] }
141
+
142
+ connections = datasource.live_query_connections.keys.map { |connection_name| { name: connection_name } }
143
+ schema[:native_query_connections] = connections
144
+
145
+ schema
146
+ end
147
+
148
+ def serialize_collection_for_rpc(collection)
149
+ {
150
+ name: collection.name,
151
+ countable: collection.schema[:countable],
152
+ searchable: collection.schema[:searchable],
153
+ segments: collection.schema[:segments] || [],
154
+ charts: collection.schema[:charts] || [],
155
+ actions: serialize_actions_for_rpc(collection.schema[:actions] || {}),
156
+ fields: serialize_fields_for_rpc(collection.schema[:fields] || {})
157
+ }
158
+ end
159
+
160
+ def serialize_fields_for_rpc(fields)
161
+ fields.transform_values do |field_schema|
162
+ serialize_field_schema(field_schema)
163
+ end
164
+ end
165
+
166
+ def serialize_field_schema(field_schema)
167
+ case field_schema
168
+ when ForestAdminDatasourceToolkit::Schema::ColumnSchema
169
+ {
170
+ type: 'Column',
171
+ column_type: field_schema.column_type,
172
+ filter_operators: field_schema.filter_operators,
173
+ is_primary_key: field_schema.is_primary_key,
174
+ is_read_only: field_schema.is_read_only,
175
+ is_sortable: field_schema.is_sortable,
176
+ default_value: field_schema.default_value,
177
+ enum_values: field_schema.enum_values,
178
+ validation: field_schema.validation
179
+ }
180
+ when ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema
181
+ {
182
+ type: 'ManyToOne',
183
+ foreign_collection: field_schema.foreign_collection,
184
+ foreign_key: field_schema.foreign_key,
185
+ foreign_key_target: field_schema.foreign_key_target
186
+ }
187
+ when ForestAdminDatasourceToolkit::Schema::Relations::OneToOneSchema
188
+ {
189
+ type: 'OneToOne',
190
+ foreign_collection: field_schema.foreign_collection,
191
+ origin_key: field_schema.origin_key,
192
+ origin_key_target: field_schema.origin_key_target
193
+ }
194
+ when ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema
195
+ {
196
+ type: 'OneToMany',
197
+ foreign_collection: field_schema.foreign_collection,
198
+ origin_key: field_schema.origin_key,
199
+ origin_key_target: field_schema.origin_key_target
200
+ }
201
+ when ForestAdminDatasourceToolkit::Schema::Relations::ManyToManySchema
202
+ {
203
+ type: 'ManyToMany',
204
+ foreign_collection: field_schema.foreign_collection,
205
+ foreign_key: field_schema.foreign_key,
206
+ foreign_key_target: field_schema.foreign_key_target,
207
+ origin_key: field_schema.origin_key,
208
+ origin_key_target: field_schema.origin_key_target,
209
+ through_collection: field_schema.through_collection
210
+ }
211
+ when ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicManyToOneSchema
212
+ {
213
+ type: 'PolymorphicManyToOne',
214
+ foreign_collections: field_schema.foreign_collections,
215
+ foreign_key: field_schema.foreign_key,
216
+ foreign_key_type_field: field_schema.foreign_key_type_field,
217
+ foreign_key_targets: field_schema.foreign_key_targets
218
+ }
219
+ when ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicOneToOneSchema
220
+ {
221
+ type: 'PolymorphicOneToOne',
222
+ foreign_collection: field_schema.foreign_collection,
223
+ origin_key: field_schema.origin_key,
224
+ origin_key_target: field_schema.origin_key_target,
225
+ origin_type_field: field_schema.origin_type_field,
226
+ origin_type_value: field_schema.origin_type_value
227
+ }
228
+ when ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicOneToManySchema
229
+ {
230
+ type: 'PolymorphicOneToMany',
231
+ foreign_collection: field_schema.foreign_collection,
232
+ origin_key: field_schema.origin_key,
233
+ origin_key_target: field_schema.origin_key_target,
234
+ origin_type_field: field_schema.origin_type_field,
235
+ origin_type_value: field_schema.origin_type_value
236
+ }
237
+ else
238
+ # Fallback: try to convert to hash if possible
239
+ field_schema.respond_to?(:to_h) ? field_schema.to_h : field_schema
240
+ end
241
+ end
242
+
243
+ def serialize_actions_for_rpc(actions)
244
+ actions.transform_values do |action|
245
+ action.respond_to?(:to_h) ? action.to_h : action
246
+ end
247
+ end
248
+
249
+ def compute_and_cache_hash
250
+ return unless @cached_schema
251
+
252
+ @cached_schema_hash = Digest::SHA1.hexdigest(@cached_schema.to_json)
253
+
254
+ ForestAdminRpcAgent::Facades::Container.logger.log(
255
+ 'Debug',
256
+ "RPC agent schema hash computed: #{@cached_schema_hash}"
257
+ )
258
+ end
18
259
  end
19
260
  end
@@ -20,10 +20,12 @@ module ForestAdminRpcAgent
20
20
 
21
21
  def register_sinatra(app)
22
22
  app.send(@method.to_sym, @url) do
23
- result = handle_request(params)
23
+ result = handle_request({ params: params, request: request })
24
24
 
25
25
  if result.is_a?(Hash) && result.key?(:status)
26
26
  status result[:status]
27
+ # Set custom headers if provided
28
+ result[:headers]&.each { |key, value| headers[key] = value }
27
29
  result[:content] ? serialize_response(result[:content]) : ''
28
30
  else
29
31
  serialize_response(result)
@@ -38,16 +40,16 @@ module ForestAdminRpcAgent
38
40
  # Skip authentication for health check (root path)
39
41
  if @url == '/'
40
42
  params = request.query_parameters.merge(request.request_parameters)
41
- result = handle_request({ params: params, caller: nil })
42
- [200, { 'Content-Type' => 'application/json' }, [serialize_response(result)]]
43
+ result = handle_request({ params: params, caller: nil, request: request })
44
+ build_rails_response(result)
43
45
  else
44
46
  auth_middleware = ForestAdminRpcAgent::Middleware::Authentication.new(->(_env) { [200, {}, ['OK']] })
45
47
  status, headers, response = auth_middleware.call(request.env)
46
48
 
47
49
  if status == 200
48
50
  params = request.query_parameters.merge(request.request_parameters)
49
- result = handle_request({ params: params, caller: headers[:caller] })
50
- [200, { 'Content-Type' => 'application/json' }, [serialize_response(result)]]
51
+ result = handle_request({ params: params, caller: headers[:caller], request: request })
52
+ build_rails_response(result)
51
53
  else
52
54
  [status, headers, response]
53
55
  end
@@ -62,6 +64,17 @@ module ForestAdminRpcAgent
62
64
  route_alias: @name
63
65
  end
64
66
 
67
+ def build_rails_response(result)
68
+ if result.is_a?(Hash) && result.key?(:status)
69
+ response_headers = { 'Content-Type' => 'application/json' }
70
+ response_headers.merge!(result[:headers]) if result[:headers]
71
+ body = result[:content] ? serialize_response(result[:content]) : ''
72
+ [result[:status], response_headers, [body]]
73
+ else
74
+ [200, { 'Content-Type' => 'application/json' }, [serialize_response(result)]]
75
+ end
76
+ end
77
+
65
78
  protected
66
79
 
67
80
  def get_collection_safe(datasource, collection_name)
@@ -5,23 +5,66 @@ module ForestAdminRpcAgent
5
5
  include ForestAdminAgent::Utils
6
6
  include ForestAdminAgent::Routes::QueryHandler
7
7
 
8
+ HTTP_OK = 200
9
+ HTTP_NOT_MODIFIED = 304
10
+
8
11
  def initialize
9
12
  super('rpc-schema', 'get', 'rpc_schema')
10
13
  end
11
14
 
12
- def handle_request(_params)
15
+ def handle_request(args)
13
16
  agent = ForestAdminRpcAgent::Agent.instance
14
- schema = agent.customizer.schema
15
- datasource = agent.customizer.datasource(ForestAdminRpcAgent::Facades::Container.logger)
17
+ client_etag = extract_if_none_match(args)
18
+
19
+ # If client has cached schema and ETag matches, return 304 Not Modified
20
+ if client_etag && agent.schema_hash_matches?(client_etag)
21
+ ForestAdminRpcAgent::Facades::Container.logger.log(
22
+ 'Debug',
23
+ 'ETag matches, returning 304 Not Modified'
24
+ )
25
+ return { status: HTTP_NOT_MODIFIED, content: nil,
26
+ headers: { 'ETag' => quote_etag(agent.cached_schema_hash) } }
27
+ end
28
+
29
+ # Get schema from cache (or build from datasource if not cached)
30
+ schema = agent.rpc_schema
31
+ etag = agent.cached_schema_hash
32
+
33
+ # Return schema with ETag header
34
+ {
35
+ status: HTTP_OK,
36
+ content: schema,
37
+ headers: { 'ETag' => quote_etag(etag) }
38
+ }
39
+ end
40
+
41
+ private
16
42
 
17
- schema[:collections] = datasource.collections
18
- .map { |_name, collection| collection.schema.merge({ name: collection.name }) }
19
- .sort_by { |collection| collection[:name] }
43
+ def extract_if_none_match(args)
44
+ request = args[:request] if args.is_a?(Hash)
45
+ return nil unless request
46
+
47
+ # Get If-None-Match header (works for both Rails and Sinatra)
48
+ etag = if request.respond_to?(:get_header)
49
+ request.get_header('HTTP_IF_NONE_MATCH')
50
+ elsif request.respond_to?(:env)
51
+ request.env['HTTP_IF_NONE_MATCH']
52
+ end
53
+
54
+ # Strip quotes from ETag value if present
55
+ unquote_etag(etag)
56
+ end
57
+
58
+ def quote_etag(etag)
59
+ return nil unless etag
60
+
61
+ %("#{etag}")
62
+ end
20
63
 
21
- connections = datasource.live_query_connections.keys.map { |connection_name| { name: connection_name } }
22
- schema[:native_query_connections] = connections
64
+ def unquote_etag(etag)
65
+ return nil unless etag
23
66
 
24
- schema
67
+ etag.gsub(/\A"?|"?\z/, '')
25
68
  end
26
69
  end
27
70
  end
@@ -1,3 +1,3 @@
1
1
  module ForestAdminRpcAgent
2
- VERSION = "1.16.10"
2
+ VERSION = "1.18.0"
3
3
  end
@@ -20,6 +20,8 @@ module ForestAdminRpcAgent
20
20
  setting :prefix, default: nil
21
21
  setting :cache_dir, default: :'tmp/cache/forest_admin'
22
22
  setting :project_dir, default: Dir.pwd
23
+ setting :schema_path, default: File.join(Dir.pwd, '.forestadmin-schema.json')
24
+ setting :skip_schema_update, default: false
23
25
  setting :logger_level, default: 'info'
24
26
  setting :logger, default: nil
25
27
  setting :customize_error_message, default: nil
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forest_admin_rpc_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.16.10
4
+ version: 1.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthieu
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2025-12-09 00:00:00.000000000 Z
12
+ date: 2025-12-11 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: base64
@@ -176,10 +176,7 @@ files:
176
176
  - lib/forest_admin_rpc_agent/routes/list.rb
177
177
  - lib/forest_admin_rpc_agent/routes/native_query.rb
178
178
  - lib/forest_admin_rpc_agent/routes/schema.rb
179
- - lib/forest_admin_rpc_agent/routes/sse.rb
180
179
  - lib/forest_admin_rpc_agent/routes/update.rb
181
- - lib/forest_admin_rpc_agent/sse_connection_manager.rb
182
- - lib/forest_admin_rpc_agent/sse_streamer.rb
183
180
  - lib/forest_admin_rpc_agent/thor/install.rb
184
181
  - lib/forest_admin_rpc_agent/version.rb
185
182
  homepage: https://www.forestadmin.com
@@ -1,166 +0,0 @@
1
- require 'jsonapi-serializers'
2
-
3
- module ForestAdminRpcAgent
4
- module Routes
5
- class Sse
6
- DEFAULT_HEARTBEAT_INTERVAL = 10
7
-
8
- def initialize(url = 'sse', method = 'get', name = 'rpc_sse', heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL)
9
- @url = url
10
- @method = method
11
- @name = name
12
- @heartbeat_interval = heartbeat_interval
13
- end
14
-
15
- def registered(app)
16
- if defined?(Sinatra) && (app == Sinatra::Base || app.ancestors.include?(Sinatra::Base))
17
- register_sinatra(app)
18
- elsif defined?(Rails) && app.is_a?(ActionDispatch::Routing::Mapper)
19
- register_rails(app)
20
- else
21
- raise NotImplementedError,
22
- "Unsupported application type: #{app.class}. #{self} works with Sinatra::Base or ActionDispatch::Routing::Mapper."
23
- end
24
- end
25
-
26
- def register_sinatra(app)
27
- route_instance = self
28
- app.send(@method.to_sym, "/#{@url}") do
29
- auth_middleware = ForestAdminRpcAgent::Middleware::Authentication.new(->(_env) { [200, {}, ['OK']] })
30
- status, headers, response = auth_middleware.call(env)
31
-
32
- halt status, headers, response if status != 200
33
-
34
- content_type 'text/event-stream'
35
- headers 'Cache-Control' => 'no-cache',
36
- 'Connection' => 'keep-alive',
37
- 'X-Accel-Buffering' => 'no'
38
-
39
- stream(:keep_open) do |out|
40
- # Register this connection; any previous connection will be terminated
41
- connection = ForestAdminRpcAgent::SseConnectionManager.register_connection
42
-
43
- server_stopped = false
44
- received_signal = nil
45
- stop_proc = proc do |sig|
46
- connection.terminate
47
- server_stopped = true
48
- received_signal = sig
49
- end
50
- original_int_handler = trap('INT', stop_proc)
51
- original_term_handler = trap('TERM', stop_proc)
52
-
53
- begin
54
- streamer = SseStreamer.new(out)
55
-
56
- while connection.active?
57
- streamer.write('', event: 'heartbeat')
58
- sleep route_instance.instance_variable_get(:@heartbeat_interval)
59
- end
60
-
61
- # Send RpcServerStop only if server is stopping (not client disconnect)
62
- if server_stopped
63
- begin
64
- streamer.write({ event: 'RpcServerStop' }, event: 'RpcServerStop')
65
- ForestAdminRpcAgent::Facades::Container.logger&.log('Debug', '[SSE] RpcServerStop event sent')
66
- rescue StandardError => e
67
- ForestAdminRpcAgent::Facades::Container.logger&.log('Debug', "[SSE] Error sending stop event: #{e.message}")
68
- end
69
- end
70
- rescue IOError, Errno::EPIPE => e
71
- # Client disconnected normally
72
- ForestAdminRpcAgent::Facades::Container.logger&.log('Debug', "[SSE] Client disconnected: #{e.message}")
73
- ensure
74
- trap('INT', original_int_handler)
75
- trap('TERM', original_term_handler)
76
- ForestAdminRpcAgent::SseConnectionManager.unregister_connection(connection)
77
- out.close if out.respond_to?(:close)
78
-
79
- # Re-send the signal to allow proper server shutdown
80
- Process.kill(received_signal, Process.pid) if received_signal
81
- end
82
- end
83
- end
84
- end
85
-
86
- def register_rails(router)
87
- route_instance = self
88
- handler = proc do |hash|
89
- request = ActionDispatch::Request.new(hash)
90
- auth_middleware = ForestAdminRpcAgent::Middleware::Authentication.new(->(_env) { [200, {}, ['OK']] })
91
- status, headers, response = auth_middleware.call(request.env)
92
-
93
- if status == 200
94
- headers = {
95
- 'Content-Type' => 'text/event-stream',
96
- 'Cache-Control' => 'no-cache',
97
- 'Connection' => 'keep-alive',
98
- 'X-Accel-Buffering' => 'no'
99
- }
100
-
101
- # Register this connection; any previous connection will be terminated
102
- connection = ForestAdminRpcAgent::SseConnectionManager.register_connection
103
-
104
- server_stopped = false
105
- received_signal = nil
106
- stop_proc = proc do |sig|
107
- connection.terminate
108
- server_stopped = true
109
- received_signal = sig
110
- end
111
- original_int_handler = trap('INT', stop_proc)
112
- original_term_handler = trap('TERM', stop_proc)
113
-
114
- body = Enumerator.new do |yielder|
115
- stream = SseStreamer.new(yielder)
116
-
117
- begin
118
- ForestAdminRpcAgent::Facades::Container.logger&.log('Debug', '[SSE] Starting stream')
119
-
120
- while connection.active?
121
- stream.write('', event: 'heartbeat')
122
- sleep route_instance.instance_variable_get(:@heartbeat_interval)
123
- end
124
-
125
- # Send RpcServerStop only if server is stopping (not client disconnect)
126
- if server_stopped
127
- begin
128
- stream.write({ event: 'RpcServerStop' }, event: 'RpcServerStop')
129
- ForestAdminRpcAgent::Facades::Container.logger&.log('Debug', '[SSE] RpcServerStop event sent')
130
- rescue StandardError => e
131
- ForestAdminRpcAgent::Facades::Container.logger&.log('Debug', "[SSE] Error sending stop event: #{e.message}")
132
- end
133
- end
134
- rescue IOError, Errno::EPIPE => e
135
- # Client disconnected normally
136
- ForestAdminRpcAgent::Facades::Container.logger&.log('Debug', "[SSE] Client disconnected: #{e.message}")
137
- rescue StandardError => e
138
- ForestAdminRpcAgent::Facades::Container.logger&.log('Error', "[SSE] Unexpected error: #{e.message}")
139
- ForestAdminRpcAgent::Facades::Container.logger&.log('Error', e.backtrace.join("\n"))
140
- ensure
141
- trap('INT', original_int_handler)
142
- trap('TERM', original_term_handler)
143
- ForestAdminRpcAgent::SseConnectionManager.unregister_connection(connection)
144
- ForestAdminRpcAgent::Facades::Container.logger&.log('Debug', '[SSE] Stream stopped')
145
-
146
- # Re-send the signal to allow proper server shutdown
147
- Process.kill(received_signal, Process.pid) if received_signal
148
- end
149
- end
150
-
151
- [status, headers, body]
152
- else
153
- [status, headers, response]
154
- end
155
- end
156
-
157
- router.match @url,
158
- defaults: { format: 'event-stream' },
159
- to: handler,
160
- via: @method,
161
- as: @name,
162
- route_alias: @name
163
- end
164
- end
165
- end
166
- end
@@ -1,81 +0,0 @@
1
- module ForestAdminRpcAgent
2
- # Manages SSE connections to ensure only one active connection at a time.
3
- # When a new connection is established, the previous one is terminated.
4
- # This prevents zombie loops when the master restarts and reconnects.
5
- class SseConnectionManager
6
- @mutex = Mutex.new
7
- @current_connection = nil
8
-
9
- class << self
10
- # Registers a new SSE connection and terminates any existing one.
11
- # Returns a connection object that can be used to check if the connection is still active.
12
- def register_connection
13
- connection = Connection.new
14
-
15
- @mutex.synchronize do
16
- # Terminate the previous connection if it exists
17
- if @current_connection
18
- ForestAdminRpcAgent::Facades::Container.logger&.log(
19
- 'Debug',
20
- '[SSE ConnectionManager] Terminating previous connection'
21
- )
22
- @current_connection.terminate
23
- end
24
-
25
- @current_connection = connection
26
- ForestAdminRpcAgent::Facades::Container.logger&.log(
27
- 'Debug',
28
- "[SSE ConnectionManager] New connection registered (id: #{connection.id})"
29
- )
30
- end
31
-
32
- connection
33
- end
34
-
35
- # Unregisters a connection when it's closed normally.
36
- def unregister_connection(connection)
37
- @mutex.synchronize do
38
- if @current_connection&.id == connection.id
39
- @current_connection = nil
40
- ForestAdminRpcAgent::Facades::Container.logger&.log(
41
- 'Debug',
42
- "[SSE ConnectionManager] Connection unregistered (id: #{connection.id})"
43
- )
44
- end
45
- end
46
- end
47
-
48
- # Returns the current active connection (for testing purposes)
49
- def current_connection
50
- @mutex.synchronize { @current_connection }
51
- end
52
-
53
- # Resets the manager state (for testing purposes)
54
- def reset!
55
- @mutex.synchronize do
56
- @current_connection&.terminate
57
- @current_connection = nil
58
- end
59
- end
60
- end
61
-
62
- # Represents an individual SSE connection
63
- class Connection
64
- attr_reader :id
65
-
66
- def initialize
67
- @id = SecureRandom.uuid
68
- @active = true
69
- @mutex = Mutex.new
70
- end
71
-
72
- def active?
73
- @mutex.synchronize { @active }
74
- end
75
-
76
- def terminate
77
- @mutex.synchronize { @active = false }
78
- end
79
- end
80
- end
81
- end
@@ -1,14 +0,0 @@
1
- require 'json'
2
-
3
- module ForestAdminRpcAgent
4
- class SseStreamer
5
- def initialize(yielder)
6
- @yielder = yielder
7
- end
8
-
9
- def write(object, event: nil)
10
- @yielder << "event: #{event}\n" if event
11
- @yielder << "data: #{JSON.dump(object)}\n\n"
12
- end
13
- end
14
- end