Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/compile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,8 @@ jobs:
calcit calcit.cirru js
&& 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")'

- name: Exercise Node server runtime
run: node --test scripts/server-runtime.test.mjs
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@

.compact-inc.cirru
.calcit-error.cirru
.calcit/

js-out/
node_modules/
Expand Down
2 changes: 2 additions & 0 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
nodeLinker: node-modules
npmPreapprovedPackages:
- "@calcit/procs@0.20.0"
296 changes: 249 additions & 47 deletions calcit.cirru

Large diffs are not rendered by default.

29 changes: 28 additions & 1 deletion config/calcit-quality.cirru

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 3 additions & 4 deletions deps.cirru
Original file line number Diff line number Diff line change
@@ -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.36
|calcit-lang/recollect |0.0.53
|mvc-works/ws-edn.calcit |0.0.31
|mvc-works/ws-edn.calcit |0.0.32
151 changes: 151 additions & 0 deletions docs/architectures/js-server-host.cirru
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions docs/js-server-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# 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/<month>/<day>-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`。

验收命令:

```bash
caps --calcit-version 0.20.0 --strict --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` 只能证明生成文件的语法,不代表服务端已通过运行验收。
11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
{
"version": "0.0.45",
"dependencies": {
"@calcit/procs": "0.19.1"
"@calcit/procs": "0.20.0",
"md5": "^2.3.0",
"nanoid": "6.0.1",
"randomcolor": "^0.6.2",
"ws": "^8.13.0"
},
"scripts": {
"check-quality": "calcit calcit.cirru analyze quality --baseline config/calcit-quality.cirru",
Expand All @@ -11,11 +15,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"
}
90 changes: 90 additions & 0 deletions scripts/server-runtime.test.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
});
Loading
Loading