ruflet_server 0.0.18 → 0.0.20

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: 9009d8e648b2101fcaf583feae53f3f5f6d9d8975849960d44c87f9a37f23597
4
- data.tar.gz: 58d04690e2b0395698a343722a5e0e7c69bb351c918a2979f11d47f7e14d95ab
3
+ metadata.gz: c8ce7c65f8922fb96c632f08c3fbbfbe5ab4d2f51ef010aca2938dada94bbb06
4
+ data.tar.gz: '09a16364d80b69210248753a9eeb062a8d895be2cfa02844bd7850a96c4392d4'
5
5
  SHA512:
6
- metadata.gz: bfff7024cc42755729901dca5fbfe79b494c9167aecd40e01a65b153614dc004394406a2b6fe56844b88fbfce84fdd66b3ee08e250b8b11edeed5d34c652adb6
7
- data.tar.gz: f958db4dacb8d0b6a17b1523e51e72e8374cb39c9eb32e69646d822cd6c2b7fa79a347ef1bde5fca3e71abfeeecdb0228fdec7f4936916e3d389ca1db52fc557
6
+ metadata.gz: 0efb74cd09d74eb058e0cfbdaa3027483fb809552e15109070a80e3bad364f9d9d297173249033df16029c2f6c366ee24db3e40848d1adf7fa583927b4fe7a83
7
+ data.tar.gz: 908da326003ca5cfff54b1a608148afaf0f557c8d9871c602e8234abe6710b6e79c27f0f0de0b9bcda78368ec67735d62ba31b41f84af9f6b551791bde15a889
data/README.md CHANGED
@@ -1,3 +1,17 @@
1
1
  # ruflet_server
2
2
 
