@claudewerk/bridge-substrate

中文文档
0.2.3发布于 2026/10/27MITTypeScriptES Module近 30 天下载 1816

The L2 substrate: named, durable coordination objects that live on both sides of a bun-reliable-ws connection. Composes bun-mq + bun-reliable-ws + envelope-codec + mutual-key-auth.

原始使用文档
文档目录20 节
  1. The four packages it composes
  2. Wiring
  3. Storage is pluggable (where durable state lives)
  4. Two entries: Bun and browser
  5. The asynchronous durable-store profile (relay + browser)
  6. Exactly-once across a forced disconnect (the money flow)
  7. Object model
  8. Verbs ( env.kind )
  9. The two acknowledgment levels
  10. Per-object sequencing + strict FIFO
  11. Durable outbox + dedupe (exactly-once)
  12. Futility / bounce laws
  13. Handshake (the full cross-layer tuple)
  14. The auth choice is one value, and it is required
  15. The authorize gate is required too, and for the same reason
  16. The end-to-end crypto seam
  17. Observability (pluggable, vendor-neutral)
  18. Layout
  19. What this proves
  20. Deferred (clean seams, NOT built)

bridge-substrate -- the L2 named-object engine (F1 CORE)

The keystone. It composes four proven, independently-built packages into the substrate: named, durable coordination objects that live on both sides of a connection -- "named queues that live on both sides." It is claudewerk- agnostic; it knows nothing about any broker. It is the engine a larger system (or a GATE) drives.

A Substrate sits on each side of one authenticated connection, owns named objects (F1: session + queue), and speaks the substrate verbs over the wire -- with per-object strict-FIFO sequencing, two acknowledgment levels, a durable outbox + dedupe for exactly-once, and the futility / authorized-finality laws.

bun install     # links the four packages via workspace:* (run at the monorepo root)
bun test        # 19 tests, all green (254 across the workspace)

# two-node CLI demo (see examples/README.md):
bun examples/client.ts --name C1 --port 7801
bun examples/client.ts --name C2 --port 7802 --connect 7801

There is a runnable, drive-it-by-hand two-node CLI (open / send / req / close / disconnect / connect) that visualizes the object lifecycle, the two ack levels, and exactly-once across a disconnect.

The four packages it composes

PackageRole in the substrate
bun-reliable-wsthe L1 carrier: binary wire, staged handshake, reconnect, WS delivery-acks, AuthContext with the full Negotiated tuple + connId + transcriptHash
envelope-codecthe frame codec (cborCodec): {env, body} -> CBOR bytes, the opaque body rides binary with zero base64
mutual-key-auththe AuthProvider (asAuthProvider): mutual challenge/response, role-absolute, cross-layer downgrade binding -- now zero-glue (see FINDINGS)
bun-mqthe durable per-object outbox + the dedupe ledger (receipt-fenced ack, StaleReceiptError, 7-day dedupe window)

Wiring

   SUBSTRATE A  (dialer)                               SUBSTRATE B  (listener)
   ─────────────────────                               ───────────────────────
   app: open / req / send / close                    open? -> accept/reject; req -> res
        │  ▲                                                        ▲   │
        ▼  │ answer (accept/res/end/err)   end-to-end, keyed to id  │   ▼
   ┌──────────┐  per-object durable outbox                  dedupe ledger  ┌──────────┐
   │  Outbox  │  (bun-mq, one seq authority)   (bun-mq (peer,id) >= 7d)    │ Sequencer│
   └──────────┘  at-most-one-un-acked-in-flight        in-seq delivery ────└──────────┘
        │  ▲                                                        ▲   │
        │  │  ◀──────────── ack (hop delivery-ack, receipt-fenced) ─┘   │
        ▼  │  ──────────── open/send/req/res/accept/reject/close ──────▶│

   Link:  bun-reliable-ws  ═══ ws (binary), may drop + reconnect ═══
   Codec: envelope-codec   frame = { env{v,kind,obj,objKind,id,seq,receipt}, body } -> CBOR
   Auth:  mutual-key-auth  challenge/response, bound to connId + full cross-layer tuple

The same stack + composition as a flow:

