Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -41,6 +41,10 @@ jobs:
git diff --exit-code -- calcit.cirru
calcit calcit.cirru --check-only
calcit calcit.cirru --entry server --check-only
calcit calcit.cirru analyze check-public --ns ws-edn.client --ns ws-edn.schema --summary-only --format json \
| jq -e '.data.summary.complete and .data.summary.passed and (.data.summary.definitions_checked == .data.summary.definitions_selected)'
calcit calcit.cirru --entry server analyze check-public --ns ws-edn.server --ns ws-edn.schema --summary-only --format json \
| jq -e '.data.summary.complete and .data.summary.passed and (.data.summary.definitions_checked == .data.summary.definitions_selected)'
calcit calcit.cirru analyze dynamic-methods --format json | jq -e '.data.summary.findings == 0'
calcit calcit.cirru --entry server analyze dynamic-methods --format json | jq -e '.data.summary.findings == 0'
calcit calcit.cirru analyze quality --baseline config/calcit-quality.cirru
Expand All @@ -56,5 +60,6 @@ jobs:
yarn compile-page
yarn compile-server
node --test scripts/node-host-methods.test.mjs
node --test scripts/client-server-roundtrip.test.mjs
yarn check:client-generation
yarn vite build --base=./
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,15 +135,38 @@ Keep open protocol payloads as `Dynamic` only at the WebSocket/JS boundary and
decode them into a named Struct or Enum as soon as their shape is known. Do not
reintroduce the removed `calcit-test`, `lilac`, or `memof` modules.

The module snapshot targets Calcit 0.13.77. Its local `when-let` macro declares
The module snapshot targets Calcit 0.28.0. Its local `when-let` macro declares
an explicit syntax/expansion contract. `ws-send!` always returns `Unit`; the
method-oriented `.send` returns `WsSendOutcome` instead of leaking JavaScript
`WebSocket.send` results.

模块 Snapshot 已迁移到 Calcit 0.13.77;本地 `when-let` 明确声明 syntax 与
模块 Snapshot 已迁移到 Calcit 0.28.0;本地 `when-let` 明确声明 syntax 与
expansion。`ws-send!` 始终返回 `Unit`,方法形式 `.send` 返回
`WsSendOutcome`,不会把 JavaScript `WebSocket.send` 的返回值泄漏到公开 API。

### 0.28 类型边界迁移(尚未发布)

Calcit CLI 与 `@calcit/procs` 对齐到 0.28.0,JS-FFI 声明升级到
0.2.1-alpha.11。Node HTTP/WebSocket 事件与可选 callback 使用源码类型 `Fn`;
`DynFn` 是编译器内部名称,写成源码 symbol 会被当作未绑定的类型名称,不能代表
开放 callable。这个事件接口允许不同 arity 的函数,不承诺每个事件的 payload 已验证。
可选 callback 仍在运行时用 `fn?` 检查,Number、Tag 和缺失值都不会被注册。

旧 `%some/%none` 构造曾在 `reset! Option<Fn>` 写入处触发两条合同告警。
迁移为具名 Option 构造后,正式 0.28 的客户端新生成 JS 已通过
generation/lifecycle 回归,覆盖旧 generation 回调隔离、重试、heartbeat、
cleanup 与 singleton Ref。已有 `fn?` 证明及 Fn 参数合同消除了两处强制转换;
callback 保留开放 arity,没有缩窄为单一回调签名。

当前正式工具链通过 12 项附属测试、两个入口严格检查、客户端/共享公开定义
34/34、服务端/共享公开定义 23/23,以及两项 Node Buffer/Date 回归。
随机本地端口的真实双客户端网络回归确认 EDN 往返、不同 String session ID 和连接清理。
服务端调用 nanoid 后校验 String,不再把函数对象当作 session ID;四处缺省 callback
返回具名 None,避免事件处理把函数值当作 Enum。
质量基线通过,unsafeCoerce 从 30 降至 27,废弃调用为 0,预算未放宽。
本地链接现已固定到发布的 JS-FFI alpha.11 与 cumulo-util 0.0.23,未修改缓存。
完整 Caps 严格依赖解析仍应以远端 CI 为准;本地网络回归不覆盖 TLS 或真实浏览器页面。