3
- Part of Ruflet monorepo.
3
+ `ruflet_server` runs server-driven Ruflet applications and connects their Ruby
4
+ UI code to Ruflet clients.
5
+
6
+ It is installed automatically in projects created with `ruflet new`. Start an
7
+ application through the Ruflet CLI:
8
+
9
+ ```bash
10
+ ruflet run
11
+ ruflet run --web
12
+ ruflet run --desktop
13
+ ```
14
+
15
+ Application code uses the public `Ruflet.run` API supplied by `ruflet_core`.
16
+ Rails applications should use `ruflet_rails` instead of starting this server
17
+ directly.
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruflet
4
+ # Transport-agnostic implementation of the Ruflet wire protocol: one
5
+ # connection loop shared by every server that speaks to Flutter clients.
6
+ #
7
+ # The standalone TCP server (Ruflet::Server) and host-server adapters such
8
+ # as ruflet_rails' Rack-hijack endpoint include this module and provide
9
+ # only their transport plus the integration hooks below — the protocol
10
+ # itself is never reimplemented.
11
+ #
12
+ # Includers must initialize:
13
+ # @app_block — proc invoked with the Page on first registration
14
+ # @sessions — Hash mapping connection key => Page
15
+ # @sessions_mutex — Mutex guarding @sessions
16
+ module ConnectionProtocol
17
+ # ------------------------------------------------------------------
18
+ # Integration hooks (override in the including server as needed).
19
+ # ------------------------------------------------------------------
20
+
21
+ # Called when a connection enters the protocol loop.
22
+ def connection_opened(ws); end
23
+
24
+ # Called when a connection leaves the protocol loop.
25
+ def connection_closed(ws); end
26
+
27
+ # Return an existing Page to resume for this session id, or nil to
28
+ # create a fresh one (hosts with a session registry override this).
29
+ def resume_session(_session_id)
30
+ nil
31
+ end
32
+
33
+ # Called after a Page is stored for a connection.
34
+ def session_stored(page, ws); end
35
+
36
+ # Called after a Page is removed for a connection.
37
+ def session_removed(page, ws); end
38
+
39
+ # Called before a control event is dispatched to the Page.
40
+ def before_dispatch_event(ws, event); end
41
+
42
+ def log_connection_error(error)
43
+ warn "server error: #{error.class}: #{error.message}"
44
+ warn error.backtrace.join("\n") if error.backtrace
45
+ end
46
+
47
+ # ------------------------------------------------------------------
48
+ # Transport entry points.
49
+ # ------------------------------------------------------------------
50
+
51
+ # For hosts that already performed the HTTP upgrade (Rack hijack, the
52
+ # embedded runtime, tests with socket pairs).
53
+ def handle_upgraded_socket(io)
54
+ run_connection(Ruflet::WebSocketConnection.new(io))
55
+ end
56
+
57
+ def run_connection(ws)
58
+ connection_opened(ws)
59
+
60
+ while (raw = ws.read_message)
61
+ handle_message(ws, raw)
62
+ end
63
+ rescue StandardError => e
64
+ return if disconnect_error?(e)
65
+
66
+ log_connection_error(e)
67
+ send_message(ws, Protocol::ACTIONS[:session_crashed], { "message" => e.message.to_s.dup.force_encoding("UTF-8") })
68
+ ensure
69
+ close_connection(ws)
70
+ end
71
+
72
+ def close_connection(ws)
73
+ return unless ws
74
+
75
+ remove_session(ws)
76
+ connection_closed(ws)
77
+ ws.close
78
+ end
79
+
80
+ # ------------------------------------------------------------------
81
+ # Protocol core.
82
+ # ------------------------------------------------------------------
83
+
84
+ def handle_message(ws, raw)
85
+ action, payload = decode_incoming(raw)
86
+ payload ||= {}
87
+
88
+ warn "incoming action=#{action.inspect}" if ENV["RUFLET_DEBUG"] == "1"
89
+
90
+ case action
91
+ when Protocol::ACTIONS[:register_client], Protocol::ACTIONS[:register_web_client]
92
+ on_register_client(ws, payload)
93
+ when Protocol::ACTIONS[:control_event], Protocol::ACTIONS[:page_event_from_web]
94
+ on_control_event(ws, payload)
95
+ when Protocol::ACTIONS[:update_control], Protocol::ACTIONS[:update_control_props]
96
+ on_update_control(ws, payload)
97
+ when Protocol::ACTIONS[:invoke_control_method]
98
+ on_invoke_control_method(ws, payload)
99
+ else
100
+ raise "Unknown action: #{action.inspect}"
101
+ end
102
+ end
103
+
104
+ def decode_incoming(raw)
105
+ parsed = normalize_incoming(Ruflet::WireCodec.unpack(raw.to_s.b))
106
+
107
+ if parsed.is_a?(Array) && parsed.length >= 2
108
+ return [parsed[0], parsed[1]]
109
+ end
110
+
111
+ if parsed.is_a?(Hash)
112
+ action = parsed["action"] || parsed[:action]
113
+ payload = parsed["payload"] || parsed[:payload]
114
+ return [action, payload] unless action.nil?
115
+
116
+ if (parsed.key?("target") || parsed.key?(:target)) && (parsed.key?("name") || parsed.key?(:name))
117
+ return [Protocol::ACTIONS[:control_event], parsed]
118
+ end
119
+ end
120
+
121
+ raise "Unsupported payload format"
122
+ end
123
+
124
+ def normalize_incoming(value)
125
+ case value
126
+ when String
127
+ value.dup.force_encoding("UTF-8")
128
+ when Integer, Float, TrueClass, FalseClass, NilClass
129
+ value
130
+ when Symbol
131
+ value.to_s
132
+ when Array
133
+ value.map { |v| normalize_incoming(v) }
134
+ when Hash
135
+ value.each_with_object({}) do |(k, v), out|
136
+ out[k.to_s] = normalize_incoming(v)
137
+ end
138
+ else
139
+ value.to_s
140
+ end
141
+ end
142
+
143
+ def on_register_client(ws, payload)
144
+ normalized = Protocol.normalize_register_payload(payload)
145
+ session_id = normalized["session_id"].to_s.empty? ? pseudo_uuid : normalized["session_id"]
146
+
147
+ page = resume_session(session_id)
148
+ first_registration = page.nil?
149
+
150
+ if page
151
+ attach_sender(page, ws)
152
+ reset_mount_state(page)
153
+ else
154
+ page = Page.new(
155
+ session_id: session_id,
156
+ client_details: normalized,
157
+ sender: sender_for(ws)
158
+ )
159
+ page.title = "Ruflet App"
160
+ end
161
+
162
+ @sessions_mutex.synchronize { @sessions[ws.session_key] = page }
163
+ session_stored(page, ws)
164
+
165
+ initial_response = [
166
+ Protocol::ACTIONS[:register_client],
167
+ Protocol.register_response(session_id: session_id)
168
+ ]
169
+ ws.send_binary(Ruflet::WireCodec.pack(initial_response))
170
+
171
+ @app_block.call(page) if first_registration
172
+ page.update
173
+ rescue StandardError => e
174
+ send_message(ws, Protocol::ACTIONS[:session_crashed], { "message" => e.message.to_s })
175
+ raise
176
+ end
177
+
178
+ def on_control_event(ws, payload)
179
+ event = Protocol.normalize_control_event_payload(payload)
180
+ page = fetch_page(ws)
181
+ return if event["target"].nil? || event["name"].to_s.empty?
182
+
183
+ attach_sender(page, ws)
184
+ before_dispatch_event(ws, event)
185
+ page.dispatch_event(
186
+ target: event["target"],
187
+ name: event["name"],
188
+ data: normalize_event_data(event["data"])
189
+ )
190
+ end
191
+
192
+ def on_update_control(ws, payload)
193
+ update = Protocol.normalize_update_control_payload(payload)
194
+ page = fetch_page(ws)
195
+ return if update["id"].nil?
196
+
197
+ attach_sender(page, ws)
198
+ page.apply_client_update(update["id"], update["props"] || {})
199
+ end
200
+
201
+ def on_invoke_control_method(ws, payload)
202
+ page = fetch_page(ws)
203
+ attach_sender(page, ws)
204
+ page.handle_invoke_method_result(Protocol.normalize_invoke_method_result_payload(payload))
205
+ end
206
+
207
+ def fetch_page(ws)
208
+ page = @sessions_mutex.synchronize { @sessions[ws.session_key] }
209
+ raise "Session not found" unless page
210
+
211
+ page
212
+ end
213
+
214
+ def remove_session(ws)
215
+ return unless ws
216
+
217
+ page = @sessions_mutex.synchronize { @sessions.delete(ws.session_key) }
218
+ session_removed(page, ws) if page
219
+ page
220
+ end
221
+
222
+ def normalize_event_data(value)
223
+ case value
224
+ when Hash
225
+ value.each_with_object({}) { |(k, v), out| out[k.to_sym] = normalize_event_data(v) }
226
+ when Array
227
+ value.map { |entry| normalize_event_data(entry) }
228
+ else
229
+ value
230
+ end
231
+ end
232
+
233
+ def send_message(ws, action, payload)
234
+ return if ws.nil? || ws.closed?
235
+
236
+ ws.send_binary(Ruflet::WireCodec.pack([action, payload]))
237
+ rescue StandardError => e
238
+ log_connection_error(e) unless disconnect_error?(e)
239
+ remove_session(ws)
240
+ connection_closed(ws)
241
+ nil
242
+ end
243
+
244
+ def sender_for(ws)
245
+ lambda do |action, msg_payload|
246
+ send_message(ws, action, msg_payload)
247
+ end
248
+ end
249
+
250
+ def attach_sender(page, ws)
251
+ page.instance_variable_set(:@sender, sender_for(ws))
252
+ end
253
+
254
+ def reset_mount_state(page)
255
+ page.instance_variable_set(:@overlay_container_mounted, false)
256
+ page.instance_variable_set(:@dialogs_container_mounted, false)
257
+ page.instance_variable_set(:@services_container_mounted, false)
258
+ end
259
+
260
+ def disconnect_error?(error)
261
+ return true if error.is_a?(IOError)
262
+ return true if error.is_a?(Errno::EPIPE)
263
+ return true if error.is_a?(Errno::ECONNRESET)
264
+ return true if error.is_a?(Errno::ECONNABORTED)
265
+ return true if error.is_a?(Errno::ENOTCONN)
266
+ return true if error.is_a?(Errno::ESHUTDOWN)
267
+ return true if error.is_a?(Errno::EBADF)
268
+ return true if error.is_a?(Errno::EINVAL)
269
+
270
+ false
271
+ end
272
+
273
+ def pseudo_uuid
274
+ now = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
275
+ rnd = rand(0..0xffff_ffff)
276
+ "%08x-%04x-%04x-%04x-%012x" % [
277
+ rnd,
278
+ now & 0xffff,
279
+ (now >> 16) & 0xffff,
280
+ (now >> 32) & 0xffff,
281
+ (now >> 48) & 0xffff_ffff_ffff
282
+ ]
283
+ end
284
+ end
285
+ end
data/lib/ruflet/server.rb CHANGED
@@ -124,21 +124,50 @@ module Ruflet
124
124
  ws = @connections_mutex.synchronize { @connections[session_key] }
