ruflet_server 0.0.14 → 0.0.16

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: 68ae173b9c0aa89989e40d996aed245db4c434b96e084b2bc83642c89f244208
4
- data.tar.gz: 9aea810e84df29511565821252e06f7c0a73cc989b5df86df683393a496d8793
3
+ metadata.gz: b2720e5cd2f2bbe7ea1f8c6470c420fcb2b84bb8c6c3bab83ca12b5ea64633d5
4
+ data.tar.gz: 410a06961fed394deb2b0bdc194cccede9c407de205b5a8e977da1c165290e68
5
5
  SHA512:
6
- metadata.gz: de44b6b80acfdc2ce0bca54a3460e027b053d16244cf8d1e44d81b6446d036795008cba9510d37ccec622734205b9bb41cecb8401456bbeb0fa349d8a51ad13e
7
- data.tar.gz: 5a7e6e9e7623eebc5635ac8e200e13d8e62ba68335d5fe15533d6260b88a83fe5f5d3c279819c3f89d8f2465a5fac5014e399d1c4b9d67a52a15c0a3f3a76f91
6
+ metadata.gz: e9d1f379990a194e710220e0cc872501c3ce9da07e7e9149b5eaf4377ab669f5d74cc814bb6c8cf37dec3f647ea767074c1aa5c49daab87a584da3f9909d9846
7
+ data.tar.gz: c59c37531ebbd43796a7117d843208680562f18e09d939aeb63ab05b5560faa79fe7fbc5014de7fd4dc413f2be80d949f4cb1a4d802aa421ed4e41aa1ca325fb
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
+ bundle exec ruflet run
11
+ bundle exec ruflet run --web
12
+ bundle exec 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
@@ -7,9 +7,15 @@ require "thread"
7
7
  require "ruflet_core"
8
8
  require_relative "server/wire_codec"
9
9
  require_relative "server/web_socket_connection"
10
+ require_relative "server/connection_protocol"
10
11
 
11
12
  module Ruflet
13
+ # Standalone TCP transport for the Ruflet protocol. The protocol itself
14
+ # lives in Ruflet::ConnectionProtocol and is shared with host-server
15
+ # adapters (e.g. ruflet_rails runs it on the Rails server's own socket).
12
16
  class Server
17
+ include ConnectionProtocol
18
+
13
19
  attr_reader :port
14
20
 
15
21
  WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
@@ -47,12 +53,6 @@ module Ruflet
47
53
  restore_stop_signals(previous_signals)
48
54
  end
49
55
 
50
- # For Rack-hosted mode: caller already performed the HTTP upgrade.
51
- def handle_upgraded_socket(io)
52
- ws = Ruflet::WebSocketConnection.new(io)
53
- run_connection(ws)
54
- end
55
-
56
56
  def bind_server_socket!(max_attempts: 100)
57
57
  requested = @port.to_i
58
58
  candidate = requested
@@ -63,8 +63,9 @@ module Ruflet
63
63
  @server_socket = TCPServer.new(@host, candidate)
64
64
  @port = candidate
65
65
  if @port != requested && ENV["RUFLET_SUPPRESS_SERVER_BANNER"] != "1"
66
- warn "Requested port #{requested} is busy; bound to #{@port}"
66
+ warn "Port #{requested} is busy; using #{@port}."
67
67
  end
68
+ publish_bound_port!
68
69
  return
69
70
  rescue Errno::EADDRINUSE
70
71
  candidate += 1
@@ -80,6 +81,7 @@ module Ruflet
80
81
  return unless @running || @server_socket
81
82
 
82
83
  @running = false
84
+ remove_port_file!
83
85
 
84
86
  server = @server_socket
85
87
  @server_socket = nil
@@ -128,6 +130,30 @@ module Ruflet
128
130
 
129
131
  private
130
132
 
