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 +4 -4
- data/README.md +15 -1
- data/lib/ruflet/server/connection_protocol.rb +285 -0
- data/lib/ruflet/server.rb +158 -233
- data/lib/ruflet/version.rb +1 -1
- data/lib/ruflet_server.rb +11 -2
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b2720e5cd2f2bbe7ea1f8c6470c420fcb2b84bb8c6c3bab83ca12b5ea64633d5
|
|
4
|
+
data.tar.gz: 410a06961fed394deb2b0bdc194cccede9c407de205b5a8e977da1c165290e68
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e9d1f379990a194e710220e0cc872501c3ce9da07e7e9149b5eaf4377ab669f5d74cc814bb6c8cf37dec3f647ea767074c1aa5c49daab87a584da3f9909d9846
|
|
7
|
+
data.tar.gz: c59c37531ebbd43796a7117d843208680562f18e09d939aeb63ab05b5560faa79fe7fbc5014de7fd4dc413f2be80d949f4cb1a4d802aa421ed4e41aa1ca325fb
|
data/README.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
1
|
# ruflet_server
|
|
2
2
|
|
|
3
|
-
|
|
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 "
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
276
|
-
serve_asset(socket,
|
|
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
|
-
|
|
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 ".
|
|
321
|
-
|
|
322
|
-
when ".
|
|
323
|
-
|
|
324
|
-
when ".
|
|
325
|
-
|
|
326
|
-
when ".
|
|
327
|
-
|
|
328
|
-
when ".
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
|
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
|
data/lib/ruflet/version.rb
CHANGED
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:
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|