125
125
  next unless ws
126
126
 
127
- refreshed_page = Page.new(
128
- session_id: current_page.session_id,
129
- client_details: current_page.client_details,
130
- sender: lambda do |action, payload|
131
- send_message(ws, action, payload)
127
+ # Open dialogs, sheets, and snack bars are Navigator routes on the
128
+ # client; replacing the control tree does not pop them. Close them
129
+ # first, otherwise a reload while an overlay is open leaves it on
130
+ # screen wired to control ids the reloaded page cannot resolve.
131
+ begin
132
+ nil while current_page.respond_to?(:close_dialog) && current_page.close_dialog
133
+ rescue StandardError
134
+ nil
135
+ end
136
+
137
+ if current_page.respond_to?(:reset_for_reload!)
138
+ # Re-render on the live page. The client's overlay/service/dialogs
139
+ # containers stay mounted; a recreated Page would re-send them,
140
+ # replacing their client-side instances and detaching them — after
141
+ # which dialog and service patches are silently ignored.
142
+ current_page.reset_for_reload!
143
+ @app_block.call(current_page)
144
+ # Clear page-level chrome the reloaded block dropped and flush the
145
+ # overlay (appbar/drawer/FAB live in the view and rebuild wholesale;
146
+ # page props and the mounted overlay need explicit updates).
147
+ current_page.finalize_reload! if current_page.respond_to?(:finalize_reload!)
148
+ # Keeps route-driven apps on their current route: replays the
149
+ # route_change event when the block did not route itself.
150
+ current_page.replay_route_after_reload! if current_page.respond_to?(:replay_route_after_reload!)
151
+ current_page.update
152
+ else
153
+ # Older ruflet_core without reset_for_reload!: fall back to a fresh
154
+ # page (loses client-side container bindings until reconnect).
155
+ refreshed_page = Page.new(
156
+ session_id: current_page.session_id,
157
+ client_details: current_page.client_details,
158
+ sender: lambda do |action, payload|
159
+ send_message(ws, action, payload)
160
+ end
161
+ )
162
+ refreshed_page.title = "Ruflet App"
163
+
164
+ @sessions_mutex.synchronize do
165
+ @sessions[session_key] = refreshed_page
132
166
  end
133
- )
134
- refreshed_page.title = "Ruflet App"
135
167
 
