citrine 0.2.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,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ # DevTools 构建(T-B3):显式 `require "citrine/debug"` 才会加载本文件——
4
+ # 生产构建不 require 它,产物中不含任何埋点字符串与依赖图导出代码。
5
+ #
6
+ # 能力:
7
+ # 1. 依赖图数据层:Citrine.debug_dependency_graph 导出 signal→effect 边
8
+ # 与重跑计数快照(可 to_json,供 Cytoscape 依赖图 UI 与信号泳道时序消费)。
9
+ # 2. 诊断接口:Effect#disposed? / debug_info、Signal#debug_info。
10
+ #
11
+ # 实现方式:以 prepend 模块包住 Signal/Effect 的 initialize/run——
12
+ # 内核方法保持原样,不因埋点改变行为。
13
+ module Citrine
14
+ # DevTools 埋点(仅 citrine/debug 加载时生效)
15
+ module Debug
16
+ module SignalClassTracking
17
+ attr_accessor :debug_tracking
18
+
19
+ def all
20
+ @all ||= []
21
+ end
22
+
23
+ def track(signal)
24
+ all << signal if debug_tracking
25
+ end
26
+ end
27
+
28
+ module EffectClassTracking
29
+ attr_accessor :debug_tracking
30
+
31
+ def all
32
+ @all ||= []
33
+ end
34
+
35
+ def track(effect)
36
+ all << effect if debug_tracking
37
+ end
38
+ end
39
+
40
+ module SignalInstanceTracking
41
+ def initialize(*args, &block)
42
+ super
43
+ Signal.track(self)
44
+ end
45
+
46
+ def debug_info
47
+ { id: object_id, subscribers: @subs.size }
48
+ end
49
+ end
50
+
51
+ module EffectInstanceTracking
52
+ def initialize(*args, &block)
53
+ super
54
+ Effect.track(self)
55
+ end
56
+
57
+ def run(*args, &block)
58
+ super.tap { @runs = (@runs || 0) + 1 }
59
+ end
60
+
61
+ def disposed?
62
+ @deps.nil?
63
+ end
64
+
65
+ def debug_info
66
+ { id: object_id, deps: (@deps || []).map(&:object_id), runs: @runs, disposed: disposed? }
67
+ end
68
+ end
69
+
70
+ class << Signal
71
+ prepend SignalClassTracking
72
+ end
73
+
74
+ class << Effect
75
+ prepend EffectClassTracking
76
+ end
77
+ end
78
+
79
+ class Signal
80
+ prepend Debug::SignalInstanceTracking
81
+ end
82
+
83
+ class Effect
84
+ prepend Debug::EffectInstanceTracking
85
+ end
86
+
87
+ class << self
88
+ def debug_tracking?
89
+ @debug_tracking == true
90
+ end
91
+
92
+ def debug_tracking=(value)
93
+ @debug_tracking = value == true
94
+ Signal.debug_tracking = self.debug_tracking?
95
+ Effect.debug_tracking = self.debug_tracking?
96
+ self
97
+ end
98
+
99
+ # DevTools 依赖图数据:开启埋点 → 返回当前 signal→effect 边与
100
+ # 各 Effect 的重跑计数快照(可 to_json)。UI 晚一步、数据先行。
101
+ def debug_dependency_graph
102
+ self.debug_tracking = true
103
+ {
104
+ signals: Signal.all.map(&:debug_info),
105
+ effects: Effect.all.reject(&:disposed?).map(&:debug_info)
106
+ }
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Citrine 开发服务器(T-B1 瘦身版:Rack + Puma + Listen 替代手写 socket 服务)。
4
+ #
5
+ # 功能(与旧版行为等价):
6
+ # - 静态服务指定目录(html/css/…),"/" 提供目录索引
7
+ # - *.js 请求按对应 *.rb 现场编译(Opal CLI),带失效缓存
8
+ # - 监听 lib/ 与目录下 .rb/.css 变更,经 SSE 通知浏览器整页刷新
9
+ # - 编译失败时返回错误浮层脚本(页面不再白屏)
10
+ # - 样式资产注入(Citrine.css → <link>;Citrine.css_text → <style>)
11
+ #
12
+ # 用法:bin/citrine dev [目录] [-p 端口] [-I 加载路径]
13
+ #
14
+ # 旧版自建五件事(socket 静态服务 / 轮询监听 / 缓存失效 / SSE / 浮层),
15
+ # 瘦身后 HTTP 交给 Rack + Puma、文件监听交给 Listen,只保留应用语义
16
+ # (现场编译、错误浮层、样式资产注入)。
17
+ require "rack"
18
+ require "puma"
19
+ require "puma/server"
20
+ require "listen"
21
+ require "json"
22
+ require "open3"
23
+ require "tmpdir"
24
+ require "tempfile"
25
+
26
+ require "citrine/theme" # 样式资产注册表(Citrine.css / css_text)
27
+
28
+ module Citrine
29
+ class DevServer
30
+ CONTENT_TYPES = {
31
+ "html" => "text/html; charset=utf-8",
32
+ "js" => "application/javascript",
33
+ "css" => "text/css",
34
+ "png" => "image/png",
35
+ "svg" => "image/svg+xml",
36
+ "map" => "application/json"
37
+ }.freeze
38
+
39
+ CLIENT_JS = <<~'JS'
40
+ (function () {
41
+ var overlay = null;
42
+ window.__rvShowError = function (msg) {
43
+ if (!overlay) {
44
+ overlay = document.createElement("pre");
45
+ overlay.style.cssText =
46
+ "position:fixed;z-index:99999;top:0;left:0;right:0;margin:0;padding:12px;" +
47
+ "background:#b91c1c;color:#fff;font:13px/1.5 monospace;white-space:pre-wrap;" +
48
+ "max-height:60vh;overflow:auto;";
49
+ document.documentElement.appendChild(overlay);
50
+ }
51
+ overlay.textContent = "⚠ Citrine 编译错误(修正并保存即自动恢复)\n\n" + msg;
52
+ };
53
+ window.__rvClearError = function () {
54
+ if (overlay) { overlay.remove(); overlay = null; }
55
+ };
56
+ var es = new EventSource("/__rv_reload");
57
+ es.onmessage = function (e) {
58
+ if (e.data === "reload") { window.__rvClearError(); location.reload(); }
59
+ };
60
+ })();
61
+ JS
62
+
63
+ # SSE ping 清扫周期(秒):僵尸连接(浏览器已关、但无消息可写)平时不会暴露,
64
+ # 定期 ping 让写动作发生,写失败即被清扫(T6)
65
+ SSE_PING_INTERVAL = 15
66
+
67
+ # 命令行解析(纯函数,便于单测):返回 [目录, 端口, 额外加载路径]
68
+ def self.parse_args(args)
69
+ dir = nil
70
+ port = 4402
71
+ extra_libs = []
72
+ i = 0
73
+ while i < args.length
74
+ arg = args[i]
75
+ if arg == "-p"
76
+ value = args[i + 1]
77
+ raise ArgumentError, "-p 需要一个端口参数(如 -p 4402)" if value.nil? || value.start_with?("-")
78
+
79
+ port = value.to_i
80
+ i += 2
81
+ elsif arg == "-I"
82
+ value = args[i + 1]
83
+ raise ArgumentError, "-I 需要一个加载路径参数(如 -I ../some-lib/lib)" if value.nil? || value.start_with?("-")
84
+
85
+ extra_libs << value
86
+ i += 2
87
+ elsif arg.start_with?("-I")
88
+ extra_libs << arg[2..]
89
+ i += 1
90
+ elsif arg !~ /\A-/
91
+ dir = arg
92
+ i += 1
93
+ else
94
+ warn "未知参数 #{arg},已忽略(用法:citrine dev [目录] [-p 端口] [-I 加载路径])"
95
+ i += 1
96
+ end
97
+ end
98
+ [dir || "examples", port, extra_libs]
99
+ end
100
+
101
+ def self.run!(args)
102
+ dir, port, extra_libs = parse_args(args)
103
+ new(dir, port, extra_libs).start
104
+ end
105
+
106
+ def initialize(dir, port, extra_libs = [])
107
+ @dir = File.expand_path(dir)
108
+ @root = File.expand_path("../..", __dir__) # 项目根(dev_server.rb 位于 lib/citrine/)
109
+ # 额外加载路径(-I 可重复):跨仓库示例(如组件库的 examples/)编译时
110
+ # 需要补上那个仓库的 lib;转绝对路径——编译 cwd 是源文件所在目录
111
+ @extra_libs = extra_libs.map { |p| File.expand_path(p) }
112
+ @port = port
113
+ @clients = [] # 每个 SSE 连接一个 Queue
114
+ @cache = {} # js 路径 => body(Listen 事件触发时整体清空)
115
+ @mutex = Mutex.new
116
+ end
117
+
118
+ def start
119
+ raise "目录不存在: #{@dir}" unless File.directory?(@dir)
120
+
121
+ watch
122
+ start_ping_sweep
123
+ puma = Puma::Server.new(rack_app)
124
+ puma.add_tcp_listener "127.0.0.1", @port
125
+ puts "Citrine dev server → http://localhost:#{@port}/"
126
+ puts " 目录: #{@dir};监听 lib/**/*.rb 与该目录 **/*.{rb,css}"
127
+ puma.run
128
+ sleep # Puma 在后台线程受理连接,主线程挂起
129
+ end
130
+
131
+ private
132
+
133
+ # ── 文件监听(Listen 替代 0.3s 轮询)──────────────────────
134
+
135
+ def watch
136
+ sources = [File.join(@root, "lib"), @dir].select { |p| File.directory?(p) }
137
+ listener = Listen.to(*sources, only: /\.(rb|css)$/) do |modified, added, _removed|
138
+ @mutex.synchronize { @cache.clear }
139
+ names = (modified + added).map { |f| f.delete_prefix("#{@root}/") }
140
+ puts "[citrine] 变更: #{names.join(', ')} → 通知刷新"
141
+ broadcast("reload")
142
+ end
143
+ listener.start
144
+ end
145
+
146
+ def broadcast(message)
147
+ @mutex.synchronize { @clients.dup }.each { |queue| queue << message }
148
+ end
149
+
150
+ # ── Rack 应用 ──────────────────────────────────────────
151
+
152
+ def rack_app
153
+ @rack_app ||= ->(env) { route(env) }
154
+ end
155
+
156
+ def route(env)
157
+ path = Rack::Request.new(env).path
158
+ return sse(env) if path == "/__rv_reload"
159
+ return respond(200, "application/javascript", CLIENT_JS) if path == "/__rv_client.js"
160
+ return index if path == "/"
161
+
162
+ route_file(path)
163
+ rescue StandardError => e
164
+ respond(500, "text/plain; charset=utf-8", "#{e.class}: #{e.message}")
165
+ end
166
+
167
+ def route_file(path)
168
+ full = File.expand_path(File.join(@dir, path.delete_prefix("/")))
169
+ # A4:目录逃逸守卫——裸前缀匹配挡不住 ../(@dir 为 /x/app 时 /x/app-evil 也命中),
170
+ # 必须"等于目录本身,或以 目录+分隔符 开头";先确认是文件再比路径
171
+ unless File.file?(full) && (full == @dir || full.start_with?(@dir + File::SEPARATOR))
172
+ return respond(404, "text/plain; charset=utf-8", "not found: #{path}")
173
+ end
174
+
175
+ if path.end_with?(".js")
176
+ rb = full.sub(/\.js$/, ".rb")
177
+ return serve_compiled(path, rb) if File.exist?(rb)
178
+ end
179
+
180
+ body = File.binread(full)
181
+ type = CONTENT_TYPES[full.split(".").last] || "application/octet-stream"
182
+ if full.end_with?(".html")
183
+ body = inject_client(body)
184
+ type = CONTENT_TYPES["html"]
185
+ end
186
+ respond(200, type, body)
187
+ end
188
+
189
+ def serve_compiled(path, rb_full)
190
+ cached = @cache[path]
191
+ return respond(200, "application/javascript", cached) if cached
192
+
193
+ # T6:临时产物用 Tempfile(进程退出兜底清理),不再 rand 拼名裸写 tmpdir
194
+ tmp = Tempfile.new(["rv_dev_#{Process.pid}", ".js"])
195
+ # 与手工编译完全一致的形态:cwd = 源文件所在目录,-I附着式传参
196
+ includes = ["-I#{File.join(@root, 'lib')}", "-I."] + @extra_libs.map { |p| "-I#{p}" }
197
+ out, err, status = Open3.capture3(
198
+ opal_executable, "-c", *includes,
199
+ "-o", tmp.path, File.basename(rb_full),
200
+ chdir: File.dirname(rb_full)
201
+ )
202
+ if status.success?
203
+ body = File.binread(tmp.path)
204
+ @mutex.synchronize { @cache[path] = body }
205
+ respond(200, "application/javascript", body)
206
+ else
207
+ message = (out + "\n" + err).strip.to_json
208
+ respond(200, "application/javascript",
209
+ "window.__rvShowError(#{message});")
210
+ end
211
+ rescue Errno::ENOENT
212
+ abort "找不到 opal 可执行文件:请先 bundle install,并用 bundle exec bin/citrine dev 启动"
213
+ ensure
214
+ tmp&.close!
215
+ end
216
+
217
+ # A5:经 rubygems 解析 opal 的 binstub(bundler 环境下稳定指向 bundle 内的 opal,
218
+ # 不依赖 PATH 里有没有裸 "opal");解析不到退化为 PATH 查找——真缺失时由
219
+ # 上方 Errno::ENOENT 分支给出可操作的 abort 提示
220
+ def opal_executable
221
+ Gem.bin_path("opal", "opal")
222
+ rescue Gem::LoadError
223
+ "opal"
224
+ end
225
+
226
+ def index
227
+ pages = Dir.glob(File.join(@dir, "*.html")).map { |f| File.basename(f) }.sort
228
+ links = pages.map { |p| %(<li><a href="/#{p}">#{p}</a></li>) }.join("\n")
229
+ body = <<~HTML
230
+ <!DOCTYPE html>
231
+ <html lang="zh"><head><meta charset="utf-8"><title>Citrine dev</title></head>
232
+ <body style="font-family:sans-serif;padding:24px;line-height:1.8">
233
+ <h2>Citrine dev server</h2>
234
+ <p>修改 lib/ 或本目录下的 .rb / .css 文件并保存,浏览器将自动刷新。</p>
235
+ <ul>#{links}</ul>
236
+ </body></html>
237
+ HTML
238
+ respond(200, CONTENT_TYPES["html"], body)
239
+ end
240
+
241
+ def inject_client(html)
242
+ # S2-5:先把声明的样式资产(<link> / <style>)注入 <head>
243
+ html = inject_head_assets(html)
244
+ # CITRINE_DEV:开发模式标志(布局提醒等只在开发期输出;生产构建不注入)
245
+ script = %(<script>window.CITRINE_DEV = true;</script>\n<script src="/__rv_client.js"></script>)
246
+ return html.sub("</head>", "#{script}</head>") if html.include?("</head>")
247
+
248
+ html.sub("</body>", "#{script}</body>")
249
+ end
250
+
251
+ # S2-5:样式资产注入——Citrine.css 声明的样式表(<link>)与
252
+ # Citrine.css_text 自定义样式文本(<style>,媒体查询/伪类的逃生舱)。
253
+ # 纯函数(便于单测):只改传入的 html,不读文件系统。
254
+ def inject_head_assets(html)
255
+ assets = Citrine.css_files.map { |f| %(<link rel="stylesheet" href="/#{f}">) }
256
+ assets << "<style>#{Citrine.css_text}</style>" if Citrine.css_text && !Citrine.css_text.empty?
257
+ return html if assets.empty?
258
+
259
+ block = assets.join("\n")
260
+ return html.sub("</head>", "#{block}\n</head>") if html.include?("</head>")
261
+
262
+ html.sub("</body>", "#{block}\n</body>")
263
+ end
264
+
265
+ # ── SSE 热刷新(rack.hijack 接管连接)────────────────────
266
+
267
+ def sse(env)
268
+ io = env["rack.hijack"].call
269
+ io.write "HTTP/1.1 200 OK\r\n" \
270
+ "Content-Type: text/event-stream\r\n" \
271
+ "Cache-Control: no-cache\r\n" \
272
+ "Connection: keep-alive\r\n\r\n"
273
+ queue = Queue.new
274
+ @mutex.synchronize { @clients << queue }
275
+ Thread.new { pump_client(io, queue) }
276
+ [-1, {}, []] # 已劫持连接,Rack 不再处理响应
277
+ end
278
+
279
+ # 每连接一个泵线程:从队列取消息写给浏览器;写失败(连接已死)即注销。
280
+ # ping 帧(:ping)只作心跳不占消息位——僵尸连接靠它暴露写失败并被清扫。
281
+ def pump_client(io, queue)
282
+ loop do
283
+ message = queue.pop
284
+ io.write(message == :ping ? ": ping\n\n" : "data: #{message}\n\n")
285
+ end
286
+ rescue StandardError
287
+ @mutex.synchronize { @clients.delete(queue) } # 连接断开时清理
288
+ end
289
+
290
+ # T6:定期给所有 SSE 连接发 ping——平时无消息可写时,浏览器已关的僵尸连接
291
+ # 不会触发写失败,注册表只增不减;ping 让写动作周期性发生,死连接随之被清扫
292
+ def start_ping_sweep
293
+ Thread.new do
294
+ loop do
295
+ sleep SSE_PING_INTERVAL
296
+ @mutex.synchronize { @clients.dup }.each { |queue| queue << :ping }
297
+ end
298
+ end
299
+ end
300
+
301
+ def respond(status, type, body)
302
+ [status, {"Content-Type" => type, "Content-Length" => body.bytesize.to_s}, [body]]
303
+ end
304
+ end
305
+ end