flowchart LR
  subgraph A["Substrate A · dialer"]
    AApp["app: open / req / send / close"]
    AOut["Outbox<br/>durable bun-mq · 1 seq authority<br/>at-most-1-unacked-in-flight"]
    ASeq["Sequencer<br/>in-seq · gap buffer · resync"]
    AApp --> AOut
    ASeq --> AApp
  end

  subgraph B["Substrate B · listener"]
    BApp["app: accept/reject · req→res"]
    BOut["Outbox"]
    BDed["Dedupe ledger<br/>(peer,id) ≥ 7d"]
    BSeq["Sequencer"]
    BApp --> BOut
    BDed --> BSeq
    BSeq --> BApp
  end

  AOut ==>|"open/send/req/res/accept/reject/close"| BDed
  BOut -.->|"ack · hop delivery-ack · receipt-fenced"| AOut
  BOut ==>|"answer · accept/res/end/err · keyed to id"| ASeq

  AOut <--> L
  BOut <--> L
  L["LINK<br/>bun-reliable-ws — binary ws, drop+reconnect<br/>envelope-codec — {env,body} → CBOR, zero base64<br/>mutual-key-auth — bound to connId + full cross-layer tuple"]

Storage is pluggable (where durable state lives)

Every peer's outbox, inbox, dedupe ledger and object registry go into one Store (bun-mq's persistence contract). Defaults need no configuration; a host that wants the state in ITS OWN database plugs a store in:

OptionWhere state lives
nothingmemory, not durable
dbPath / acceptor dbPathFor(peerId)a SQLite file the substrate opens and closes itself
storage / acceptor storageFor(peerId)a Store the host built; the substrate never closes it

Precedence on an acceptor: storageFor, then storage, then dbPathFor, then dbPath. Every durable key is scoped by peer id, so ONE store can serve every peer. To keep bridge state in the host's database, next to the host's tables and in the host's transactions:

import { createPeerAcceptor, sqliteStore } from "@claudewerk/bridge-substrate";

const bridgeStore = sqliteStore(appDb, { tablePrefix: "bridge_" }); // appDb opened { strict: true }
const peers = createPeerAcceptor({ peerId: "broker", auth, authorize, storageFor: () => bridgeStore });

A queue object accepted with acceptQueue() then keeps its durable inbox in the host's database: the host can count it, inspect it and manage it like the rest of its data. Releasing a peer leaves its rows in place for the host to keep or delete.

Two entries: Bun and browser

@claudewerk/bridge-substrate is the Bun entry (carrier factories, SQLite). @claudewerk/bridge-substrate/client is the browser entry: the same engine (SubstrateCore, also exported as Substrate), the object surface, outbox, receive path and dedupe, with no bun:sqlite, no carrier and no node:*. The Bun Substrate is that same class plus dbPath; there is no second copy of any logic. bun build --target=browser src/client.ts is a test.

The asynchronous durable-store profile (relay + browser)

For stores that can only be asynchronous (RFC 0005 §8.3). The synchronous profile above is unchanged; this is an addition with two forms.

Relay form: a stateless edge in front of a stream (RFC 0006 §9.1.1, §7.5). The edge holds no durable state; any replica can serve any session.

const edge = new SubstrateCore(link, {
  authorize, peerId: "edge", remotePeerId: browserId,
  relay: {
    sink: jetStreamSink(js, "GATE_IN"),      // store(op) -> Promise<{seq, duplicate?}>, idempotent by (peer, id)
    feed: jetStreamFeed(js, "GATE_EVENTS"),  // resume(target, fromSeq): push the object's stream from fromSeq
  },
});
  • Inbound: every accepted op goes to sink.store on a per-object serial chain; the ack ("stored") is sent only after it resolved. A failed store sends no ack and abandons the ops behind it; the browser resends. A replay is collapsed by the sink and re-acked. Objects and frontiers are adopted from the browser.
  • Outbound: each object's browser-bound direction is the stream. Frames carry the stream seq and prev (the previous frame's seq), so a lost frame is told apart from a gap; the browser resumes with resync{fromSeq} on any replica.
  • examples/jetstream-adapter.ts is the dependency-free shape of the JetStream adapter; memorySink / memoryFeed (src/memory-stream.ts) are the fakes.

Journal form: the browser outbox over IndexedDB (RFC 0006 §8.1.1).

import { createClientSubstrate, openIdbJournal } from "@claudewerk/bridge-substrate/client";

const journal = await openIdbJournal({ name: `bridge:${me}:${edgeId}` });
const s = await createClientSubstrate(link, { authorize, peerId: me, remotePeerId: edgeId, journal, resumeFeeds: true });

Every op is written to IndexedDB (awaited, one strict-durability transaction) before it can reach the wire, freed on its hop ack, and resent after a reload. A failed write (quota) rejects the send. Not supported here: acceptQueue.

Exactly-once across a forced disconnect (the money flow)