136
- @sessions_mutex.synchronize do
137
- @sessions[session_key] = refreshed_page
168
+ @app_block.call(refreshed_page)
169
+ refreshed_page.update
138
170
  end
139
-
140
- @app_block.call(refreshed_page)
141
- refreshed_page.update
142
171
  rescue StandardError => e
143
172
  warn "reload error: #{e.class}: #{e.message}"
144
173
  end
@@ -155,7 +184,10 @@ module Ruflet
155
184
 
156
185
  def trap_signal(signal_name)
157
186
  Signal.trap(signal_name) do
158
- stop
187
+ # Trap context restricts Mutex use, so calling stop here raises
188
+ # ThreadError and the signal is silently swallowed. Only unwind the
189
+ # main thread; start's ensure performs the actual stop outside the
190
+ # trap context.
159
191
  Thread.main.raise(Interrupt)
160
192
  rescue StandardError
161
193
  nil
@@ -324,14 +356,17 @@ module Ruflet
324
356
  end
325
357
 
326
358
  def handle_http_request(socket, path)
327
- case path
359
+ request_path = path.to_s.split("?", 2).first.to_s
360
+ return if serve_web_client(socket, request_path)
361
+
362
+ case request_path
328
363
  when "/health"
329
364
  write_http_response(socket, 200, "text/plain", "ok")
