From 5fc209fd4c5265c9c797d76f86a34c01f0af185c Mon Sep 17 00:00:00 2001 From: tiye Date: Thu, 24 Sep 2026 13:57:32 +0800 Subject: [PATCH 1/8] Migrate Cumulo Reel server host boundaries to Node JS --- .gitignore | 1 + calcit.cirru | 287 ++++++++++++++++++++---- config/calcit-quality.cirru | 29 ++- deps.cirru | 5 +- docs/architectures/js-server-host.cirru | 151 +++++++++++++ docs/js-server-migration.md | 24 ++ package.json | 10 +- 7 files changed, 455 insertions(+), 52 deletions(-) create mode 100644 docs/architectures/js-server-host.cirru create mode 100644 docs/js-server-migration.md diff --git a/.gitignore b/.gitignore index 3baac40..b2d0ba3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .compact-inc.cirru .calcit-error.cirru +.calcit/ js-out/ node_modules/ diff --git a/calcit.cirru b/calcit.cirru index 382d81c..7d41cf4 100644 --- a/calcit.cirru +++ b/calcit.cirru @@ -7,9 +7,9 @@ :feature-policy $ {} :modules $ [] |respo.calcit/ |recollect/ |respo-ui.calcit/ |ws-edn.calcit/ |cumulo-util.calcit/ |respo-message.calcit/ |js-ffi/ :type-slots $ {} $ :dispatch-op |cumulo-reel.schema/Op - :server $ {} (:description |) (:init-fn 'cumulo-reel.app.server/main!) (:mode :native) (:reload-fn 'cumulo-reel.app.server/reload!) (:target :node) + :server $ {} (:description |) (:init-fn 'cumulo-reel.app.server/main!) (:mode :js) (:reload-fn 'cumulo-reel.app.server/reload!) (:target :node) :feature-policy $ {} - :modules $ [] |recollect/ |ws-edn.calcit/ |cumulo-util.calcit/ |calcit.std/ |calcit-wss/ + :modules $ [] |recollect/ |ws-edn.calcit/ |cumulo-util.calcit/ |js-ffi/ :type-slots $ {} $ :dispatch-op |cumulo-reel.schema/Op :files $ {} 'cumulo-reel.app.client $ %{} 'FileEntry @@ -441,10 +441,10 @@ '*initial-db $ %{} 'CodeEntry (:doc |) :code $ quote $ defatom *initial-db if - path-exists? $ w-log storage-file + file-exists? $ w-log storage-file do (println "|Found local EDN data") assert-type - parse-cirru-edn (read-file storage-file) + parse-cirru-edn (read-text! storage-file) {} (:Database schema/database) (:Session schema/session) (:User schema/user) (:Router schema/router) 'cumulo-reel.schema/Database do (println "|Found no data") schema/database @@ -463,7 +463,7 @@ :code $ quote $ defn dispatch! (op sid) let op-id $ generate-id! - op-time $ -> (get-time!) get-timestamp + op-time $ now-ms if config/dev? $ println |Dispatch! (str op) sid match op (:effect/persist) (persist-db!) @@ -474,10 +474,10 @@ 'get-backup-path! $ %{} 'CodeEntry (:doc |) :code $ quote $ defn get-backup-path! () let - now $ extract-time $ get-time! - join-path calcit-dirname |backups - str $ &map:get now :month - str (&map:get now :day) |-snapshot.cirru + now $ local-month-day! + backup-dir $ path-join calcit-dirname |backups + month-dir $ path-join backup-dir $ str (&map:get now :month) + path-join month-dir $ str (&map:get now :day) |-snapshot.cirru :examples $ [] :schema $ :: 'Fn $ {} (:return 'String) :args $ [] @@ -492,11 +492,11 @@ run-server! port println $ str "|Server started on port:" port do (; "|init it before doing multi-threading") (identity @*reader-reel) - set-interval 200 $ fn () $ render-loop! - set-interval 600000 $ fn () $ persist-db! - on-control-c on-exit! + every! 200 $ fn () $ render-loop! + every! 600000 $ fn () $ persist-db! + on-interrupt! on-exit! :examples $ [] - :schema $ :: 'Fn $ {} (:return 'FfiTask) + :schema $ :: 'Fn $ {} (:return 'Unit) :args $ [] :features $ #{} :js-ffi 'on-exit! $ %{} 'CodeEntry (:doc |) @@ -511,8 +511,8 @@ file-content $ format-cirru-edn $ assoc (assert-type reel.:db 'cumulo-reel.schema/Database) :sessions ({}) storage-path storage-file backup-path $ get-backup-path! - check-write-file! storage-path file-content - check-write-file! backup-path file-content + check-write-text! storage-path file-content + check-write-text! backup-path file-content :examples $ [] :schema $ :: 'Fn $ {} (:return 'Unit) :args $ [] @@ -535,24 +535,24 @@ :args $ [] 'run-server! $ %{} 'CodeEntry (:doc |) :code $ quote $ defn run-server! (port) - wss-serve! (&{} :port port) - fn (data) (println |Data data) - match data - (:connect sid) - do - dispatch! (:: :session/connect) sid - println "|New client." - (:message sid msg) - match (try-parse-cirru-edn-as msg 'cumulo-reel.schema/Op) - (:ok action) (dispatch! action sid) - (:err error) (eprintln "|Invalid client action:" error) - (:disconnect sid) - do (println "|Client closed!") - dispatch! (:: :session/disconnect) sid - (:blob sid _) (eprintln "|Unexpected binary message from:" sid) - :examples $ [] - :schema $ :: 'Fn $ {} (:return 'FfiTask) + serve! port $ fn (data) + match data + (:connect sid) + do + dispatch! (:: :session/connect) sid + println "|New client." + (:message sid msg) + match (try-parse-cirru-edn-as msg 'cumulo-reel.schema/Op) + (:ok action) (dispatch! action sid) + (:err error) (eprintln "|Invalid client action:" error) + (:disconnect sid) + do (println "|Client closed!") + dispatch! (:: :session/disconnect) sid + (:blob sid) (eprintln "|Unexpected binary message from:" sid) + :examples $ [] + :schema $ :: 'Fn $ {} (:return 'ws-edn.server/NodeWebSocketServerHost) :args $ [] 'Number + :features $ #{} :js-ffi 'storage-file $ %{} 'CodeEntry (:doc |) :code $ quote $ def storage-file if (empty? calcit-dirname) @@ -562,7 +562,7 @@ :schema $ :: 'String 'sync-clients! $ %{} 'CodeEntry (:doc |) :code $ quote $ defn sync-clients! (reel) (begin-twig-frame!) - wss-each! $ fn (sid) + each! $ fn (sid) let db $ assert-type reel.:db 'cumulo-reel.schema/Database records reel.:records @@ -579,7 +579,7 @@ if not $ empty? changes do - wss-send! sid $ format-cirru-edn $ {} (:kind :patch) (:data changes) + send! sid $ format-cirru-edn $ {} (:kind :patch) (:data changes) swap! *client-caches assoc sid new-store finish-twig-frame! :examples $ [] @@ -593,16 +593,211 @@ cumulo-reel.app.config :as config cumulo-reel.app.twig.container :refer $ twig-container recollect.diff :refer $ diff-twig - wss.core :refer $ wss-serve! wss-send! wss-each! recollect.twig :refer $ clear-twig-caches! cumulo-reel.$meta :refer $ calcit-dirname - calcit.std.fs :refer $ path-exists? check-write-file! - calcit.std.time :refer $ set-interval - calcit.std.date :refer $ get-time! get-timestamp extract-time - calcit.std.path :refer $ join-path recollect.memo :refer $ begin-twig-frame! finish-twig-frame! + cumulo-reel.app.server-ws :refer $ serve! send! each! + js-ffi.node :refer $ file-exists? read-text! path-join + js-ffi.shared :refer $ now-ms + cumulo-reel.app.server-host :refer $ check-write-text! local-month-day! every! on-interrupt! + 'cumulo-reel.app.server-host $ %{} 'FileEntry + :defs $ {} + 'LocalDateHost $ %{} 'CodeEntry (:doc "|仅暴露备份命名所需的宿主本地月、日读取。") + :code $ quote $ deftrait LocalDateHost + .get-month $ :: 'Fn $ {} + :args $ [] 'cumulo-reel.app.server-host/LocalDateHost + :return 'Number + .get-date $ :: 'Fn $ {} + :args $ [] 'cumulo-reel.app.server-host/LocalDateHost + :return 'Number + :examples $ [] + :ffi $ {} (:backend :js) (:kind :external-object) (:target :node) + :names $ {} (:get-date |getDate) (:get-month |getMonth) + :schema $ :: 'Trait + 'NodeProcessHost $ %{} 'CodeEntry (:doc "|仅用于订阅 Node 的 SIGINT 信号。") + :code $ quote $ deftrait NodeProcessHost + .on $ :: 'Fn $ {} + :args $ [] 'cumulo-reel.app.server-host/NodeProcessHost 'String $ :: 'Fn + {} + :args $ [] + :return 'Unit + :return 'cumulo-reel.app.server-host/NodeProcessHost + :examples $ [] + :ffi $ {} (:backend :js) (:kind :external-object) (:target :node) + :names $ {} $ :on |on + :schema $ :: 'Trait + 'check-write-text! $ %{} 'CodeEntry (:doc "|内容未变时跳过写入,按需建立一级备份目录。") + :code $ quote $ defn check-write-text! (path content) + let + parent $ path-dirname path + ensure-directory! parent + if (file-exists? path) + if + = (read-text! path) content + , &unit $ write-text! path content + write-text! path content + , &unit + :examples $ [] + :schema $ :: 'Fn $ {} (:return 'Unit) + :args $ [] 'String 'String + :features $ #{} :js-ffi + 'ensure-directory! $ %{} 'CodeEntry (:doc "|递归建立缺少的父目录,避免备份月份目录不存在时写入失败。") + :code $ quote $ defn ensure-directory! (directory) + if (file-exists? directory) &unit $ do + ensure-directory! $ path-dirname directory + mkdir! directory + , &unit + :examples $ [] + :schema $ :: 'Fn $ {} (:return 'Unit) + :args $ [] 'String + :features $ #{} :js-ffi + 'every! $ %{} 'CodeEntry (:doc "|由 Node 宿主重复调度 Unit 回调。") + :code $ quote $ defn every! (delay callback) (js/setInterval callback delay) &unit + :examples $ [] + :schema $ :: 'Fn $ {} (:return 'Unit) + :args $ [] 'Number $ :: 'Fn + {} (:return 'Unit) + :args $ [] + :features $ #{} :js-ffi + 'local-month-day! $ %{} 'CodeEntry (:doc "|读取 Node 宿主本地时区的月份与日期。") + :code $ quote $ defn local-month-day! () + let + date $ unsafe-coerce (new js/Date) LocalDateHost + {} + :month $ inc $ date .get-month + :day $ date .get-date + :examples $ [] + :schema $ :: 'Fn $ {} + :args $ [] + :features $ #{} :js-ffi + :return $ :: 'Map 'Tag 'Number + 'on-interrupt! $ %{} 'CodeEntry (:doc "|将 SIGINT 交给业务退出回调,而不依赖 native CLI 注入。") + :code $ quote $ defn on-interrupt! (callback) + let + process $ unsafe-coerce js/process NodeProcessHost + process .on |SIGINT callback + , &unit + :examples $ [] + :schema $ :: 'Fn $ {} (:return 'Unit) + :args $ [] $ :: 'Fn + {} (:return 'Unit) + :args $ [] + :features $ #{} :js-ffi + :ns $ %{} 'NsEntry (:doc |) + :code $ quote $ ns cumulo-reel.app.server-host + :require $ js-ffi.node :refer $ file-exists? read-text! write-text! mkdir! path-dirname + 'cumulo-reel.app.server-ws $ %{} 'FileEntry + :defs $ {} + '*clients $ %{} 'CodeEntry (:doc |) + :code $ quote $ defatom *clients ({}) + :examples $ [] + :schema $ :: 'Ref $ :: 'Map 'Number 'ws-edn.server/NodeWebSocketHost + '*next-sid $ %{} 'CodeEntry (:doc |) + :code $ quote $ defatom *next-sid 0 + :examples $ [] + :schema $ :: 'Ref 'Number + 'SocketEvent $ %{} 'CodeEntry (:doc "|适配器向业务层报告的连接、原始文本消息、断线和二进制通知。") + :code $ quote $ defenum SocketEvent (:connect 'Number) (:message 'Number 'String) (:disconnect 'Number) (:blob 'Number) + :examples $ [] + :schema $ :: 'Enum + 'close-session! $ %{} 'CodeEntry (:doc "|连接关闭或错误只通知业务层一次断线。") + :code $ quote $ defn close-session! (sid on-event) + match (get @*clients sid) + (:some _) + do (swap! *clients dissoc sid) + on-event $ SocketEvent :disconnect sid + (:none) &unit + , &unit + :examples $ [] + :schema $ :: 'Fn $ {} (:return 'Unit) + :args $ [] 'Number $ :: 'Fn + {} (:return 'Unit) + :args $ [] 'cumulo-reel.app.server-ws/SocketEvent + :tests $ [] $ %{} 'TestEntry (:name |ignores-unknown-session) + :code $ quote $ let + calls $ atom 0 + reset! *clients $ {} + close-session! 12 $ fn (event) (swap! calls inc) &unit + assert= 0 @calls + 'each! $ %{} 'CodeEntry (:doc "|只遍历当前仍连接的 Number session ID。") + :code $ quote $ defn each! (handler) + each (keys @*clients) handler + , &unit + :examples $ [] + :schema $ :: 'Fn $ {} (:return 'Unit) + :args $ [] $ :: 'Fn + {} (:return 'Unit) + :args $ [] 'Number + :tests $ [] $ %{} 'TestEntry (:name |skips-disconnected-sessions) + :code $ quote $ let + seen $ atom $ [] + reset! *clients $ {} + each! $ fn (sid) (swap! seen conj sid) &unit + assert= ([]) @seen + 'send! $ %{} 'CodeEntry (:doc "|按 Number session ID 发送原始 Cirru EDN 文本。") + :code $ quote $ defn send! (sid message) + match + get + assert-type @*clients $ :: 'Map 'Number 'ws-edn.server/NodeWebSocketHost + , sid + (:some socket) + let + socket $ assert-type socket 'ws-edn.server/NodeWebSocketHost + socket .send message + (:none) (eprintln |WebSocket-client-missing: sid) + , &unit + :examples $ [] + :schema $ :: 'Fn $ {} (:return 'Unit) + :args $ [] 'Number 'String + :features $ #{} :js-ffi + 'serve! $ %{} 'CodeEntry + :doc "|启动 Node WebSocket server,并把宿主事件转为保持原有 SID 语义的 SocketEvent。" + :code $ quote $ defn serve! (port on-event) + let + server $ unsafe-coerce + new WebSocketServer $ &js-object :port port + , 'ws-edn.server/NodeWebSocketServerHost + server .on |connection $ fn (raw-socket _request) + let + socket $ unsafe-coerce raw-socket 'ws-edn.server/NodeWebSocketHost + sid $ inc @*next-sid + reset! *next-sid sid + swap! *clients assoc sid socket + on-event $ SocketEvent :connect sid + socket .on |message $ fn (raw-data binary?) + let + is-binary? $ contract/expect-bool |ws-message-is-binary $ binary? + if is-binary? + on-event $ SocketEvent :blob sid + on-event $ SocketEvent :message sid $ node-data-string raw-data + , &unit + socket .on |close $ fn (_code _reason) (close-session! sid on-event) &unit + socket .on |error $ fn (error) (eprintln |WebSocket-client-error: error) (close-session! sid on-event) &unit + , &unit + , &unit + server .on |error $ fn (error) (eprintln |WebSocket-server-error: error) &unit + , server + :examples $ [] + :schema $ :: 'Fn $ {} (:return 'ws-edn.server/NodeWebSocketServerHost) + :args $ [] 'Number $ :: 'Fn + {} (:return 'Unit) + :args $ [] 'cumulo-reel.app.server-ws/SocketEvent + :features $ #{} :js-ffi + :ns $ %{} 'NsEntry (:doc |) + :code $ quote $ ns cumulo-reel.app.server-ws + :require + |ws :refer $ WebSocketServer + ws-edn.server :refer $ node-data-string + js-ffi.contract :as contract 'cumulo-reel.app.twig.container $ %{} 'FileEntry :defs $ {} + 'rand-hex-color! $ %{} 'CodeEntry (:doc |) + :code $ quote $ defn rand-hex-color! () + contract/expect-string |randomcolor $ randomcolor-host + :examples $ [] + :schema $ :: 'Fn $ {} (:return 'String) + :args $ [] + :features $ #{} :js-ffi 'twig-container $ %{} 'CodeEntry (:doc |) :code $ quote $ defn twig-container (db session records) assert-type @@ -655,9 +850,10 @@ :code $ quote $ ns cumulo-reel.app.twig.container :require cumulo-reel.app.twig.user :refer $ twig-user - calcit.std.rand :refer $ rand-hex-color! recollect.memo :refer $ memo-twig-by1 memo-twig-by2 cumulo-reel.schema :as schema + |randomcolor :default randomcolor-host + js-ffi.contract :as contract 'cumulo-reel.app.twig.user $ %{} 'FileEntry :defs $ {} $ 'twig-user %{} 'CodeEntry (:doc |) @@ -797,6 +993,13 @@ :examples $ [] :schema $ :: 'Fn $ {} (:return 'cumulo-reel.schema/Database) :args $ [] 'cumulo-reel.schema/Database 'Number 'String 'Number + 'md5 $ %{} 'CodeEntry (:doc |) + :code $ quote $ defn md5 (text) + contract/expect-string |md5 $ md5-host text + :examples $ [] + :schema $ :: 'Fn $ {} (:return 'String) + :args $ [] 'String + :features $ #{} :js-ffi 'sign-up $ %{} 'CodeEntry (:doc |) :code $ quote $ defn sign-up (db username password sid op-id op-time) let @@ -831,9 +1034,7 @@ :args $ [] 'cumulo-reel.schema/Database 'String 'String 'Number 'String 'Number :ns $ %{} 'NsEntry (:doc |) :code $ quote $ ns cumulo-reel.app.updater.user - :require - calcit.std.hash :refer $ md5 - cumulo-reel.schema :as schema + :require (cumulo-reel.schema :as schema) (|md5 :default md5-host) (js-ffi.contract :as contract) 'cumulo-reel.comp.reel $ %{} 'FileEntry :defs $ {} 'comp-reel $ %{} 'CodeEntry (:doc |) diff --git a/config/calcit-quality.cirru b/config/calcit-quality.cirru index 6fd72de..3966ccf 100644 --- a/config/calcit-quality.cirru +++ b/config/calcit-quality.cirru @@ -90,6 +90,33 @@ :typeNotFull 0 :unresolved 4 :unsafeCoerce 0 + |cumulo-reel.app.server-host/local-month-day! $ {} (:codeDynamic 0) + :codeNil 0 + :declaredOptional 0 + :deprecatedCalls 0 + :schemaDynamic 0 + :typeNone 0 + :typeNotFull 0 + :unresolved 0 + :unsafeCoerce 1 + |cumulo-reel.app.server-host/on-interrupt! $ {} (:codeDynamic 0) + :codeNil 0 + :declaredOptional 0 + :deprecatedCalls 0 + :schemaDynamic 0 + :typeNone 0 + :typeNotFull 0 + :unresolved 0 + :unsafeCoerce 1 + |cumulo-reel.app.server-ws/serve! $ {} (:codeDynamic 0) + :codeNil 0 + :declaredOptional 0 + :deprecatedCalls 0 + :schemaDynamic 0 + :typeNone 0 + :typeNotFull 0 + :unresolved 0 + :unsafeCoerce 2 |cumulo-reel.app.server/sync-clients! $ {} (:codeDynamic 0) :codeNil 1 :declaredOptional 0 @@ -215,7 +242,7 @@ :typeNone 0 :typeNotFull 11 :unresolved 42 - :unsafeCoerce 0 + :unsafeCoerce 4 :scope $ {} (:includeDependencies false) :namespace nil :namespacePrefix nil diff --git a/deps.cirru b/deps.cirru index 8a342c6..3e79429 100644 --- a/deps.cirru +++ b/deps.cirru @@ -1,11 +1,10 @@ -{} (:calcit-version |0.19.1) +{} (:calcit-version |0.20.0) :version |0.0.45 :dependencies $ {} (|Cumulo/cumulo-util.calcit |0.0.23) |Respo/respo-message.calcit |0.0.28 |Respo/respo-ui.calcit |0.7.31 |Respo/respo.calcit |0.16.113 - |calcit-lang/calcit-wss |0.2.32 - |calcit-lang/calcit.std |0.2.35 + |calcit-lang/js-ffi |0.1.40 |calcit-lang/recollect |0.0.53 |mvc-works/ws-edn.calcit |0.0.31 diff --git a/docs/architectures/js-server-host.cirru b/docs/architectures/js-server-host.cirru new file mode 100644 index 0000000..0a32445 --- /dev/null +++ b/docs/architectures/js-server-host.cirru @@ -0,0 +1,151 @@ +{} + :schema-version 1 + :feature 'js-server-host + :doc "|把 Cumulo Reel 的 server 宿主边界迁到 Node JS,保持 Number session ID、原始 Cirru EDN 消息、断线语义和本地日期备份命名。" + :roots $ #{} 'cumulo-reel.app.server/run-server! + :definitions $ {} + 'cumulo-reel.app.server/run-server! $ {} (:mode :external) (:kind :fn) + :schema $ :: 'Fn $ {} + :args $ [] 'Number + :return 'ws-edn.server/NodeWebSocketServerHost + :features $ #{} :js-ffi + 'cumulo-reel.app.server/get-backup-path! $ {} (:mode :external) (:kind :fn) + :schema $ :: 'Fn $ {} + :args $ [] + :return 'String + 'cumulo-reel.app.server/persist-db! $ {} (:mode :external) (:kind :fn) + :schema $ :: 'Fn $ {} + :args $ [] + :return 'Unit + 'cumulo-reel.app.server/main! $ {} (:mode :external) (:kind :fn) + :schema $ :: 'Fn $ {} + :args $ [] + :return 'Unit + :features $ #{} :js-ffi + 'cumulo-reel.app.server-ws/SocketEvent $ {} + :mode :ensure + :kind :data + :doc "|适配器向业务层报告的连接、原始文本消息、断线和二进制通知。" + :schema $ :: 'Enum + :code $ quote $ defenum SocketEvent (:connect 'Number) (:message 'Number 'String) (:disconnect 'Number) (:blob 'Number) + 'cumulo-reel.app.server-ws/serve! $ {} + :mode :ensure + :kind :fn + :doc "|启动 Node WebSocket server,并把宿主事件转为保持原有 SID 语义的 SocketEvent。" + :params $ [] 'port 'on-event + :schema $ :: 'Fn $ {} + :args $ [] 'Number $ :: 'Fn $ {} + :args $ [] 'cumulo-reel.app.server-ws/SocketEvent + :return 'Unit + :return 'ws-edn.server/NodeWebSocketServerHost + :features $ #{} :js-ffi + 'cumulo-reel.app.server-ws/send! $ {} + :mode :ensure + :kind :fn + :doc "|按 Number session ID 发送原始 Cirru EDN 文本。" + :params $ [] 'sid 'message + :schema $ :: 'Fn $ {} + :args $ [] 'Number 'String + :return 'Unit + :features $ #{} :js-ffi + 'cumulo-reel.app.server-ws/close-session! $ {} + :mode :ensure + :kind :fn + :doc "|连接关闭或错误只通知业务层一次断线。" + :params $ [] 'sid 'on-event + :schema $ :: 'Fn $ {} + :args $ [] 'Number $ :: 'Fn $ {} + :args $ [] 'cumulo-reel.app.server-ws/SocketEvent + :return 'Unit + :return 'Unit + 'cumulo-reel.app.server-ws/each! $ {} + :mode :ensure + :kind :fn + :doc "|只遍历当前仍连接的 Number session ID。" + :params $ [] 'handler + :schema $ :: 'Fn $ {} + :args $ [] $ :: 'Fn $ {} + :args $ [] 'Number + :return 'Unit + :return 'Unit + 'cumulo-reel.app.server-host/LocalDateHost $ {} + :mode :ensure + :kind :data + :doc "|仅暴露备份命名所需的宿主本地月、日读取。" + :schema $ :: 'Trait + :code $ quote $ deftrait LocalDateHost + .get-month $ :: 'Fn $ {} + :args $ [] 'cumulo-reel.app.server-host/LocalDateHost + :return 'Number + .get-date $ :: 'Fn $ {} + :args $ [] 'cumulo-reel.app.server-host/LocalDateHost + :return 'Number + 'cumulo-reel.app.server-host/NodeProcessHost $ {} + :mode :ensure + :kind :data + :doc "|仅用于订阅 Node 的 SIGINT 信号。" + :schema $ :: 'Trait + :code $ quote $ deftrait NodeProcessHost + .on $ :: 'Fn $ {} + :args $ [] 'cumulo-reel.app.server-host/NodeProcessHost 'String $ :: 'Fn $ {} + :args $ [] + :return 'Unit + :return 'cumulo-reel.app.server-host/NodeProcessHost + 'cumulo-reel.app.server-host/local-month-day! $ {} + :mode :ensure + :kind :fn + :doc "|读取 Node 宿主本地时区的月份与日期。" + :params $ [] + :schema $ :: 'Fn $ {} + :args $ [] + :return $ :: 'Map 'Tag 'Number + :features $ #{} :js-ffi + 'cumulo-reel.app.server-host/check-write-text! $ {} + :mode :ensure + :kind :fn + :doc "|内容未变时跳过写入,按需建立一级备份目录。" + :params $ [] 'path 'content + :schema $ :: 'Fn $ {} + :args $ [] 'String 'String + :return 'Unit + :features $ #{} :js-ffi + 'cumulo-reel.app.server-host/ensure-directory! $ {} + :mode :ensure + :kind :fn + :doc "|递归建立缺少的父目录,避免备份月份目录不存在时写入失败。" + :params $ [] 'directory + :schema $ :: 'Fn $ {} + :args $ [] 'String + :return 'Unit + :features $ #{} :js-ffi + 'cumulo-reel.app.server-host/every! $ {} + :mode :ensure + :kind :fn + :doc "|由 Node 宿主重复调度 Unit 回调。" + :params $ [] 'delay 'callback + :schema $ :: 'Fn $ {} + :args $ [] 'Number $ :: 'Fn $ {} + :args $ [] + :return 'Unit + :return 'Unit + :features $ #{} :js-ffi + 'cumulo-reel.app.server-host/on-interrupt! $ {} + :mode :ensure + :kind :fn + :doc "|将 SIGINT 交给业务退出回调,而不依赖 native CLI 注入。" + :params $ [] 'callback + :schema $ :: 'Fn $ {} + :args $ [] $ :: 'Fn $ {} + :args $ [] + :return 'Unit + :return 'Unit + :features $ #{} :js-ffi + :edges $ #{} + :: :call 'cumulo-reel.app.server/run-server! 'cumulo-reel.app.server-ws/serve! + :: :type 'cumulo-reel.app.server-ws/serve! 'cumulo-reel.app.server-ws/SocketEvent + :: :call 'cumulo-reel.app.server-ws/serve! 'cumulo-reel.app.server-ws/close-session! + :: :call 'cumulo-reel.app.server/get-backup-path! 'cumulo-reel.app.server-host/local-month-day! + :: :call 'cumulo-reel.app.server/persist-db! 'cumulo-reel.app.server-host/check-write-text! + :: :call 'cumulo-reel.app.server-host/check-write-text! 'cumulo-reel.app.server-host/ensure-directory! + :: :call 'cumulo-reel.app.server/main! 'cumulo-reel.app.server-host/on-interrupt! + :: :type 'cumulo-reel.app.server-host/on-interrupt! 'cumulo-reel.app.server-host/NodeProcessHost diff --git a/docs/js-server-migration.md b/docs/js-server-migration.md new file mode 100644 index 0000000..4f9b944 --- /dev/null +++ b/docs/js-server-migration.md @@ -0,0 +1,24 @@ +# Cumulo Reel 的 Node JS server 迁移 + +服务端入口使用 Calcit 的 `:server` JS 模式。业务层继续使用数字 session ID、原始 Cirru EDN 消息和 `:connect`/`:message`/`:disconnect`/`:blob` 事件;WebSocket、文件、定时器和本地日期只在宿主适配层接触 Node API。密码 MD5 与随机颜色保留原调用语义,分别由现有 npm 包提供。 + +`cumulo-reel.app.server-ws/serve!` 只负责传输,不提前解析消息。业务层仍通过 `try-parse-cirru-edn-as` 验证客户端操作,并按原逻辑返回错误。连接关闭与错误复用 `close-session!`,只发送一次断线事件。数字 ID 从 1 起分配,与原 native WebSocket 服务一致。 + +持久化仍采用 Cirru EDN。`check-write-text!` 在内容相同时跳过写入,并在需要时建立备份目录;备份路径沿用宿主本地月份和日期,格式为 `backups//-snapshot.cirru`。服务端时间戳仍是毫秒。 + +质量基线仅增加四个宿主对象断言:一个 `Date` 到 `LocalDateHost`,一个 `process` 到 `NodeProcessHost`,以及 WebSocket server 与 socket 到对应的 host trait。外部 npm 返回值用 `expect-string` 检查,消息中的布尔值用 `expect-bool` 检查,不再增加 `unsafe-coerce`。这些断言应保持在适配层,不能向业务定义扩散。退出信号由 `process.on('SIGINT')` 注册,不再使用 native CLI 的 `on-control-c`。 + +验收命令: + +```bash +caps --calcit-version 0.20.0 --ci +corepack yarn install --immutable +calcit calcit.cirru --check-only +calcit --entry server calcit.cirru --check-only +calcit calcit.cirru test +calcit --entry server calcit.cirru analyze quality --baseline config/calcit-quality.cirru +calcit --entry server calcit.cirru js +node --check js-out/cumulo-reel.app.server.mjs +``` + +静态验收之后,还必须在 Node 进程中验证连接、数字 ID、文本与二进制消息、断线、EDN 非法输入、重连、广播、持久化和备份路径。`node --check` 只能证明生成文件的语法,不代表服务端已通过运行验收。 diff --git a/package.json b/package.json index 1c01016..7af195e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,10 @@ { "version": "0.0.45", "dependencies": { - "@calcit/procs": "0.19.1" + "@calcit/procs": "0.20.0", + "md5": "^2.3.0", + "randomcolor": "^0.6.2", + "ws": "^8.13.0" }, "scripts": { "check-quality": "calcit calcit.cirru analyze quality --baseline config/calcit-quality.cirru", @@ -11,11 +14,8 @@ "watch-page": "calcit calcit.cirru js -w" }, "devDependencies": { - "md5": "^2.3.0", - "randomcolor": "^0.6.2", "shortid": "^2.2.16", - "vite": "^8.2.2", - "ws": "^8.13.0" + "vite": "^8.2.2" }, "packageManager": "yarn@4.18.0" } From 02f5745caed71f2ad09e73effea5ecb627b2a2fc Mon Sep 17 00:00:00 2001 From: tiye Date: Thu, 24 Sep 2026 14:05:00 +0800 Subject: [PATCH 2/8] Keep strict js-ffi resolution for Cumulo migration --- deps.cirru | 2 +- docs/js-server-migration.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/deps.cirru b/deps.cirru index 3e79429..6aefe75 100644 --- a/deps.cirru +++ b/deps.cirru @@ -5,6 +5,6 @@ |Respo/respo-message.calcit |0.0.28 |Respo/respo-ui.calcit |0.7.31 |Respo/respo.calcit |0.16.113 - |calcit-lang/js-ffi |0.1.40 + |calcit-lang/js-ffi |0.1.36 |calcit-lang/recollect |0.0.53 |mvc-works/ws-edn.calcit |0.0.31 diff --git a/docs/js-server-migration.md b/docs/js-server-migration.md index 4f9b944..8997357 100644 --- a/docs/js-server-migration.md +++ b/docs/js-server-migration.md @@ -6,6 +6,8 @@ 持久化仍采用 Cirru EDN。`check-write-text!` 在内容相同时跳过写入,并在需要时建立备份目录;备份路径沿用宿主本地月份和日期,格式为 `backups//-snapshot.cirru`。服务端时间戳仍是毫秒。 +当前直接依赖 `js-ffi@0.1.36`,与现有上游模块的固定版本一致,保留 CI 的 `caps --strict --ci` 门禁。已确认迁移所需的 Node 文件、路径、时间及契约 API 在该版本存在;上游模块统一升级后再推进到更新的 `js-ffi` 版本。 + 质量基线仅增加四个宿主对象断言:一个 `Date` 到 `LocalDateHost`,一个 `process` 到 `NodeProcessHost`,以及 WebSocket server 与 socket 到对应的 host trait。外部 npm 返回值用 `expect-string` 检查,消息中的布尔值用 `expect-bool` 检查,不再增加 `unsafe-coerce`。这些断言应保持在适配层,不能向业务定义扩散。退出信号由 `process.on('SIGINT')` 注册,不再使用 native CLI 的 `on-control-c`。 验收命令: From fcba4ce8bbc7973cc7bb7aa0e2ea9dbb65950f49 Mon Sep 17 00:00:00 2001 From: tiye Date: Thu, 24 Sep 2026 14:07:16 +0800 Subject: [PATCH 3/8] Document strict dependency verification --- docs/js-server-migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/js-server-migration.md b/docs/js-server-migration.md index 8997357..2ff3f46 100644 --- a/docs/js-server-migration.md +++ b/docs/js-server-migration.md @@ -13,7 +13,7 @@ 验收命令: ```bash -caps --calcit-version 0.20.0 --ci +caps --calcit-version 0.20.0 --strict --ci corepack yarn install --immutable calcit calcit.cirru --check-only calcit --entry server calcit.cirru --check-only From 9968eebb94f221bdb2bb2da0f7ccfb28ac68ceb9 Mon Sep 17 00:00:00 2001 From: tiye Date: Thu, 24 Sep 2026 14:47:19 +0800 Subject: [PATCH 4/8] Align procs lockfile and narrowly preapprove Calcit 0.20.0 --- .yarnrc.yml | 2 ++ yarn.lock | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.yarnrc.yml b/.yarnrc.yml index 3186f3f..c6e0f6b 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1 +1,3 @@ nodeLinker: node-modules +npmPreapprovedPackages: + - "@calcit/procs@0.20.0" diff --git a/yarn.lock b/yarn.lock index 028d583..6bf9dd7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,14 +5,14 @@ __metadata: version: 10 cacheKey: 10c0 -"@calcit/procs@npm:0.19.1": - version: 0.19.1 - resolution: "@calcit/procs@npm:0.19.1" +"@calcit/procs@npm:0.20.0": + version: 0.20.0 + resolution: "@calcit/procs@npm:0.20.0" dependencies: "@calcit/ternary-tree": "npm:0.0.26" "@cirru/parser.ts": "npm:^0.0.9" "@cirru/writer.ts": "npm:^0.1.9" - checksum: 10c0/765534c4d0cb19bf652b1b6cadfc55d1774cfdbdb5f0588904da40c921b9e82d2f24dee8e9bcd8cb365537ffedd24cf8afdf06e796fe4015f04f5c0c9fa8c896 + checksum: 10c0/468d59cf4f0468e982b78dded471b93b46d29b05ead23bb4f7fbb560acacc15ce8e3e4e20abb91ebf533cbe90d07c18b2d725b2904bf6c9b9e99a1c81df97723 languageName: node linkType: hard @@ -864,7 +864,7 @@ __metadata: version: 0.0.0-use.local resolution: "root-workspace-0b6124@workspace:." dependencies: - "@calcit/procs": "npm:0.19.1" + "@calcit/procs": "npm:0.20.0" md5: "npm:^2.3.0" randomcolor: "npm:^0.6.2" shortid: "npm:^2.2.16" From 357e4ce071d348464c2824c588561bca51a7f0ce Mon Sep 17 00:00:00 2001 From: tiye Date: Thu, 24 Sep 2026 14:49:19 +0800 Subject: [PATCH 5/8] Check generated Node server syntax in CI --- .github/workflows/compile.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/compile.yaml b/.github/workflows/compile.yaml index d9c4e0c..f9f216d 100644 --- a/.github/workflows/compile.yaml +++ b/.github/workflows/compile.yaml @@ -56,3 +56,4 @@ jobs: calcit calcit.cirru js && yarn vite build && calcit calcit.cirru --entry server js + && node --check js-out/cumulo-reel.app.server.mjs From ab0ded20a90d87559b13b17efa5f4fef0ecf7221 Mon Sep 17 00:00:00 2001 From: tiye Date: Thu, 24 Sep 2026 15:13:53 +0800 Subject: [PATCH 6/8] Validate Node server import and WebSocket message boundary --- .github/workflows/compile.yaml | 1 + calcit.cirru | 11 ++++++----- package.json | 1 + yarn.lock | 10 ++++++++++ 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.github/workflows/compile.yaml b/.github/workflows/compile.yaml index f9f216d..590e58e 100644 --- a/.github/workflows/compile.yaml +++ b/.github/workflows/compile.yaml @@ -57,3 +57,4 @@ jobs: && yarn vite build && calcit calcit.cirru --entry server js && node --check js-out/cumulo-reel.app.server.mjs + && node --input-type=module -e 'await import("./js-out/cumulo-reel.app.server.mjs")' diff --git a/calcit.cirru b/calcit.cirru index 7d41cf4..4aa5673 100644 --- a/calcit.cirru +++ b/calcit.cirru @@ -445,9 +445,9 @@ do (println "|Found local EDN data") assert-type parse-cirru-edn (read-text! storage-file) - {} (:Database schema/database) (:Session schema/session) (:User schema/user) (:Router schema/router) + {} (:Database database) (:Session session) (:User user) (:Router router) 'cumulo-reel.schema/Database - do (println "|Found no data") schema/database + do (println "|Found no data") database :examples $ [] :schema $ :: 'Ref 'cumulo-reel.schema/Database '*reader-reel $ %{} 'CodeEntry (:doc |) @@ -569,7 +569,7 @@ session $ assert-type match (get db.:sessions sid) (:some found) found - (:none) schema/session + (:none) session , 'cumulo-reel.schema/Session old-store $ match (get @*client-caches sid) (:some cached) cached @@ -587,7 +587,8 @@ :args $ [] 'cumulo-reel.core/ReelState :ns $ %{} 'NsEntry (:doc |) :code $ quote $ ns cumulo-reel.app.server - :require (cumulo-reel.schema :as schema) + :require + cumulo-reel.schema :refer $ database session user router cumulo-reel.app.updater :refer $ updater cumulo-reel.core :refer $ reel-reducer refresh-reel reel-schema cumulo-reel.app.config :as config @@ -766,7 +767,7 @@ on-event $ SocketEvent :connect sid socket .on |message $ fn (raw-data binary?) let - is-binary? $ contract/expect-bool |ws-message-is-binary $ binary? + is-binary? $ contract/expect-bool |ws-message-is-binary binary? if is-binary? on-event $ SocketEvent :blob sid on-event $ SocketEvent :message sid $ node-data-string raw-data diff --git a/package.json b/package.json index 7af195e..e0e54e9 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "dependencies": { "@calcit/procs": "0.20.0", "md5": "^2.3.0", + "nanoid": "6.0.1", "randomcolor": "^0.6.2", "ws": "^8.13.0" }, diff --git a/yarn.lock b/yarn.lock index 6bf9dd7..af966f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -668,6 +668,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:6.0.1": + version: 6.0.1 + resolution: "nanoid@npm:6.0.1" + bin: + nanoid: bin/nanoid.js + checksum: 10c0/5b362ad9822388dfebdf655849c76d3539e074d1003fbe6eb5dff8687faf93a9abbb19bdc5d134b9b8599f875f9b091feacfd7099b34b5fef23bc3f5be41dd10 + languageName: node + linkType: hard + "nanoid@npm:^2.1.0": version: 2.1.11 resolution: "nanoid@npm:2.1.11" @@ -866,6 +875,7 @@ __metadata: dependencies: "@calcit/procs": "npm:0.20.0" md5: "npm:^2.3.0" + nanoid: "npm:6.0.1" randomcolor: "npm:^0.6.2" shortid: "npm:^2.2.16" vite: "npm:^8.2.2" From e3992dfa087899b6db01ac2c7db1636db2bc5d48 Mon Sep 17 00:00:00 2001 From: tiye Date: Thu, 24 Sep 2026 19:58:41 +0800 Subject: [PATCH 7/8] Verify Cumulo Node server runtime with ws-edn 0.0.32 --- .github/workflows/compile.yaml | 3 ++ deps.cirru | 2 +- scripts/server-runtime.test.mjs | 90 +++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 scripts/server-runtime.test.mjs diff --git a/.github/workflows/compile.yaml b/.github/workflows/compile.yaml index 590e58e..03fd7d7 100644 --- a/.github/workflows/compile.yaml +++ b/.github/workflows/compile.yaml @@ -58,3 +58,6 @@ jobs: && calcit calcit.cirru --entry server js && node --check js-out/cumulo-reel.app.server.mjs && node --input-type=module -e 'await import("./js-out/cumulo-reel.app.server.mjs")' + + - name: Exercise Node server runtime + run: node --test scripts/server-runtime.test.mjs diff --git a/deps.cirru b/deps.cirru index 6aefe75..7f906a1 100644 --- a/deps.cirru +++ b/deps.cirru @@ -7,4 +7,4 @@ |Respo/respo.calcit |0.16.113 |calcit-lang/js-ffi |0.1.36 |calcit-lang/recollect |0.0.53 - |mvc-works/ws-edn.calcit |0.0.31 + |mvc-works/ws-edn.calcit |0.0.32 diff --git a/scripts/server-runtime.test.mjs b/scripts/server-runtime.test.mjs new file mode 100644 index 0000000..4a41cbb --- /dev/null +++ b/scripts/server-runtime.test.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import WebSocket from 'ws'; + +import { + each_$x_ as eachClient, + send_$x_ as send, + serve_$x_ as serve, +} from '../js-out/cumulo-reel.app.server-ws.mjs'; + +async function waitFor(predicate, label) { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.fail(`Timed out waiting for ${label}`); +} + +test('WebSocket adapter handles text, binary, broadcast, disconnect, and reconnect', async () => { + const events = []; + const server = serve(0, (event) => events.push(String(event))); + const sockets = []; + try { + await once(server, 'listening'); + const url = `ws://127.0.0.1:${server.address().port}`; + const first = new WebSocket(url); + sockets.push(first); + const received = []; + first.on('message', (value) => received.push(String(value))); + await once(first, 'open'); + await waitFor(() => events.some((event) => event.includes(':connect 1)')), 'first connection'); + + first.send('hello'); + await waitFor(() => events.some((event) => event.includes(':message 1 |hello)')), 'text message'); + first.send(Buffer.from([1, 2, 3])); + await waitFor(() => events.some((event) => event.includes(':blob 1)')), 'binary message'); + + const active = []; + eachClient((sid) => { + active.push(sid); + send(sid, 'broadcast'); + }); + assert.deepEqual(active, [1]); + await waitFor(() => received.includes('broadcast'), 'broadcast'); + + first.close(); + await once(first, 'close'); + await waitFor(() => events.some((event) => event.includes(':disconnect 1)')), 'first disconnect'); + const afterClose = []; + eachClient((sid) => afterClose.push(sid)); + assert.deepEqual(afterClose, []); + + const second = new WebSocket(url); + sockets.push(second); + await once(second, 'open'); + await waitFor(() => events.some((event) => event.includes(':connect 2)')), 'reconnection'); + second.close(); + await once(second, 'close'); + await waitFor(() => events.some((event) => event.includes(':disconnect 2)')), 'second disconnect'); + assert.equal(events.filter((event) => event.includes(':disconnect 1)')).length, 1); + assert.equal(events.filter((event) => event.includes(':disconnect 2)')).length, 1); + } finally { + for (const socket of sockets) socket.terminate(); + await new Promise((resolve) => server.close(resolve)); + } +}); + +test('server persistence writes storage and a dated backup', async () => { + const originalCwd = process.cwd(); + const temporaryDir = await mkdtemp(join(tmpdir(), 'cumulo-reel-persist-')); + try { + process.chdir(temporaryDir); + const { persist_db_$x_: persist } = await import('../js-out/cumulo-reel.app.server.mjs'); + persist(); + const storage = await readFile('storage.cirru', 'utf8'); + assert.match(storage, /Database/); + const months = await readdir('backups'); + assert.equal(months.length, 1); + const files = await readdir(join('backups', months[0])); + assert.equal(files.length, 1); + assert.equal(await readFile(join('backups', months[0], files[0]), 'utf8'), storage); + } finally { + process.chdir(originalCwd); + await rm(temporaryDir, { recursive: true, force: true }); + } +}); From e08a84190b048de6b05f818285946d0aaad7e116 Mon Sep 17 00:00:00 2001 From: tiye Date: Thu, 24 Sep 2026 20:19:12 +0800 Subject: [PATCH 8/8] Exit on WebSocket server bind failure --- calcit.cirru | 2 +- scripts/server-runtime.test.mjs | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/calcit.cirru b/calcit.cirru index 4aa5673..bb4fcf6 100644 --- a/calcit.cirru +++ b/calcit.cirru @@ -776,7 +776,7 @@ socket .on |error $ fn (error) (eprintln |WebSocket-client-error: error) (close-session! sid on-event) &unit , &unit , &unit - server .on |error $ fn (error) (eprintln |WebSocket-server-error: error) &unit + server .on |error $ fn (error) (eprintln |WebSocket-server-error: error) (quit! 1) &unit , server :examples $ [] :schema $ :: 'Fn $ {} (:return 'ws-edn.server/NodeWebSocketServerHost) diff --git a/scripts/server-runtime.test.mjs b/scripts/server-runtime.test.mjs index 4a41cbb..a6e674e 100644 --- a/scripts/server-runtime.test.mjs +++ b/scripts/server-runtime.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; import { once } from 'node:events'; import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -69,6 +70,34 @@ test('WebSocket adapter handles text, binary, broadcast, disconnect, and reconne } }); +test('WebSocket bind failure exits with a nonzero status', async () => { + const adapterUrl = new URL('../js-out/cumulo-reel.app.server-ws.mjs', import.meta.url).href; + const source = ` + import { once } from 'node:events'; + import { serve_$x_ } from ${JSON.stringify(adapterUrl)}; + const first = serve_$x_(0, () => {}); + await once(first, 'listening'); + serve_$x_(first.address().port, () => {}); + `; + const result = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['--input-type=module', '-e', source], { + stdio: ['ignore', 'ignore', 'pipe'], + }); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('error', reject); + const timeout = setTimeout(() => child.kill('SIGKILL'), 5000); + child.on('close', (code, signal) => { + clearTimeout(timeout); + resolve({ code, signal, stderr }); + }); + }); + assert.equal(result.signal, null, result.stderr); + assert.equal(result.code, 1, result.stderr); + assert.match(result.stderr, /WebSocket-server-error:/); +}); + test('server persistence writes storage and a dated backup', async () => { const originalCwd = process.cwd(); const temporaryDir = await mkdtemp(join(tmpdir(), 'cumulo-reel-persist-'));