sequenceDiagram
  participant A as Substrate A (Outbox)
  participant Wire as ws link
  participant B as Substrate B (Dedupe→app)

  A->>A: enqueue send(id=M-1, seq=n) — durable, BEFORE the wire
  A->>Wire: send{id:M-1, receipt:r1}
  Wire->>B: send{id:M-1, receipt:r1}
  B->>B: admit(peer,M-1)=fresh → commit, deliver to app once
  Note over Wire: 💥 connection drops — ack lost
  B--xWire: ack{M-1, r1}  (never arrives)
  Wire-->>A: reconnect (new connId, same peer → rebind)
  A->>Wire: RESEND send{id:M-1, receipt:r1}  (same producer-stable id)
  Wire->>B: send{id:M-1}
  B->>B: admit(peer,M-1)=DUP → skip app (dedupe wins)
  B->>Wire: ack{M-1, r1}  (branchless re-ack)
  Wire->>A: ack{M-1, r1}
  A->>A: q.ack(r1) receipt-fenced → outbox row freed
  Note over A,B: delivered exactly once · FIFO intact

Object model

An object is a named coordination endpoint that exists on both sides:

  • Address -- an opaque token the substrate MATCHES, never parses. Charset [a-z0-9._/-]{1,128}; an @ is routing_not_supported. objKind (session | queue) is an explicit envelope field, not parsed from the name.
  • Lifecycle -- open -> accept / reject, then live, then close (or a terminal err). Both endpoints track the object's participants; a killing verb is honoured only from a verified participant.
  • Session -- supports req/res (end-to-end answer) and one-way send.
  • Queue -- the same machinery, send-oriented (one-way send + ack).

Verbs (env.kind)

VerbDirectionMeaning
open / accept / rejectopener ⇄ acceptorestablish or refuse an object
closeeither participantterminate an object (authorized finality)
send + ackproducer -> peerone-way message; ack is the hop delivery-ack
req / rescaller ⇄ calleerequest + end-to-end answer
errcallee / substrateterminal error (class derived by the receiver from the code)
endcalleeterminal end-of-answer
chunk / resync--chunk is an F2 seam; resync an internal control seam

The two acknowledgment levels

  1. Hop delivery-ack (ack, QoS-1, this connection). Sent by the receiver the moment an op is durably committed to the dedupe ledger. It frees the sender's outbox row, receipt-fenced via the echoed env.receipt. Lost to a dropped connection -> the sender resends the same frame on reconnect -> the receiver dedupes the redelivery and re-acks. This is what makes delivery exactly-once and what enforces strict FIFO (at most one un-acked op in flight per object).
  2. End-to-end answer (accept/reject/res/end/err), keyed to the producer-stable id, connection-agnostic -- it resolves the caller's promise whenever it can be delivered, across any number of reconnects.

Per-object sequencing + strict FIFO

Each object has a single seq authority per direction (the Outbox). Frames go out strictly one-un-acked-at-a-time, so with an ordered carrier the receiver's Sequencer sees them in order and passes through; a reorder/redelivery is buffered within [nextExpected, +window], a below-frontier seq is a duplicate (re-ack only), and a gap older than 30s asks for a bounded resync.

Durable outbox + dedupe (exactly-once)

Every open/send/req (and every answer) is durably queued before it hits the wire. id is a producer-stable idempotency key -- minted once, reused on every retry, never per-attempt. The receiver dedupes by (peer, id) for >= 7 days via bun-mq's persistent dedupe table (which outlives consume + restart), so dedupe wins over tombstone and a redelivery collapses even after the original was delivered.

Futility / bounce laws

  • (a) Receiver-derived error class -- there is no wire final flag; the receiver classifies a code as terminal or retryable (finality.ts).
  • (b) Authorized finality -- close/err that kills an object is honoured ONLY from a verified participant; an unauthorized finality frame is rejected before it can even perturb the seq frontier.
  • (c) Exactly one bounce -- a frame to an unknown object (or a bad address) yields exactly one terminal err, tombstoned per-op-id (not per name), so a later legit open of the same name still works.
  • (d) An err never begets an err -- an unroutable err is never bounced.
  • (e) Purge on terminal -- a terminal answer/bounce purges the sender's op and emits a bridge/futile event.

Handshake (the full cross-layer tuple)

The L1 stands up via bun-reliable-ws + cborCodec + asAuthProvider. The auth transcript binds the complete negotiated tuple -- version, features, the chosen name in every slot (auth, e2eSeal, e2eSign, codec), dedupeScope (carried as a negotiated feature token), and connId -- so downgrade binding is complete across every layer.

The auth choice is one value, and it is required

createDialerSubstrate / createListenerSubstrate take auth: () => AuthProvider or auth: "none". There is no default and no third option, so a deployment cannot arrive at an unauthenticated link by leaving something out; writing auth: "none" is as deliberate as SSH's Ciphers none, and it is logged at warn when it resolves. Both the advertised auth name-list and the wired provider are derived from that one value, so they cannot disagree.

The authorize gate is required too, and for the same reason

auth settles WHO the peer is. authorize settles WHAT it may do, and it is required on the same factories with no default, so authorization cannot end up off through an omission. It takes an interceptor: (ctx) => Denial | void, where returning nothing allows and deny(code) refuses.

import { createListenerSubstrate, scopeAuthorizer, compositeInterceptor, deny, ALLOW_ALL } from "@claudewerk/bridge-substrate";

createListenerSubstrate({
  peerId: "gate",
  // One key record per peer, chosen by the id the peer claimed in its hello.
  auth: substrateAuth({ myKey, peerRecordFor: (peer) => keyRecords.get(peer), method: AUTH_METHOD }),
  authorize: scopeAuthorizer({
    scopesOf: (peer) => keyRecords.get(peer)?.scopes,  // unknown peer -> refused
    matrix: { producer: ["open", "send", "close"], admin: ["*"] },
  }),
});

Where the gate sits is the whole value of it. Every inbound operation is offered to the chain after address and verb validation and before object lookup, the sequencer, the durable commit and the hop-ack -- so a denied peer creates no object here, does not move the receive frontier, and is never told its operation was accepted. An inbound stream establish passes the same chain and is refused with establish_fail{unauthorized} before a label is allocated; objKind: "stream" is not a way around the gate.

It is a CHAIN because observing is the common case: an interceptor that returns nothing allows, so tracing and audit are members alongside the policy, and the first denial stops the rest.

authorize: compositeInterceptor([
  (ctx) => void log.info("inbound", { peer: ctx.peer, verb: ctx.verb, obj: ctx.obj }),
  (ctx) => (ctx.obj.startsWith("admin/") ? deny("unauthorized") : undefined),
  scopeAuthorizer({ scopesOf, matrix }),
]);

The shipped policy is deny-by-default and calls mutual-key-auth's scopeAllows rather than restating the rule -- one definition, two callers. ALLOW_ALL is the named opt-out and gives back the pre-A1 posture: any authenticated peer may do anything on this connection. ctx.cap carries an opaque capability token the opener presented (open(kind, addr, { cap }) / openStream(addr, { cap })); the substrate ferries it and never parses it.

The end-to-end crypto seam

src/e2e.ts is what the e2eSeal and e2eSign slot names MEAN. One registry per slot holds the suites this build implements; a host advertises exactly those names and resolves a negotiated name back through the same map, so the list and the wiring cannot drift. A negotiated name with no implementation is refused at ready, never skipped.

The only suite shipped today is none, which is the honest answer for a point-to-point deployment: TLS already covers every hop, and the real suites exist for a relay that must carry what it cannot read. Adding one is two registry entries and no change to negotiation.

Observability (pluggable, vendor-neutral)

The substrate stays dependency-free but is instrumented. Four narrow concerns -- Logger / Metrics / Events / Tracer -- each with a null default (zero cost) and a composite (fan-out to N). Pass an observability bag to the host factories; heavy sinks (OTel, Prometheus) are adapters BEHIND the interfaces, never deps.

import { createListenerSubstrate, observe, inMemoryMetrics, ALLOW_ALL } from "@claudewerk/bridge-substrate";

const metrics = inMemoryMetrics();                       // snapshot() feeds a dashboard
createListenerSubstrate({ peerId: "host", authorize: ALLOW_ALL, auth: "none", observability: observe({ metrics }) });
// ... later:
metrics.snapshot().gauges.filter(g => g.name === "substrate.outbox.depth"); // per-object backlog

Default is NO_OBSERVABILITY -- uninstrumented is the norm and free. Emitted signals: substrate.ops.enqueued/acked, substrate.outbox.depth{obj} (per-object backlog), substrate.ack.latency_ms / substrate.answer.latency_ms, substrate.ingress.op{verb}, substrate.dedupe.duplicate, substrate.seq.deferred, substrate.authorize.denied{verb,objKind,code}, substrate.bounce, substrate.futile{code}, substrate.conn.ready/ended. Adapters: consoleLogger/Metrics/Events (debug), inMemoryMetrics (dashboards); an OTel adapter is the reserved next one. See src/observe.ts + src/observe-adapters.ts.

Layout

src/
  wire.ts        Env / Frame types, verbs, objKind, address validation
  observe.ts     pluggable observability: Logger/Metrics/Events/Tracer, null + composite
  observe-adapters.ts  console + in-memory sinks (OTel deferred)
  codec.ts       cborCodec as a WS Codec (zero glue)
  auth.ts        asAuthProvider as a WS AuthProvider (zero glue)
  host.ts        createDialer/ListenerSubstrate; capabilities; per-peer rebind
  link.ts        the structural Link surface (subset of ReliableConnection)
  substrate-core.ts  the engine: object registry, routing, the two ack levels, laws
                 (platform-neutral: no bun:, no node:, no carrier)
  substrate.ts   the Bun door: the same class plus `dbPath` (a SQLite file)
  client.ts      the browser entry: the engine and client surface, no Bun-only module
  async-sink.ts  async profile: the AsyncSink contract + the per-object commit chain
  feed.ts        relay outbound: external-seq feed (stream seq + prev), FeedHost
  memory-stream.ts  JetStream-shaped memory stream, memorySink, memoryFeed (fakes)
  journal.ts     journal form: DurableJournal, its serial writer, restore
  idb-journal.ts the browser outbox store over IndexedDB
  client-substrate.ts  createClientSubstrate: restore from the journal, then build
  object.ts      SubstrateObject: state machine + app-facing verbs
  outbox.ts      durable per-object outbox, strict-FIFO one-in-flight, receipt-fenced
  sequencer.ts   per-object in-seq delivery, gap buffer, out_of_seq, resync
  dedupe.ts      durable (peer,id) dedupe ledger over the bun-mq Store
  finality.ts    error-class derivation + per-op-id tombstones
test/
  handshake.test.ts    four packages compose; connId agrees on both ends
  session.test.ts      open+accept, req->res, send->ack, reject, close
  reliability.test.ts  exactly-once across a forced disconnect; strict FIFO under redelivery
  futility.test.ts     non-participant close rejected; unknown-object bounce+tombstone; bad address
  auth.test.ts         wrong key fails; tampered tuple breaks the proof
  primitives.test.ts   unit tests for outbox / sequencer / dedupe

What this proves

Over a real reliable-ws connection between two Substrate instances:

  • session open A->B (accept), req -> res, one-way send -> ack;
  • exactly-once across a forced disconnect + reconnect (resend + dedupe);
  • strict FIFO per object preserved under a redelivery;
  • a close/err from a non-participant is rejected (object stays alive);
  • a frame to an unknown object yields exactly one terminal err + a per-op-id tombstone (a later legit open of the same name still works);
  • auth/downgrade: a wrong key fails; a tampered negotiated tuple (connId / cross-layer set) breaks the direction proof.

Deferred (clean seams, NOT built)

F1 CORE stops here. Later phases have named seams in the code:

  • F2 bulk transfer -- the chunk verb + sendChunk on the carrier (declared, not wired).
  • F3 peer lifecycle -- active/unreachable/severing/terminated (the substrate is per-peer + rebindable, which is the hook).
  • F4 discovery + propose/consent/elicit -- new verbs on the same router.
  • F5 state@1 (Yjs) -- a third objKind on the same object machinery.

Sequencer state and tombstones are in-memory (the durable outbox + dedupe carry exactly-once across restart); durable sequencer/tombstone persistence is a later-phase seam. See FINDINGS.md for what composing revealed.

版本与发布记录

@claudewerk/bridge-substrate 共发布 5 个版本,首次发布于 2026/09/28;过去 12 个月发布了 5 个版本。

版本发布日期距今
0.2.3latest2026/10/2713 天前
0.2.22026/09/301 个月前
0.2.12026/09/281 个月前
0.2.02026/09/281 个月前
0.1.02026/09/281 个月前

关于 @claudewerk/bridge-substrate 的常见问题

@claudewerk/bridge-substrate 是什么?

The L2 substrate: named, durable coordination objects that live on both sides of a bun-reliable-ws connection. Composes bun-mq + bun-reliable-ws + envelope-codec + mutual-key-auth.

如何安装 @claudewerk/bridge-substrate?

在项目目录运行 npm install @claudewerk/bridge-substrate,或使用 pnpm add / yarn add / bun add。

@claudewerk/bridge-substrate 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@claudewerk/bridge-substrate 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@claudewerk/bridge-substrate 可以商用吗?

@claudewerk/bridge-substrate 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@claudewerk/bridge-substrate 的下载量有多少?

根据 npm 官方下载统计,@claudewerk/bridge-substrate 在 2026/10/10 – 2026/11/08 这 30 天内被下载 1,816 次,日均约 61 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@claudewerk/bridge-substrate 还在维护吗?

最新版本 0.2.3 发布于 2026/10/27(13 天前);至今共发布 5 个版本,其中过去 12 个月发布了 5 个;首次发布于 2026/09/28。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。