330
365
  when "/"
331
366
  write_http_response(socket, 200, "text/plain", "ruflet server")
332
367
  else
333
- if path.start_with?("/assets/")
334
- serve_asset(socket, path)
368
+ if request_path.start_with?("/assets/")
369
+ serve_asset(socket, request_path)
335
370
  else
336
371
  write_http_response(socket, 404, "text/plain", "not found")
337
372
  end
@@ -341,6 +376,37 @@ module Ruflet
341
376
  write_http_response(socket, 500, "text/plain", "server error")
342
377
  end
343
378
 
379
+ # The Flutter web client is served from this same port so that it loads and
380
+ # opens its websocket on one origin. Without that the client cannot reach
381
+ # /ws at all.
382
+ def web_client_root
383
+ return @web_client_root if defined?(@web_client_root)
384
+
385
+ configured = ENV["RUFLET_WEB_CLIENT_DIR"].to_s.strip
386
+ @web_client_root =
387
+ if configured.empty? || !File.directory?(configured)
388
+ nil
389
+ else
390
+ File.expand_path(configured)
391
+ end
392
+ end
393
+
394
+ def serve_web_client(socket, request_path)
395
+ root = web_client_root
396
+ return false unless root
397
+
398
+ relative = request_path
399
+ relative = "/index.html" if relative.empty? || relative == "/"
400
+ candidate = File.expand_path(File.join(root, relative))
401
+ # Never serve outside the client bundle.
402
+ return false unless candidate == root || candidate.start_with?("#{root}#{File::SEPARATOR}")
403
+ return false unless File.file?(candidate)
404
+
405
+ content = read_binary_file(candidate)
406
+ write_http_response(socket, 200, content_type_for(candidate), content, binary: true)
407
+ true
408
+ end
409
+
344
410
  def serve_asset(socket, path)
345
411
  asset_path = resolve_asset_path(path)
346
412
  unless asset_path
@@ -392,6 +458,28 @@ module Ruflet
392
458
  "image/webp"
393
459
  when ".svg"
394
460
  "image/svg+xml"
461
+ when ".html", ".htm"
462
+ "text/html; charset=utf-8"
463
+ when ".js", ".mjs"
464
+ "text/javascript; charset=utf-8"
465
+ when ".css"
466
+ "text/css; charset=utf-8"
467
+ when ".json", ".map"
468
+ "application/json; charset=utf-8"
469
+ when ".wasm"
470
+ "application/wasm"
471
+ when ".ttf"
472
+ "font/ttf"
473
+ when ".otf"
474
+ "font/otf"
475
+ when ".woff"
476
+ "font/woff"
477
+ when ".woff2"
478
+ "font/woff2"
479
+ when ".ico"
480
+ "image/x-icon"
481
+ when ".txt", ".symbols"
482
+ "text/plain; charset=utf-8"
395
483
  else
396
484
  "application/octet-stream"
397
485
  end
@@ -533,11 +621,23 @@ module Ruflet
533
621
  normalized = Protocol.normalize_register_payload(payload)
534
622
  session_id = normalized["session_id"].to_s.empty? ? pseudo_uuid : normalized["session_id"]
