citrine-devtools 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +44 -0
- data/bin/citrine-devtools +27 -0
- data/docs/PROTOCOL.md +52 -0
- data/lib/citrine-devtools/bridge.js +264 -0
- data/lib/citrine-devtools/panel.html +127 -0
- data/lib/citrine-devtools/panel.js +1056 -0
- data/lib/citrine-devtools/server.rb +304 -0
- data/lib/citrine-devtools/vendor/cytoscape.min.js +32 -0
- data/lib/citrine-devtools/version.rb +7 -0
- data/lib/citrine-devtools.rb +6 -0
- metadata +113 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack"
|
|
4
|
+
require "puma"
|
|
5
|
+
require "puma/server"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Citrine
|
|
9
|
+
module DevTools
|
|
10
|
+
# DevTools 中继服务(M2-2):浏览器桥接脚本与调试面板之间的消息总线。
|
|
11
|
+
#
|
|
12
|
+
# 端点与消息格式见 docs/PROTOCOL.md(传输协议 v1)——协议是契约,本文件是实现:
|
|
13
|
+
# GET /__devtools/stream SSE 下行流(面板与桥接共用;15s comment ping 清扫僵尸连接)
|
|
14
|
+
# POST /__devtools/ingest 桥接批量上报 {v:1, messages:[…]},逐条广播给所有 stream 客户端
|
|
15
|
+
# POST /__devtools/cmd 面板指令,语义原样广播(桥接消费、面板忽略)
|
|
16
|
+
# GET / 调试面板页面(panel.html,M3)
|
|
17
|
+
# GET /panel.js 调试面板脚本(panel.js,M3)
|
|
18
|
+
# GET /bridge.js 桥接脚本静态资源
|
|
19
|
+
# GET /vendor/cytoscape.min.js 依赖图渲染库(M4,vendored cytoscape 3.30.4,MIT)
|
|
20
|
+
# 所有响应带 CORS `Access-Control-Allow-Origin: *` 并处理 OPTIONS 预检(仅 localhost 调试场景)。
|
|
21
|
+
# v1 单会话:不按会话隔离,多窗口消息混合(协议 §会话)。
|
|
22
|
+
#
|
|
23
|
+
# 实现借 citrine DevServer 的成熟模式:Rack + Puma、每连接 Queue + 泵线程、
|
|
24
|
+
# rack.hijack 接管 SSE 连接、定期 ping 让僵尸连接的写失败暴露并被清扫。
|
|
25
|
+
class Server
|
|
26
|
+
PROTOCOL_VERSION = 1
|
|
27
|
+
DEFAULT_PORT = 9527
|
|
28
|
+
HOST = "127.0.0.1"
|
|
29
|
+
|
|
30
|
+
# 协议规定的 SSE ping 清扫周期(秒):僵尸连接(浏览器已关、无消息可写)平时不暴露,
|
|
31
|
+
# 定期 comment ping 让写动作发生,写失败即被清扫
|
|
32
|
+
SSE_PING_INTERVAL = 15
|
|
33
|
+
|
|
34
|
+
# 单批 ingest 条数上限:v1 本地调试的安全阀(协议未规定上限,超出回 400 提示分批)
|
|
35
|
+
MAX_INGEST_MESSAGES = 1000
|
|
36
|
+
|
|
37
|
+
# SSE 注册表条目:每连接一个队列,泵线程阻塞取消息写给客户端
|
|
38
|
+
Client = Struct.new(:queue, :io)
|
|
39
|
+
|
|
40
|
+
# 桥接脚本随 gem 分发,/bridge.js 原样吐出(请求时复读文件,开发期改完即生效)
|
|
41
|
+
BRIDGE_JS_PATH = File.expand_path("bridge.js", __dir__)
|
|
42
|
+
|
|
43
|
+
# 面板资产随 gem 分发(M3):/ 与 /panel.js 原样吐出(同 bridge_js 的请求时复读模式)
|
|
44
|
+
PANEL_HTML_PATH = File.expand_path("panel.html", __dir__)
|
|
45
|
+
PANEL_JS_PATH = File.expand_path("panel.js", __dir__)
|
|
46
|
+
|
|
47
|
+
# 依赖图渲染库(M4):vendored cytoscape(MIT),面板 <script src="/vendor/cytoscape.min.js"> 引入
|
|
48
|
+
CYTOSCAPE_JS_PATH = File.expand_path("vendor/cytoscape.min.js", __dir__)
|
|
49
|
+
|
|
50
|
+
attr_reader :port
|
|
51
|
+
|
|
52
|
+
# 命令行解析(纯函数,便于单测):citrine-devtools serve [-p 端口]
|
|
53
|
+
def self.parse_args(args)
|
|
54
|
+
port = DEFAULT_PORT
|
|
55
|
+
i = 0
|
|
56
|
+
while i < args.length
|
|
57
|
+
arg = args[i]
|
|
58
|
+
if arg == "-p"
|
|
59
|
+
value = args[i + 1]
|
|
60
|
+
raise ArgumentError, "-p 需要一个端口参数(如 -p 9527)" if value.nil? || value.start_with?("-")
|
|
61
|
+
|
|
62
|
+
begin
|
|
63
|
+
port = Integer(value, 10)
|
|
64
|
+
rescue ArgumentError
|
|
65
|
+
raise ArgumentError, "-p 端口必须是整数(收到 #{value.inspect})"
|
|
66
|
+
end
|
|
67
|
+
i += 2
|
|
68
|
+
else
|
|
69
|
+
warn "未知参数 #{arg},已忽略(用法:citrine-devtools serve [-p 端口])"
|
|
70
|
+
i += 1
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
port
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.run!(args)
|
|
77
|
+
new(port: parse_args(args)).run
|
|
78
|
+
rescue ArgumentError => e
|
|
79
|
+
warn "参数错误:#{e.message}"
|
|
80
|
+
exit 1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# port 0 表示随机空闲端口(测试用);ping_interval 仅供测试缩短清扫周期
|
|
84
|
+
def initialize(port: DEFAULT_PORT, ping_interval: SSE_PING_INTERVAL)
|
|
85
|
+
@requested_port = port
|
|
86
|
+
@ping_interval = ping_interval
|
|
87
|
+
@clients = []
|
|
88
|
+
@mutex = Mutex.new
|
|
89
|
+
@puma = nil
|
|
90
|
+
@ping_thread = nil
|
|
91
|
+
@port = nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# 非阻塞启动(Puma 在后台线程受理连接),返回 self;实际绑定端口见 #port
|
|
95
|
+
def start
|
|
96
|
+
@puma = Puma::Server.new(rack_app)
|
|
97
|
+
@puma.add_tcp_listener HOST, @requested_port
|
|
98
|
+
@port = @puma.connected_ports.first
|
|
99
|
+
start_ping_sweep
|
|
100
|
+
@puma.run
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# 阻塞运行(CLI 入口)
|
|
105
|
+
def run
|
|
106
|
+
start
|
|
107
|
+
puts "citrine-devtools 中继 → http://localhost:#{@port}/"
|
|
108
|
+
puts " 调试面板: http://localhost:#{@port}/"
|
|
109
|
+
puts " 被调试页引入: <script src=\"http://127.0.0.1:#{@port}/bridge.js\"></script>"
|
|
110
|
+
sleep
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# 关闭:停 ping 清扫、断开全部 SSE 连接、停 Puma 并释放监听端口
|
|
114
|
+
def stop
|
|
115
|
+
@ping_thread&.kill
|
|
116
|
+
@ping_thread = nil
|
|
117
|
+
@mutex.synchronize { @clients.dup }.each do |client|
|
|
118
|
+
begin
|
|
119
|
+
client.io.close # 先关 io,泵线程被唤醒后的写必然失败并注销
|
|
120
|
+
rescue StandardError
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
client.queue << :ping
|
|
124
|
+
end
|
|
125
|
+
@puma&.stop(true)
|
|
126
|
+
@puma = nil
|
|
127
|
+
self
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# SSE 注册表规模(测试与运维观察口)
|
|
131
|
+
def client_count
|
|
132
|
+
@mutex.synchronize { @clients.size }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
# ── Rack 路由 ─────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
def rack_app
|
|
140
|
+
@rack_app ||= ->(env) { route(env) }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def route(env)
|
|
144
|
+
request = Rack::Request.new(env)
|
|
145
|
+
return preflight if request.options?
|
|
146
|
+
|
|
147
|
+
case request.path
|
|
148
|
+
when "/__devtools/stream"
|
|
149
|
+
request.get? ? stream(env) : method_not_allowed("GET")
|
|
150
|
+
when "/__devtools/ingest"
|
|
151
|
+
request.post? ? ingest(env) : method_not_allowed("POST")
|
|
152
|
+
when "/__devtools/cmd"
|
|
153
|
+
request.post? ? cmd(env) : method_not_allowed("POST")
|
|
154
|
+
when "/"
|
|
155
|
+
request.get? ? static_file(PANEL_HTML_PATH, "text/html", "/") : method_not_allowed("GET")
|
|
156
|
+
when "/panel.js"
|
|
157
|
+
request.get? ? static_file(PANEL_JS_PATH, "application/javascript", "/panel.js") : method_not_allowed("GET")
|
|
158
|
+
when "/bridge.js"
|
|
159
|
+
request.get? ? respond(200, "application/javascript", bridge_js) : method_not_allowed("GET")
|
|
160
|
+
when "/vendor/cytoscape.min.js"
|
|
161
|
+
request.get? ? static_file(CYTOSCAPE_JS_PATH, "application/javascript", "/vendor/cytoscape.min.js") : method_not_allowed("GET")
|
|
162
|
+
else
|
|
163
|
+
respond(404, "application/json", json_error("not found: #{request.path}"))
|
|
164
|
+
end
|
|
165
|
+
rescue JSON::ParserError
|
|
166
|
+
respond(400, "application/json", json_error("请求体不是合法 JSON"))
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
respond(500, "application/json", json_error("#{e.class}: #{e.message}"))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# ── 上行:ingest / cmd ────────────────────────────────
|
|
172
|
+
|
|
173
|
+
# 桥接批量上报:校验后逐条广播(一条消息一个 SSE 事件,保序)
|
|
174
|
+
def ingest(env)
|
|
175
|
+
body = JSON.parse(env["rack.input"].read)
|
|
176
|
+
unless body.is_a?(Hash) && body["v"] == PROTOCOL_VERSION && body["messages"].is_a?(Array)
|
|
177
|
+
return respond(400, "application/json", json_error("ingest 需要 {v:1, messages:[…]}"))
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
messages = body["messages"]
|
|
181
|
+
if messages.size > MAX_INGEST_MESSAGES
|
|
182
|
+
return respond(400, "application/json",
|
|
183
|
+
json_error("单批消息超过上限 #{MAX_INGEST_MESSAGES} 条,请分批上报"))
|
|
184
|
+
end
|
|
185
|
+
unless messages.all? { |m| valid_message?(m) }
|
|
186
|
+
return respond(400, "application/json",
|
|
187
|
+
json_error("每条消息都必须是带 v:1 与 type 的 JSON 对象"))
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
messages.each { |m| broadcast(JSON.generate(m)) }
|
|
191
|
+
# M2-4 验收诊断:ingest 记账(每批一行:类型统计)
|
|
192
|
+
tally = messages.group_by { |m| m["type"] }.transform_values(&:size)
|
|
193
|
+
puts "[ingest] #{messages.size} 条 #{tally}" if ENV["CITRINE_DEVTOOLS_DEBUG"]
|
|
194
|
+
respond(200, "application/json",
|
|
195
|
+
JSON.generate({v: PROTOCOL_VERSION, ok: true, accepted: messages.size}))
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def valid_message?(message)
|
|
199
|
+
message.is_a?(Hash) && message["v"] == PROTOCOL_VERSION && message["type"].is_a?(String)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# 面板指令:校验后语义原样广播(紧凑重排保证 SSE 单行成帧;桥接消费、面板忽略)
|
|
203
|
+
def cmd(env)
|
|
204
|
+
body = JSON.parse(env["rack.input"].read)
|
|
205
|
+
unless body.is_a?(Hash) && body["v"] == PROTOCOL_VERSION &&
|
|
206
|
+
body["type"] == "cmd" && body["cmd"].is_a?(String)
|
|
207
|
+
return respond(400, "application/json",
|
|
208
|
+
json_error("cmd 需要 {v:1, type:\"cmd\", cmd:\"…\", …}"))
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
broadcast(JSON.generate(body))
|
|
212
|
+
respond(200, "application/json", JSON.generate({v: PROTOCOL_VERSION, ok: true}))
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# ── 下行:SSE 广播(rack.hijack 接管连接)──────────────
|
|
216
|
+
|
|
217
|
+
def stream(env)
|
|
218
|
+
io = env["rack.hijack"].call
|
|
219
|
+
io.write "HTTP/1.1 200 OK\r\n" \
|
|
220
|
+
"Content-Type: text/event-stream\r\n" \
|
|
221
|
+
"Cache-Control: no-cache\r\n" \
|
|
222
|
+
"Connection: keep-alive\r\n" \
|
|
223
|
+
"Access-Control-Allow-Origin: *\r\n" \
|
|
224
|
+
"\r\n"
|
|
225
|
+
client = Client.new(Queue.new, io)
|
|
226
|
+
@mutex.synchronize { @clients << client }
|
|
227
|
+
Thread.new { pump_client(client) }
|
|
228
|
+
[-1, {}, []] # 已劫持连接,Rack 不再处理响应
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# 每连接一个泵线程:从队列取消息写成 SSE 帧;写失败(对端已关)即注销。
|
|
232
|
+
# ping 帧(:ping)只作心跳不占消息位——僵尸连接靠它暴露写失败并被清扫。
|
|
233
|
+
def pump_client(client)
|
|
234
|
+
loop do
|
|
235
|
+
message = client.queue.pop
|
|
236
|
+
client.io.write(message == :ping ? ": ping\n\n" : "data: #{message}\n\n")
|
|
237
|
+
end
|
|
238
|
+
rescue StandardError
|
|
239
|
+
@mutex.synchronize { @clients.delete(client) } # 连接断开时清理
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def broadcast(payload)
|
|
243
|
+
@mutex.synchronize { @clients.dup }.each { |client| client.queue << payload }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# 定期给所有 SSE 连接发 ping——协议规定的 15s comment ping 清扫周期
|
|
247
|
+
def start_ping_sweep
|
|
248
|
+
@ping_thread = Thread.new do
|
|
249
|
+
loop do
|
|
250
|
+
sleep @ping_interval
|
|
251
|
+
@mutex.synchronize { @clients.dup }.each { |client| client.queue << :ping }
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# ── 响应辅助 ──────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
def respond(status, type, body)
|
|
259
|
+
[status, {
|
|
260
|
+
"content-type" => type,
|
|
261
|
+
"content-length" => body.bytesize.to_s,
|
|
262
|
+
"access-control-allow-origin" => "*"
|
|
263
|
+
}, [body]]
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def preflight
|
|
267
|
+
[204, {
|
|
268
|
+
"access-control-allow-origin" => "*",
|
|
269
|
+
"access-control-allow-methods" => "GET, POST, OPTIONS",
|
|
270
|
+
"access-control-allow-headers" => "content-type",
|
|
271
|
+
"access-control-max-age" => "86400",
|
|
272
|
+
"content-length" => "0"
|
|
273
|
+
}, []]
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def method_not_allowed(allow)
|
|
277
|
+
[405, {
|
|
278
|
+
"content-type" => "application/json",
|
|
279
|
+
"content-length" => json_error("method not allowed(请用 #{allow})").bytesize.to_s,
|
|
280
|
+
"allow" => allow,
|
|
281
|
+
"access-control-allow-origin" => "*"
|
|
282
|
+
}, [json_error("method not allowed(请用 #{allow})")]]
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def json_error(message)
|
|
286
|
+
JSON.generate({v: PROTOCOL_VERSION, error: message})
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# 面板等静态资产:请求时复读文件(开发期改完即生效);文件缺失(如旧版安装)
|
|
290
|
+
# 按静态资源口径回 404,而不是走通用 rescue 的 500
|
|
291
|
+
def static_file(path, type, public_name)
|
|
292
|
+
unless File.exist?(path)
|
|
293
|
+
return respond(404, "application/json", json_error("static asset not found: #{public_name}"))
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
respond(200, type, File.binread(path))
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def bridge_js
|
|
300
|
+
File.binread(BRIDGE_JS_PATH)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|