Legacy class mapper (for compatibility with older payloads):

```cirru.no-check
Expand Down
244 changes: 172 additions & 72 deletions calcit.cirru

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions deps.cirru
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@

{} (:calcit-version |0.19.1)
:version |0.0.32
:dependencies $ {} (|Cumulo/cumulo-util.calcit |0.0.23)
|calcit-lang/js-ffi |0.1.36
{} (:calcit-version |0.28.0)
:version |0.0.33
:dependencies $ {} (|Cumulo/cumulo-util.calcit |0.0.24)
|calcit-lang/js-ffi |0.2.1-alpha.11
35 changes: 35 additions & 0 deletions editing-history/20261004-client-state-handle-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# 客户端状态句柄的类型边界

Diary 在正式 Calcit 0.28 下被 `assert-type client WsClient0` 阻挡:
工厂返回带 trait 的 WsClient,调用方却直接断言成原始结构。把所有内部
接收者改成 WsClient 会让固定 0.19.1 无法解析别名字段,因此不能采用。

保留内部函数的 WsClient0 字段契约,在生命周期入口和全局回调更新处
使用 `client-state-handle`:先检查 Struct、名义名称和完整字段集合,再
返回同一个句柄。没有复制状态或 Ref,没有新增 unsafe-coerce。这个检查
验证句柄布局;字段值仍由类型化工厂负责,不是任意外部数据的深层解码器。

工厂的五个空 Option 初始值在创建 atom 前明确 payload 类型。这样 Ref
从一开始就具有回调、计时器或心跳租约的正确类型,避免推导成
Ref<Option<Never>> 后再扩大可变引用类型。socket-factory 的宿主返回类型
改为完整命名空间引用。

原 `DynFn` 标注不是工具链认可的内建类型,改用内建 Fn;已有回调守卫和
转换位置保留。浏览器回调字段及回调 Option 采用同一个 callable 类型。

## 验证

- 从官方 0.19.1 tag 编译本机 CLI,并用该版本完成全部 Snapshot 编辑。
- 使用固定发布依赖图:页面和 server 严格检查、两个入口 JS 生成通过。
- 5 项 native 单元测试通过,包含非法值及其他名义结构拒绝。
- 重新生成页面 JS 后,客户端 generation / singleton / 重试 / heartbeat /
lifecycle 回归通过。新增断言验证句柄身份不变,非法句柄不会安装监听器
或计时器。
- 质量基线通过,预算未扩大;Node 24.19.0 下 Vite 前端构建通过。
- 接入独立 Diary 迁移快照后,候选 Calcit 0.29.0-alpha.1 的完整客户端
main! / reload! 严格检查和 JS 生成通过;新输出的初始、离线、登录页
SSR 内容检查通过。

正式 0.28 的 Diary 完整入口仍有 JS-FFI EventHost 回调及两个 add-watch
泛型回调警告;本修复不代表这些问题或浏览器实际交互已完成。版本 pin
保持原值,生成文件和临时报告不入库。
42 changes: 42 additions & 0 deletions history/20261004-client-option-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 修正客户端 Option callback 边界

这轮处理 Respo #194 下游 Cumulo Reel CI 暴露的 ws-edn 依赖阻塞。本地分支先前已经修正 `WsClient` / `WsClient0` 与 state handle 合同;客户端新产物还被两条 `reset! Option<Fn>` 告警阻止。

## 变更与证据

通过正式 Calcit CLI,将 23 个定义/测试的旧 Option/Result 构造迁移到具名构造。两个 reset 告警随之消失,没有改变 Ref、callback arity 或连接行为。

`client-option-callback` 在 `fn?` 之后直接返回 Fn;`ws-set-on-data!` 的参数本身已经声明为 Fn,因此去掉两处重复的 `unsafe-coerce`。新增测试确认缺失、Number、Tag 返回 None,以及零参数/一参数函数保留原函数值。

默认入口显式声明 browser、server 入口声明 node,让公开定义检查可以验证完整命名空间;CI 接入了带覆盖完整性断言的公开定义门禁。

新增真实网络回归首先复现了缺省 callback 返回 `%none` 函数而不是 Enum 的错误。四处缺省值改为具名 None,并按对应 callback 合同标注空 Option 的类型上下文。服务端 session ID 原先强转 nanoid 函数对象,现在调用 `new-session-id!` 生成并用 `string?` 校验实际返回值,消除该强转。

网络回归使用两个真实 WebSocket 客户端和随机 loopback 端口,验证不同非空 String ID、Unicode/嵌套列表/nil/匿名 Enum 的 EDN 往返,以及关闭后注册表清空;资源由测试 hook 清理。该回归已接入 CI。

## 正式 0.28 本地复核

- 客户端与服务端入口严格检查通过。
- 原生附属测试 12/12;其中 5 项为新 callback 合同测试。
- 客户端/共享公开定义 34/34,服务端/共享公开定义 23/23,均无诊断且 complete=true。
- 新生成客户端 JS 的 generation/lifecycle/singleton 回归通过。
- 新生成服务端 JS 的 Node Buffer/Date 回归 2/2;Node 24 Vite 生产构建通过。
- 真实客户端/服务端 WebSocket 回归 1/1,通过 EDN 往返、会话隔离与清理。
- 原质量基线通过,unsafeCoerce 27/30,deprecatedCalls 为 0;没有提高预算。

验证链接固定到发布缓存:JS-FFI alpha.11 (`799e707`,要求 Calcit 0.28 alpha.3) 和 cumulo-util 0.0.23 (`bf21934`);正式 Calcit 0.28 满足此工具链下限。未使用刚同步到 alpha.2 的 JS-FFI main,也未改写共享缓存。测试输出、生成 JS 和临时 JSON 都没有入库。

这些源码回归没有替代 Caps 严格依赖解析、TLS 或真实浏览器页面验证。下游消费的发布版本还需要合并、发布并更新清单;不据本地成功关闭 milestone。

## 严格依赖解析修复与发布准备

cumulo-util `0.0.24` 已发布,依赖清单改用该版本,与直接依赖的
JS-FFI `0.2.1-alpha.11` 一致。通过实际 `caps --strict --ci` 下载和
`caps verify --toolchain` 校验,不再使用原先的本地依赖链接。

使用正式 Calcit / procs `0.28.0`、Node 24,重新执行现有 CI 的全部源码门禁、
12 项原生测试、README 示例、两入口生成、Node host 测试、真实 WebSocket 往返、
客户端 generation/singleton 回归与 Vite 构建,均通过。原质量预算保持不变。

模块与 package 版本准备为 `0.0.33`,用于替代仍含旧客户端类型错误的已发布
`0.0.32`。当前没有创建 `0.0.33` 标签,下游应在发布后再更新版本。
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"packageManager": "yarn@4.18.0",
"dependencies": {
"@calcit/procs": "0.19.1",
"@calcit/procs": "0.28.0",
"nanoid": "^5.1.5"
},
"scripts": {
Expand All @@ -12,11 +12,12 @@
"watch-page": "calcit --emit-path out-page/ js -w",
"check:unit": "calcit test --tag unit --require-match --summary-only --format json",
"check:deprecated": "calcit analyze deprecated --summary-only --format json",
"check:client-generation": "node scripts/check-client-generation.mjs"
"check:client-generation": "node scripts/check-client-generation.mjs",
"check:network": "node --test scripts/client-server-roundtrip.test.mjs"
},
"devDependencies": {
"vite": "^8.0.2",
"ws": "^8.18.2"
},
"version": "0.0.32"
"version": "0.0.33"
}
9 changes: 9 additions & 0 deletions scripts/check-client-generation.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { CalcitMap, init_tags, invoke_method } from "@calcit/procs";
import {
client_state_handle,
create_client_with_$x_,
install_browser_lifecycle_$x_,
ws_connect_$x_,
Expand Down Expand Up @@ -74,6 +75,14 @@ const client = create_client_with_$x_("ws://example.test", options, (url) => {
});
const connected = () => invoke_method("connected?", client);

assert.strictEqual(client_state_handle(client), client);
for (const invalid of [null, 0, false, new CalcitMap()]) {
assert.throws(() => install_browser_lifecycle_$x_(invalid),
/\[ws-edn\] expected a WsClient state handle/);
}
assert.equal(listeners.size, 0);
assert.equal(intervals.size, 0);

assert.equal(sockets.length, 1);
assert.equal(connected(), false);
sockets[0].onopen({ generation: 1 });
Expand Down
64 changes: 64 additions & 0 deletions scripts/client-server-roundtrip.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { once } from "node:events";
import test from "node:test";
import * as c from "../out-page/calcit.core.mjs";
import * as client from "../out-page/ws-edn.client.mjs";
import * as server from "../js-out/ws-edn.server.mjs";

async function waitFor(predicate, label) {
const deadline = Date.now() + 5000;
while (!predicate()) {
assert.ok(Date.now() < deadline, `Timed out: ${label}`);
await new Promise(resolve => setTimeout(resolve, 10));
}
}

test("real clients preserve EDN payloads and distinct String session IDs", { timeout: 15000 }, async t => {
const tags = c.init_tags(["on-open", "on-data"]);
const ids = [];
const incoming = [];
const failures = [];
const clients = [];
const replies = [[], []];
const wss = server.wss_serve_$x_(0, c._$n__$M_(
tags["on-open"], sid => {
if (typeof sid !== "string") failures.push("session ID is not a String");
ids.push(sid);
},
tags["on-data"], (sid, data) => {
incoming.push([sid, data]);
server.wss_send_$x_(sid, data);
},
));
t.after(async () => {
for (const connection of clients) client.client_close_$x_(connection);
for (const socket of wss.clients) socket.terminate();
await new Promise(resolve => wss.close(resolve));
});
await once(wss, "listening", { signal: t.signal });
const url = `ws://127.0.0.1:${wss.address().port}`;
for (let index = 0; index < 2; index += 1) {
clients.push(client.create_client_with_$x_(url, c._$n__$M_(
tags["on-data"], data => { replies[index].push(data); },
), address => new WebSocket(address)));
}
await waitFor(() => ids.length === 2 && clients.every(connection => client.client_connected_$q_(connection)), "both connections");
assert.deepEqual(failures, []);
assert.notEqual(ids[0], ids[1]);
assert.ok(ids.every(sid => sid.length > 0));
const payloads = [
c.parse_cirru_edn("{} (:message |中文) (:items $ [] 1 |two) (:optional nil)"),
c.parse_cirru_edn(":: :reply $ {} (:count 2) (:ok true)"),
];
for (let index = 0; index < clients.length; index += 1) {
assert.equal(client.client_send(clients[index], payloads[index]).tag.value, "sent");
}
await waitFor(() => incoming.length === 2 && replies.every(values => values.length === 1), "EDN echoes");
for (let index = 0; index < clients.length; index += 1) {
assert.ok(c._$n__$e_(payloads[index], replies[index][0]));
}
assert.ok(incoming.every(([sid]) => typeof sid === "string" && ids.includes(sid)));
assert.equal(new Set(incoming.map(([sid]) => sid)).size, 2);
for (const connection of clients) client.client_close_$x_(connection);
await waitFor(() => c.count(c.deref(server._$s_global_connections)) === 0, "session cleanup");
});
28 changes: 18 additions & 10 deletions yarn.lock

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

Loading