535
623
 
624
+ # Run the app block BEFORE responding and ship the complete state in the
625
+ # register response (page_patch), like Flet. The client merges that map
626
+ # by control id, keeping existing instances alive — required for
627
+ # reconnecting clients (backend restarts) whose control store persists.
628
+ # Incremental op patches sent during the block are superseded by the
629
+ # full state and dropped; everything else is flushed afterwards.
630
+ registered = false
631
+ buffered = []
536
632
  page = Page.new(
537
633
  session_id: session_id,
538
634
  client_details: normalized,
539
635
  sender: lambda do |action, msg_payload|
540
- send_message(ws, action, msg_payload)
636
+ if registered
637
+ send_message(ws, action, msg_payload)
638
+ else
639
+ buffered << [action, msg_payload]
640
+ end
541
641
  end
542
642
  )
543
643
 
@@ -547,14 +647,29 @@ module Ruflet
547
647
  @sessions[ws.session_key] = page
548
648
  end
549
649
 
650
+ @app_block.call(page)
651
+
652
+ page_patch = page.respond_to?(:register_page_patch) ? page.register_page_patch : {}
653
+ response = begin
654
+ Protocol.register_response(session_id: session_id, page_patch: page_patch)
655
+ rescue ArgumentError
656
+ # Older ruflet_core without the page_patch parameter.
657
+ page_patch = {}
658
+ Protocol.register_response(session_id: session_id)
659
+ end
550
660
  initial_response = [
551
661
  Protocol::ACTIONS[:register_client],
552
- Protocol.register_response(session_id: session_id)
662
+ response
553
663
  ]
554
664
  ws.send_binary(Ruflet::WireCodec.pack(initial_response))
665
+ registered = true
555
666
 
556
- @app_block.call(page)
557
- page.update
667
+ buffered.each do |action, msg_payload|
668
+ next if action == Protocol::ACTIONS[:patch_control]
669
+
670
+ send_message(ws, action, msg_payload)
671
+ end
672
+ page.update if page_patch.empty?
558
673
  rescue StandardError => e
559
674
  send_message(ws, Protocol::ACTIONS[:session_crashed], { "message" => e.message })
560
675
  raise e
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruflet
4
- VERSION = "0.0.18" unless const_defined?(:VERSION)
4
+ VERSION = "0.0.20" unless const_defined?(:VERSION)
5
5
  end
data/lib/ruflet_server.rb CHANGED
@@ -2,18 +2,4 @@
2
2
 
3
3
  require "ruflet_core"
4
4
  require_relative "ruflet/server"
5
-
6
- module Ruflet
7
- module_function
8
-
9
- def run(entrypoint = nil, host: "0.0.0.0", port: nil, &block)
10
- callback = entrypoint || block
11
- raise ArgumentError, "Ruflet.run requires a callable entrypoint or block" unless callback.respond_to?(:call)
12
- port = resolved_run_port(port) if respond_to?(:resolved_run_port)
13
- port = 8550 if port.nil?
14
-
15
- Server.new(host: host, port: port) do |page|
16
- callback.call(page)
17
- end.start
18
- end
19
- end
5
+ require_relative "ruflet/server/connection_protocol"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruflet_server
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.18
4
+ version: 0.0.20
5
5
  platform: ruby
6
6
  authors:
7
7
  - AdamMusa
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.0.18
18
+ version: 0.0.20
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.0.18
25
+ version: 0.0.20
26
26
  description: Ruflet WebSocket server runtime compatible with Flet protocol.
27
27
  email:
28
28
  - adammusa2222@gmail.com
@@ -32,6 +32,7 @@ extra_rdoc_files: []
32
32
  files:
33
33
  - README.md
34
34
  - lib/ruflet/server.rb
35
+ - lib/ruflet/server/connection_protocol.rb
35
36
  - lib/ruflet/server/web_socket_connection.rb
36
37
  - lib/ruflet/server/wire_codec.rb
37
38
  - lib/ruflet/version.rb