133
+ # Lets embedding hosts (e.g. the ruby_runtime Flutter plugins) discover
134
+ # which port the server actually bound when the requested one was busy.
135
+ def publish_bound_port!
136
+ path = ENV["RUFLET_PORT_FILE"].to_s
137
+ return if path.empty?
138
+
139
+ begin
140
+ File.write(path, @port.to_s)
141
+ rescue StandardError
142
+ nil
143
+ end
144
+ end
145
+
146
+ def remove_port_file!
147
+ path = ENV["RUFLET_PORT_FILE"].to_s
148
+ return if path.empty?
149
+
150
+ begin
151
+ File.delete(path)
152
+ rescue StandardError
153
+ nil
154
+ end
155
+ end
156
+
131
157
  def trap_stop_signals
132
158
  {
133
159
  "INT" => trap_signal("INT"),
@@ -207,30 +233,22 @@ module Ruflet
207
233
  warn e.backtrace.join("\n") if e.backtrace
208
234
  send_message(ws, Protocol::ACTIONS[:session_crashed], { "message" => e.message.to_s.dup.force_encoding("UTF-8") }) if ws
209
235
  ensure
210
- close_connection(ws)
211
- end
212
- end
213
-
214
- def run_connection(ws)
215
- register_connection(ws)
216
-
217
- while (raw = ws.read_message)
218
- handle_message(ws, raw)
236
+ if ws
237
+ close_connection(ws)
238
+ else
239
+ # Plain HTTP request: we answer with `Connection: close`, so we must
240
+ # actually close the socket. Leaving it open exhausts the browser's
241
+ # per-host connection pool and the later /ws upgrade never opens —
242
+ # the app then hangs on its "connecting" screen.
243
+ close_http_socket(socket)
244
+ end
219
245
  end
220
- rescue StandardError => e
221
- return if disconnect_error?(e)
222
-
223
- warn "server error: #{e.class}: #{e.message}"
224
- warn e.backtrace.join("\n") if e.backtrace
225
- send_message(ws, Protocol::ACTIONS[:session_crashed], { "message" => e.message.to_s.dup.force_encoding("UTF-8") })
226
- ensure
227
- close_connection(ws)
228
246
  end
229
247
 
230
- def close_connection(ws)
231
- remove_session(ws)
232
- unregister_connection(ws)
233
- ws&.close
248
+ def close_http_socket(socket)
249
+ socket.close if socket && !socket.closed?
250
+ rescue StandardError
251
+ nil
234
252
  end
235
253
 
236
254
  def read_http_upgrade_request(socket)
@@ -257,7 +275,7 @@ module Ruflet
257
275
  end
258
276
 
259
277
  def websocket_upgrade_request?(path, headers)
260
- return false unless path == "/ws"
278
+ return false unless path.to_s.split("?", 2).first == "/ws"
261
279
  return false unless headers["upgrade"]&.downcase == "websocket"
262
280
  return false unless headers["connection"]&.downcase&.include?("upgrade")
263
281
  return false if headers["sec-websocket-key"].to_s.empty?
@@ -266,21 +284,95 @@ module Ruflet
266
284
  end
267
285
 
268
286
  def handle_http_request(socket, path)
269
- case path
270
- when "/health"
271
- write_http_response(socket, 200, "text/plain", "ok")
287
+ clean = path.to_s.split("?", 2).first.split("#", 2).first
288
+ return write_http_response(socket, 200, "text/plain", "ok") if clean == "/health"
289
+
290
+ # In web mode the standalone backend also serves the Flutter web client,
291
+ # so the browser loads the app and opens its websocket on this same
292
+ # origin/port — no separate static server or proxy is needed.
293
+ return serve_web_client(socket, clean) if web_client_root
294
+
295
+ case clean
272
296
  when "/"
273
297
  write_http_response(socket, 200, "text/plain", "ruflet server")
274
298
  else
275
- if path.start_with?("/assets/")
276
- serve_asset(socket, path)
299
+ if clean.start_with?("/assets/")
300
+ serve_asset(socket, clean)
277
301
  else
278
302
  write_http_response(socket, 404, "text/plain", "not found")
279
303
  end
280
304
  end
281
305
  rescue StandardError => e
306
+ # The browser routinely cancels in-flight asset requests (preloads,
307
+ # duplicate connections); writing to a reset socket raises EPIPE/ECONNRESET
308
+ # and is expected, not an error.
309
+ return if disconnect_error?(e)
310
+
282
311
  warn "http error: #{e.class}: #{e.message}"
283
- write_http_response(socket, 500, "text/plain", "server error")
312
+ begin
313
+ write_http_response(socket, 500, "text/plain", "server error")
314
+ rescue StandardError
315
+ nil
316
+ end
317
+ end
318
+
319
+ def web_client_root
320
+ dir = ENV["RUFLET_WEB_CLIENT_DIR"].to_s
321
+ return nil if dir.empty?
322
+
323
+ full = File.expand_path(dir)
324
+ File.directory?(full) ? full : nil
325
+ end
326
+
327
+ def serve_web_client(socket, path)
328
+ root = web_client_root
329
+
330
+ # Neutralize the Flutter service worker: when this dev server hops between
331
+ # localhost ports a cached worker would otherwise keep a stale client
332
+ # alive that reconnects to the wrong backend. This unregisters it and
333
+ # clears caches so the browser always loads the current client.
334
+ if path == "/flutter_service_worker.js"
335
+ return write_http_response(socket, 200, "text/javascript", service_worker_reset_js, cache: false)
336
+ end
337
+
338
+ relative = path == "/" ? "index.html" : path.sub(%r{\A/}, "")
339
+ full = File.expand_path(File.join(root, relative))
340
+ if (full == root || full.start_with?(root + File::SEPARATOR)) && File.file?(full)
341
+ return write_http_response(socket, 200, content_type_for(full), File.binread(full), binary: true, cache: false)
342
+ end
343
+
344
+ # App runtime assets (images referenced by the app) fall back to the
345
+ # configured assets directory when not part of the client bundle.
346
+ if path.start_with?("/assets/") && (asset = resolve_asset_path(path))
347
+ return write_http_response(socket, 200, content_type_for(asset), File.binread(asset), binary: true, cache: false)
348
+ end
349
+
350
+ # SPA fallback: serve index.html for extension-less route paths.
351
+ index = File.join(root, "index.html")
352
+ if File.extname(path).empty? && File.file?(index)
353
+ return write_http_response(socket, 200, "text/html", File.binread(index), binary: true, cache: false)
354
+ end
355
+
356
+ write_http_response(socket, 404, "text/plain", "not found")
357
+ end
358
+
359
+ # A no-op service worker: it registers cleanly (so Flutter's loader, which
360
+ # awaits navigator.serviceWorker.ready, never hangs), wipes any caches a
361
+ # previous run left behind, claims the page, and installs NO fetch handler —
362
+ # so every request (including the app shell) goes straight to the network and
363
+ # the client always loads fresh and connects to the current origin/port.
364
+ # (Self-unregistering here can leave serviceWorker.ready unresolved.)
365
+ def service_worker_reset_js
366
+ <<~JS
367
+ self.addEventListener('install', function (e) { self.skipWaiting(); });
368
+ self.addEventListener('activate', function (e) {
369
+ e.waitUntil((async function () {
370
+ var keys = await caches.keys();
371
+ await Promise.all(keys.map(function (k) { return caches.delete(k); }));
372
+ await self.clients.claim();
373
+ })());
374
+ });
375
+ JS
284
376
  end
285
377
 
286
378
  def serve_asset(socket, path)
@@ -311,28 +403,39 @@ module Ruflet
311
403
  root = ENV["RUFLET_ASSETS_DIR"].to_s
312
404
  return root unless root.empty?
313
405
 
406
+ embedded_root = defined?($__ruflet_app_root) ? $__ruflet_app_root.to_s : ""
407
+ unless embedded_root.empty?
408
+ embedded_assets = File.join(embedded_root, "assets")
409
+ return embedded_assets if File.directory?(embedded_assets)
410
+ end
411
+
314
412
  default_root = File.join(Dir.pwd, "assets")
315
413
  File.directory?(default_root) ? default_root : nil
316
414
  end
317
415
 
318
416
  def content_type_for(path)
319
417
  case File.extname(path).downcase
320
- when ".png"
321
- "image/png"
322
- when ".jpg", ".jpeg"
323
- "image/jpeg"
324
- when ".gif"
325
- "image/gif"
326
- when ".webp"
327
- "image/webp"
328
- when ".svg"
329
- "image/svg+xml"
330
- else
331
- "application/octet-stream"
418
+ when ".html", ".htm" then "text/html; charset=utf-8"
419
+ when ".js", ".mjs" then "text/javascript; charset=utf-8"
420
+ when ".json", ".map" then "application/json; charset=utf-8"
421
+ when ".css" then "text/css; charset=utf-8"
422
+ when ".wasm" then "application/wasm"
423
+ when ".png" then "image/png"
424
+ when ".jpg", ".jpeg" then "image/jpeg"
425
+ when ".gif" then "image/gif"
426
+ when ".webp" then "image/webp"
427
+ when ".svg" then "image/svg+xml"
428
+ when ".ico" then "image/x-icon"
429
+ when ".ttf" then "font/ttf"
430
+ when ".otf" then "font/otf"
431
+ when ".woff" then "font/woff"
432
+ when ".woff2" then "font/woff2"
433
+ when ".txt" then "text/plain; charset=utf-8"
434
+ else "application/octet-stream"
332
435
  end
333
436
  end
334
437
 
335
- def write_http_response(socket, status, content_type, body, binary: false)
438
+ def write_http_response(socket, status, content_type, body, binary: false, cache: true)
336
439
  reason = {
337
440
  200 => "OK",
338
441
  404 => "Not Found",
@@ -345,6 +448,10 @@ module Ruflet
345
448
  socket.write("HTTP/1.1 #{status} #{reason}\r\n")
346
449
  socket.write("Content-Type: #{content_type}\r\n")
347
450
  socket.write("Content-Length: #{length}\r\n")
451
+ unless cache
452
+ socket.write("Cache-Control: no-store, no-cache, must-revalidate, max-age=0\r\n")
453
+ socket.write("Pragma: no-cache\r\n")
454
+ end
348
455
  socket.write("Connection: close\r\n")
349
456
  socket.write("\r\n")
350
457
  socket.write(body_str)
@@ -360,15 +467,8 @@ module Ruflet
360
467
  socket.write("\r\n")
361
468
  end
362
469
 
363
- def remove_session(ws)
364
- return unless ws
365
-
366
- @sessions_mutex.synchronize do
367
- @sessions.delete(ws.session_key)
368
- end
369
- end
370
-
371
- def register_connection(ws)
470
+ # ConnectionProtocol hooks: track live sockets so #stop can close them.
471
+ def connection_opened(ws)
372
472
  return unless ws
373
473
 
374
474
  @connections_mutex.synchronize do
@@ -376,7 +476,7 @@ module Ruflet
376
476
  end
377
477
  end
378
478
 
379
- def unregister_connection(ws)
479
+ def connection_closed(ws)
380
480
  return unless ws
381
481
 
382
482
  @connections_mutex.synchronize do
@@ -384,180 +484,5 @@ module Ruflet
384
484
  end
385
485
  end
386
486
 
387
- def handle_message(ws, raw)
388
- action, payload = decode_incoming(raw)
389
- payload ||= {}
390
-
391
- warn "incoming action=#{action.inspect}" if ENV["RUFLET_DEBUG"] == "1"
392
-
393
- case action
394
- when Protocol::ACTIONS[:register_client], Protocol::ACTIONS[:register_web_client]
395
- on_register_client(ws, payload)
396
- when Protocol::ACTIONS[:control_event], Protocol::ACTIONS[:page_event_from_web]
397
- on_control_event(ws, payload)
398
- when Protocol::ACTIONS[:update_control], Protocol::ACTIONS[:update_control_props]
399
- on_update_control(ws, payload)
400
- when Protocol::ACTIONS[:invoke_control_method]
401
- on_invoke_control_method(ws, payload)
402
- else
403
- raise "Unknown action: #{action.inspect}"
404
- end
405
- end
406
-
407
- def decode_incoming(raw)
408
- parsed = normalize_incoming(Ruflet::WireCodec.unpack(raw.to_s.b))
409
-
410
- if parsed.is_a?(Array) && parsed.length >= 2
411
- return [parsed[0], parsed[1]]
412
- end
413
-
414
- if parsed.is_a?(Hash)
415
- action = parsed["action"] || parsed[:action]
416
- payload = parsed["payload"] || parsed[:payload]
417
- return [action, payload] unless action.nil?
418
-
419
- if (parsed.key?("target") || parsed.key?(:target)) && (parsed.key?("name") || parsed.key?(:name))
420
- return [Protocol::ACTIONS[:control_event], parsed]
421
- end
422
- end
423
-
424
- raise "Unsupported payload format"
425
- end
426
-
427
- def normalize_incoming(value)
428
- case value
429
- when String
430
- value.dup.force_encoding("UTF-8")
431
- when Integer, Float, TrueClass, FalseClass, NilClass
432
- value
433
- when Symbol
434
- value.to_s
435
- when Array
436
- value.map { |v| normalize_incoming(v) }
437
- when Hash
438
- value.each_with_object({}) do |(k, v), out|
439
- out[k.to_s] = normalize_incoming(v)
440
- end
441
- else
442
- value.to_s
443
- end
444
- end
445
-
446
- def on_register_client(ws, payload)
447
- normalized = Protocol.normalize_register_payload(payload)
448
- session_id = normalized["session_id"].to_s.empty? ? pseudo_uuid : normalized["session_id"]
449
-
450
- page = Page.new(
451
- session_id: session_id,
452
- client_details: normalized,
453
- sender: lambda do |action, msg_payload|
454
- send_message(ws, action, msg_payload)
455
- end
456
- )
457
-
458
- page.title = "Ruflet App"
459
-
460
- @sessions_mutex.synchronize do
461
- @sessions[ws.session_key] = page
462
- end
463
-
464
- initial_response = [
465
- Protocol::ACTIONS[:register_client],
466
- {
467
- "session_id" => session_id,
468
- "page_patch" => {},
469
- "error" => nil
470
- }
471
- ]
472
- ws.send_binary(Ruflet::WireCodec.pack(initial_response))
473
-
474
- @app_block.call(page)
475
- page.update
476
- rescue StandardError => e
477
- send_message(ws, Protocol::ACTIONS[:session_crashed], { "message" => e.message })
478
- raise
479
- end
480
-
481
- def on_invoke_control_method(ws, payload)
482
- page = fetch_page(ws)
483
- page.handle_invoke_method_result(Protocol.normalize_invoke_method_result_payload(payload))
484
- end
485
-
486
- def on_control_event(ws, payload)
487
- event = Protocol.normalize_control_event_payload(payload)
488
- page = fetch_page(ws)
489
- return if event["target"].nil? || event["name"].to_s.empty?
490
-
491
- page.dispatch_event(
492
- target: event["target"],
493
- name: event["name"],
494
- data: normalize_event_data(event["data"])
495
- )
496
- end
497
-
498
- def on_update_control(ws, payload)
499
- update = Protocol.normalize_update_control_payload(payload)
500
- page = fetch_page(ws)
501
- return if update["id"].nil?
502
-
503
- page.apply_client_update(update["id"], update["props"] || {})
504
- end
505
-
506
- def fetch_page(ws)
507
- page = @sessions_mutex.synchronize { @sessions[ws.session_key] }
508
- raise "Session not found" unless page
509
-
510
- page
511
- end
512
-
513
- def normalize_event_data(value)
514
- case value
515
- when Hash
516
- value.each_with_object({}) { |(k, v), out| out[k.to_sym] = normalize_event_data(v) }
517
- when Array
518
- value.map { |entry| normalize_event_data(entry) }
519
- else
520
- value
521
- end
522
- end
523
-
524
- def send_message(ws, action, payload)
525
- return if ws.nil? || ws.closed?
526
-
527
- message = [action, payload]
528
- ws.send_binary(Ruflet::WireCodec.pack(message))
529
- rescue StandardError => e
530
- unless disconnect_error?(e)
531
- warn "send error: #{e.class}: #{e.message}"
532
- end
533
- remove_session(ws)
534
- unregister_connection(ws)
535
- ws&.close
536
- end
537
-
538
- def disconnect_error?(error)
539
- return true if error.is_a?(IOError)
540
- return true if error.is_a?(Errno::EPIPE)
541
- return true if error.is_a?(Errno::ECONNRESET)
542
- return true if error.is_a?(Errno::ECONNABORTED)
543
- return true if error.is_a?(Errno::ENOTCONN)
544
- return true if error.is_a?(Errno::ESHUTDOWN)
545
- return true if error.is_a?(Errno::EBADF)
546
- return true if error.is_a?(Errno::EINVAL)
547
-
548
- false
549
- end
550
-
551
- def pseudo_uuid
552
- now = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
553
- rnd = rand(0..0xffff_ffff)
554
- "%08x-%04x-%04x-%04x-%012x" % [
555
- rnd,
556
- now & 0xffff,
557
- (now >> 16) & 0xffff,
558
- (now >> 32) & 0xffff,
559
- (now >> 48) & 0xffff_ffff_ffff
560
- ]
561
- end
562
487
  end
563
488
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruflet
4
- VERSION = "0.0.14" unless const_defined?(:VERSION)
4
+ VERSION = "0.0.16" unless const_defined?(:VERSION)
5
5
  end
data/lib/ruflet_server.rb CHANGED
@@ -6,7 +6,8 @@ require_relative "ruflet/server"
6
6
  module Ruflet
7
7
  module_function
8
8
 
9
- def run(entrypoint = nil, host: "0.0.0.0", port: 8550, &block)
9
+ def run(entrypoint = nil, host: "0.0.0.0", port: nil, &block)
10
+ port = normalize_run_port(port || ENV["RUFLET_PORT"] || 8550)
10
11
  callback = entrypoint || block
11
12
  raise ArgumentError, "Ruflet.run requires a callable entrypoint or block" unless callback.respond_to?(:call)
12
13
 
@@ -27,5 +28,13 @@ module Ruflet
27
28
 
28
29
  @run_interceptors_mutex.synchronize { @run_interceptors.last }
29
30
  end
30
- private_class_method :run_interceptor
31
+
32
+ def normalize_run_port(value)
33
+ Integer(value)
34
+ rescue ArgumentError, TypeError
35
+ 8550
36
+ end
37
+ class << self
38
+ private :run_interceptor, :normalize_run_port
39
+ end
31
40
  end
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.14
4
+ version: 0.0.16
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.14
18
+ version: 0.0.16
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.14
25
+ version: 0.0.16
26
26
  description: Ruflet WebSocket server runtime compatible with Flet protocol.
27
27
  email:
28
28
  - adammusa2222@gmail.com
@@ -32,12 +32,14 @@ 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
38
39
  - lib/ruflet_server.rb
39
40
  homepage: https://github.com/AdamMusa/Ruflet
40
- licenses: []
41
+ licenses:
42
+ - MIT
41
43
  metadata: {}
42
44
  rdoc_options: []
43
45
  require_paths: