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.
@@ -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