moonstomp

    Bounded incremental STOMP 1.0/1.1/1.2 codec and transport-independent client

    stomp
    messaging
    protocol
    Download zip
    Version
    0.3.1
    License
    MIT
    Last updated
    10 days ago
    Downloads
    142

    No dependencies

    #MoonSTOMP

    CI Fresh clone License: MIT

    用 MoonBit 写的 STOMP 客户端:无 I/O 的增量编解码 + 会话状态机,支持 STOMP 1.0 / 1.1 / 1.2,并已和 Apache Artemis、ActiveMQ Classic 两个真实 broker 互通。

    核心不碰 socket,只吃字节、吐字节和事件,所以能放进任何运行时;仓库另带一个 Node TCP/TLS 适配器、send / subscribe 命令行和一条命令就能跑的场景示例。

    0.3.1 修复心跳检查相位与调度余量;0.3.0仍保留旧行为。版本内容与发布核验条件及根因、取舍与本地验证。是否已发布以Mooncakes和对应PR回执为准,源码文档不能代替实际registry验证。

    #快速开始

    需要 MoonBit 工具链(CI 锁定 moonc v0.10.14+7d59c7ec9,见 toolchain.lock.json)和 Node.js(CI 用 24),不需要外部 broker。

    git clone https://github.com/sundaysebasidian-byte/moonstomp.git cd moonstomp moon test # 单元 / 属性 / 对抗测试 moon build --target js # 构建 Node 用的 bridge node tools/stomp-cli.cjs demo # 进程内 demo broker:发一条、收一条 node examples/demo/01-pubsub.cjs # 最后一行输出 PASS pubsub moon run examples/versions --target js # 1.0 / 1.1 / 1.2 协商与边界

    在自己的 MoonBit 项目里使用:

    moon add sundaysebasidian-byte/moonstomp@0.3.1

    0.2.1 修正 Client 的心跳接收宽限为 R + max(100ms,R),降低已观察到的空闲过渡误超时;公共 HeartbeatClock 默认值不变,故障检测可能较晚。真实 TLS 心跳及五分钟场景有独立回执,不能等同长期稳定性。 版本内容及验证边界见 0.2.1 说明;实际发布状态以 Mooncakes 为准。

    0.2.0 已发布到 Mooncakes。 Registry 实际版本、metadata 和发布 ZIP checksum 均已核对;全新 registry 消费者通过核心 API、多版本及模拟 TCP/TLS 验证。历史 0.1.2 仅支持 STOMP 1.2。精确发布 commit、CI、校验和与未验范围见0.2.0 回执。

    当前源码将Client、Decoder、HeartbeatClock字段收为抽象类型,依赖字段的外部代码需要迁移。因此版本从0.1.x提升为0.2.0,不覆盖旧0.1.2;升级前请读迁移说明。该历史说明固定于变更基线,发布状态以0.2.0发布回执为准。

    固定 master 636ef52… 的本地接入续验已覆盖干净消费者6/6、真实Classic TLS三版本、receipt丢失不重发及真实心跳关闭。精确源码、SDK、命令与未测范围见 当前源码接入回执;这与旧v5和已发布0.1.2分开报告。

    #用法

    #MoonBit:喂字节,拿事件

    let client = @stomp.Client::new()
    let bytes = client.send(@stomp.connect("localhost"), 0) // 写给 socket
    ignore(client.receive(b"CONNECTED\nversion:1.2\n\n\x00", 0))
    ignore(client.send(@stomp.subscribe("sub-1", "/queue/jobs", ack="client-individual"), 0))

    // 一个 MESSAGE 帧被 TCP 拆成两段到达
    let first = client.receive(b"MESSAGE\nsubscription:sub-1\nmessage-id:m1\ndestination:/queue/jobs\nack:a1\ncontent-len", 0)
    assert_eq(first.length(), 0) // 半包:还没有完整帧
    let events = client.receive(b"gth:5\n\nhello\x00", 0)
    match events[0] {
    @stomp.Message(frame) => assert_eq(frame.body, b"hello")
    _ => fail("expected a MESSAGE")
    }
    ignore(client.send(@stomp.acknowledge("a1"), 0))

    最后一个参数是调用方提供的单调毫秒时钟,心跳由 client.tick(now) 驱动。这段代码在 readme_test.mbt 中作为测试运行;更完整的离线流程(ACK/NACK、事务、DISCONNECT receipt)见 examples/offline/main.mbt。

    #Node:连真实 broker

    transport/tcp.cjs 把核心接到 net / tls,支持 login / passcode、证书校验始终开启的 TLS、Subscription / Transaction 句柄和带 code 的结构化错误。用法速查见 docs/guide.zh-CN.md。

    node tools/demo-broker.cjs --port 61613 & node tools/stomp-cli.cjs subscribe --port 61613 --destination /queue/jobs --count 1 & node tools/stomp-cli.cjs send --port 61613 --destination /queue/jobs --body hello # sent destination=/queue/jobs bytes=5 receipt=confirmed

    #场景示例

    每个都自带回环 demo broker,退出码 0 即通过,输出与 examples/demo/expected 逐字节一致。

    场景命令
    发布 / 订阅(queue 与 topic)node examples/demo/01-pubsub.cjs
    receipt 可靠发送node examples/demo/02-reliable-send.cjs
    手动 ACK / NACKnode examples/demo/03-ack-nack.cjs
    事务 COMMIT / ABORTnode examples/demo/04-transaction.cjs
    心跳与断线检测node examples/demo/05-heartbeat.cjs
    1.0 / 1.1 / 1.2 协商node examples/demo/06-versions.cjs
    认证与结构化错误node examples/demo/07-auth-errors.cjs
    有界异步批量发送(仅未发布开发源码)node examples/demo/08-async-send.cjs

    #规范覆盖(STOMP 1.2,兼容 1.0 / 1.1)

    规范要求支持
    全部客户端帧:CONNECT / STOMP、SEND、SUBSCRIBE、UNSUBSCRIBE、ACK、NACK、BEGIN、COMMIT、ABORT、DISCONNECT✅
    全部服务端帧:CONNECTED、MESSAGE、RECEIPT、ERROR✅
    头部转义 \\ \n \c \r(1.2),CONNECT / CONNECTED 不转义;1.0 原样、1.1 无 \r✅
    content-length 与 NUL 结尾,正文可含二进制 NUL✅
    增量(半包、粘包)解析,帧间 EOL✅
    版本协商 accept-version / version,越界版本拒绝✅
    心跳协商 heart-beat 与超时检测✅
    订阅 ack 模式 auto / client / client-individual,按版本区分 ACK 头✅
    receipt 跟踪、事务内的 SEND / ACK✅
    行长、头数、正文、单次事件数上限(可配置 Limits)✅

    #真实 broker 互通

    Broker已验证
    Apache Artemis 2.57.0(另以 go-stomp 为对端)1.0 / 1.1 / 1.2 收发、分段二进制正文、receipt、ACK / 累计 ACK / NACK、事务 SEND、双向心跳、真实 ERROR
    Apache ActiveMQ Classic 6.3.2login/passcode 认证、TLS、三版本二进制收发、版本化 ACK、事务 SEND 提交 / 中止、COMMIT 前断线不重放

    已知差异:Artemis 不回滚事务内的 ACK,NACK 表现为丢弃。复现命令(脚本只校验本地已有的官方发行包,不联网下载)和完整矩阵见 docs/broker-interop.zh-CN.md。

    #设计取舍

    • sans-I/O:核心只做协议,I/O、定时器、重连策略都留给宿主。同一套核心可接 Node、Wasm 或别的运行时。
    • 失败即关闭:协议错误、时钟倒退、超限都会让会话进入 Closed,不会带着坏状态继续收发。
    • 不自动重发:receipt 丢失时报告“结果未知”,由调用方决定,避免重复投递。
    • 不做:服务端、WebSocket、自动重连、持久化 outbox、exactly-once。RabbitMQ 尚未实测。

    详细边界见 docs/boundaries.zh-CN.md,公开接口见 pkg.generated.mbti。

    #测试

    • MoonBit 测试覆盖编解码、头部转义、心跳、会话状态、版本矩阵、属性测试和对抗输入;实际执行结果以对应提交的验收回执为准。
    • 与 stomp.js 7.2.0 解析器的差分测试(scripts/differential.cjs,按不同分段长度喂入),Node 适配器的生命周期 / TLS / 凭据测试。
    • CI:ci.yml 在官方 SDK 上跑完整验证(scripts/verify.py --transport);fresh-clone.yml 模拟新用户 git clone 后直接 moon test、本页快速开始和场景示例,并在最新 SDK 上同样跑一遍。本地新增异步示例尚无远端CI回执。

    本地完整验证:python3 scripts/verify.py --output ../moonstomp-verification。 各轮验证的命令、环境和结果见 docs/verification-20261022.zh-CN.md 与 evidence/。

    #目录

    路径内容
    frame.mbt headers.mbt decoder.mbt帧、头部转义、增量解码
    client.mbt heartbeat.mbt version.mbt api.mbt会话状态机、心跳、版本协商、帧构造函数
    bridge/MoonBit ↔ JS 边界
    transport/Node TCP / TLS 适配器与句柄
    tools/命令行与 demo broker
    examples/MoonBit 与 Node 示例

    #许可与来源

    代码 MIT(LICENSE)。参考测试文件 Apache-2.0,STOMP 规范 CC BY 3.0,来源与使用范围见 docs/reference.zh-CN.md。 开发中 AI 的参与方式见 AI_USAGE.md,技术方案见 Proposal.md。

    开发分支新增的 Node sendAsync 用法、限制与回执边界见异步 SEND;尚未发布到上面的 0.2.1 包。

    ProtocolError

    pub suberror ProtocolError {
    Invalid(String)
    } derive(
    Debug
    )

    Protocol errors are fatal for a decoder/connection.

    Client

    type Client

    A sans-I/O client. Caller writes returned bytes and calls fail on write failure.

    Client::end

    fn Client::end(self : Client) -> Unit raise ProtocolError

    EOF always closes the connection, and reports truncation if the decoder is mid-frame.

    Client::fail

    fn Client::fail(self : Client) -> Unit

    Client::new

    fn Client::new(capacity? : Int, max_pending? : Int) -> Client raise ProtocolError

    Client::pending_deliveries

    fn Client::pending_deliveries(self : Client) -> Int

    Client::pending_receipts

    fn Client::pending_receipts(self : Client) -> Int

    Client::receive

    fn Client::receive(self : Client, chunk : Bytes, now : Int64) -> Array[ClientEvent] raise ProtocolError

    Any network bytes update liveness, even an incomplete frame.

    Client::send

    fn Client::send(self : Client, f : Frame, now : Int64) -> Bytes raise ProtocolError

    Validates and commits local intent before returning wire bytes. Outbound API misuse does not poison the client; a transport write failure must close it.

    Client::state

    fn Client::state(self : Client) -> State

    Client::tick

    fn Client::tick(self : Client, now : Int64) -> Bytes raise ProtocolError

    Returns LF only when needed; timeout closes client and raises a protocol error.

    Client::version

    fn Client::version(self : Client) -> Version?

    None before negotiation; remains inspectable after close.

    ClientEvent

    pub enum ClientEvent {
    Connected
    Message(Frame)
    Receipt(String)
    ServerError(Frame)
    Heartbeat
    } derive(Eq,
    Debug
    )

    ClientEvent::equal

    fn ClientEvent::equal(ClientEvent, ClientEvent) -> Bool

    ClientEvent::not_equal

    fn ClientEvent::not_equal(x : ClientEvent, y : ClientEvent) -> Bool

    Decoder

    type Decoder

    Single pass across arbitrary chunks. Fatal errors poison the decoder.

    Decoder::end

    fn Decoder::end(self : Decoder) -> Unit raise ProtocolError

    Signal EOF: distinguishes a clean boundary from a truncated frame/CR heartbeat.

    Decoder::feed

    fn Decoder::feed(self : Decoder, chunk : Bytes) -> Array[WireEvent] raise ProtocolError

    Decoder::new

    fn Decoder::new(limits? : Limits, version? : Version) -> Decoder raise ProtocolError

    Frame

    pub struct Frame {
    command : String
    headers : Array[(String, String)]
    body : Bytes
    } derive(Eq,
    Debug
    )

    Ordered headers preserve repetition/history. header returns the first.

    Frame::equal

    fn Frame::equal(Frame, Frame) -> Bool

    Frame::header

    fn Frame::header(self : Frame, key : String) -> String?

    Frame::new

    fn Frame::new(command : String, headers : Array[(String, String)], body? : Bytes) -> Frame

    Frame::not_equal

    fn Frame::not_equal(x : Frame, y : Frame) -> Bool

    Frame::to_repr

    HeartbeatClock

    type HeartbeatClock

    Time is monotonic milliseconds supplied by the transport; no hidden wall clock.

    HeartbeatClock::new

    fn HeartbeatClock::new(plan : HeartbeatPlan, now : Int64, tolerance_ms? : Int64) -> HeartbeatClock raise ProtocolError

    HeartbeatClock::poll

    fn HeartbeatClock::poll(self : HeartbeatClock, now : Int64) -> (Bool, Bool) raise ProtocolError

    (send heartbeat, peer timed out); a due beat is marked sent only after write.

    HeartbeatClock::received_data

    fn HeartbeatClock::received_data(self : HeartbeatClock, now : Int64) -> Unit raise ProtocolError

    HeartbeatClock::sent_data

    fn HeartbeatClock::sent_data(self : HeartbeatClock, now : Int64) -> Unit raise ProtocolError

    HeartbeatPlan

    pub struct HeartbeatPlan {
    send_ms : Int
    receive_ms : Int
    } derive(Eq,
    Debug
    )

    HeartbeatPlan::equal

    HeartbeatPlan::not_equal

    fn HeartbeatPlan::not_equal(x : HeartbeatPlan, y : HeartbeatPlan) -> Bool

    Limits

    pub struct Limits {
    max_line : Int
    max_headers : Int
    max_body : Int
    max_events : Int
    } derive(
    Debug
    )

    Resource limits apply while data arrives, before allocating an entire frame.

    Limits::default

    fn Limits::default() -> Limits

    Limits::new

    fn Limits::new(max_line? : Int, max_headers? : Int, max_body? : Int, max_events? : Int) -> Limits raise ProtocolError

    Public configuration constructor; limits are bytes, counts and events per feed.

    Limits::to_repr

    State

    pub enum State {
    Fresh
    Connecting
    Active
    Disconnecting
    Closed
    } derive(Eq,
    Debug
    )

    State::equal

    fn State::equal(State, State) -> Bool

    State::not_equal

    fn State::not_equal(x : State, y : State) -> Bool

    State::to_repr

    Version

    pub(all) enum Version {
    V10
    V11
    V12
    } derive(Eq,
    Debug
    )

    Wire semantics; legacy constructors continue to default to V12.

    Version::equal

    fn Version::equal(Version, Version) -> Bool

    Version::not_equal

    fn Version::not_equal(x : Version, y : Version) -> Bool

    Version::to_repr

    Version::wire

    fn Version::wire(self : Version) -> String

    WireEvent

    pub enum WireEvent {
    Heartbeat
    Frame(Frame)
    } derive(Eq,
    Debug
    )

    WireEvent::equal

    fn WireEvent::equal(WireEvent, WireEvent) -> Bool

    WireEvent::not_equal

    fn WireEvent::not_equal(x : WireEvent, y : WireEvent) -> Bool

    acknowledge

    fn acknowledge(id : String, negative? : Bool, transaction? : String) -> Frame

    acknowledge_message

    fn acknowledge_message(version : Version, message : Frame, negative? : Bool, transaction? : String) -> Frame raise ProtocolError

    Caller supplies the original MESSAGE; formula-like reinterpretation of IDs is avoided.

    begin_transaction

    fn begin_transaction(id : String) -> Frame

    connect

    fn connect(host : String, heartbeat? : String, login? : String, passcode? : String) -> Frame

    Convenience constructors keep destinations opaque; broker semantics are external. Omitted credentials stay absent; explicit empty strings are retained. CONNECT values are literal. encode/send validates forbidden delimiters.

    connect_versions

    fn connect_versions(host : String, versions : Array[Version], heartbeat? : String, login? : String, passcode? : String) -> Frame raise ProtocolError

    Explicit negotiation while the original connect() remains a 1.2 constructor. Legacy 1.0-only CONNECT omits host/version/heartbeat negotiation headers.

    disconnect

    fn disconnect(receipt : String) -> Frame

    encode

    fn encode(frame : Frame) -> Bytes raise ProtocolError

    Emits LF and a content-length for every nonempty body (including binary NUL).

    encode_versioned

    fn encode_versioned(frame : Frame, version : Version) -> Bytes raise ProtocolError

    Emits LF in all versions. 1.0 headers are literal; 1.1 CR is data, never a \r escape.

    escape_header

    fn escape_header(value : String) -> String raise ProtocolError

    finish_transaction

    fn finish_transaction(id : String, commit? : Bool) -> Frame

    negotiate_heartbeat

    fn negotiate_heartbeat(cx : Int, cy : Int, sx : Int, sy : Int) -> HeartbeatPlan raise ProtocolError

    parse_version

    fn parse_version(value : String) -> Version raise ProtocolError

    send_message

    fn send_message(destination : String, body : Bytes, transaction? : String, receipt? : String) -> Frame

    subscribe

    fn subscribe(id : String, destination : String, ack? : String) -> Frame

    subscribe_versioned

    fn subscribe_versioned(version : Version, destination : String, id? : String, ack? : String) -> Frame raise ProtocolError

    1.0 accepts id-less subscriptions; 1.1/1.2 require an id.

    unescape_header

    fn unescape_header(value : String) -> String raise ProtocolError

    unsubscribe

    fn unsubscribe(id : String) -> Frame

    unsubscribe_destination

    fn unsubscribe_destination(destination : String) -> Frame

    For 1.0 only, unsubscribe by destination when no id was supplied.

    valid_command

    fn valid_command(command : String) -> Bool