ADR-0051: TypeScript SDK with one session core over swappable transports, a persistent clientId outbox resent after the replay, and hand-written types until the OpenAPI document exists
| Status | Accepted |
| Date | 2026-10-12 |
| Issue | #46 |
| Pull request | #142 |
| Related | ADR-0006, ADR-0024, ADR-0027, ADR-0029, ADR-0030 |
Context and problem statement
converger_js/ was a 50-line wrapper around the Phoenix Socket for the pre-v1 topic. It had no reconnect handling
of its own, no watermark, no acks, no token refresh, no types, no tests and no build, and it called console.log.
Protocol v1 (spec) gives clients what they need for zero data loss: per-conversation seq,
replay from a watermark, clientId de-duplication with ack, retryAfterMs on closes and errors, and mid-session
auth. The server ships three transports: the native endpoint, the Phoenix binding (activities still in the pre-v1
activitySet shape) and Server-Sent Events. Issue #46 asks for a headless TypeScript client that uses all of this. The
scope note on the issue keeps the widget in chativa and the bot side in @converger/agent (#67). The OpenAPI document
(#45) was being written at the same time and was not on main.
Decision drivers
- No message lost or stored twice across crashes, reconnects and reloads.
- One implementation of the session rules, whichever transport carries it.
- Runs in browsers, Node and React Native with no runtime dependencies (no
phoenix, nows). - Matches what the server really does today, not the planned parts of the spec.
- Types can move to generated ones (OpenAPI #45, JSON Schemas) without breaking the public API.
Considered options
- A session core plus transport adapters that normalise to v1 frames, with our own Phoenix V2 serializer
- Wrap the
phoenixnpm client, asconverger_jsdid, and add the protocol on top - The native endpoint only, with no Phoenix or SSE support
For the outbox:
- A. Persist, then resend every unacked send after
welcome - B. Persist, wait for the replay to reach the welcome head, settle sends whose turn shows up with their clientId, then resend the rest once
- C. In-memory only
Pros and cons of the options
Option 1
- Good, because reconnect, watermark, gap fill, outbox and token refresh exist once and are tested once against a mock server. A transport is a small adapter.
- Good, because without a
phoenixdependency the package has no runtime dependencies. Phoenix's V2 framing ([join_ref, ref, topic, event, payload], heartbeat onphoenix) is small. - Bad, because the Phoenix adapter has to translate
activitySetinto v1 frames and deriveseqfrom the set's watermark, since activities on that binding carry none. That is exact for owned conversations and best-effort for routed sockets whose middleware halts activities.
Option 2
- Good, because long-polling comes for free.
- Bad, because the
phoenixclient's own reconnect loop has no jitter by default and runsonCloseafter it has already scheduled the reconnect. It would fight the session's backoff andretryAfterMshandling. - Bad, because the native endpoint, the transport #26 made the primary one, would still need its own code.
Option 3
- Good, because it is the simplest.
- Bad, because it gives nothing to networks that block WebSockets, although the server has an SSE fallback.
Outbox A / B / C
- A is simple. A send stored just before the crash is resent; the server answers
duplicate: trueand nothing is stored twice, but the turn is sent twice. - B uses the replay that the reconnect causes anyway. The server includes the sender's
clientIdon its own replayed turns, so a send that was committed but never acked is settled from the replay and not resent. Each unacked send goes on the wire at most once per connection. It costs a short wait (until the head seq arrives, at most 5 s) before the resend. - C loses messages on reload, which the issue rules out.
Decision
Chosen: Option 1 with outbox B.
-
packages/client-ts(@converger/client) is a pnpm workspace package at the repository root (pnpm-workspace.yaml). It builds ESM and CJS with tsup, uses strict TypeScript, and has no runtime dependencies. -
Conversationis the session core. It tracks the watermark (the highest contiguous seq, own acks included), drops duplicates by seq, and fills gaps withsyncafter 1.5 s or when aheartbeat'sheadSeqis ahead. It handlesreplayTruncated,replay_unavailableandinvalid_watermark, and reconnects with equal-jitter exponential backoff floored byretryAfterMs. It stops on 4400, 4403 and 1008, and refreshes the token first after 4401. The reconnecthellocarries the user id from the firstwelcome, so thews:<user>:<clientId>namespace stays stable for tokens without auser_idclaim. -
Sends get a UUID
clientIdand are written to aKeyValueStorageoutbox (localStoragein browsers, memory elsewhere, pluggable and sync or async) together with the resume state, before they go on the wire. Retryable errors are retried with the sameclientIdafterretryAfterMs; other errors drop the send. -
Transports implement
connect/send/closeand emit v1 frames:native, the default: the token inhello,pingafteridleTimeoutMs / 2of silence, a liveness timeout.phoenix: own V2 framing,activitySetmapped to frames, a synthesisedwelcomewith an unknown head, so the resend happens right away.sse: afetchstream with the token in a header. Sends are RESTPOSTwithX-Idempotency-Key: ws:<user>:<clientId>, so a resend across transports is stored once; theackis synthesised when the turn arrives on the stream.
The default order is
["native", "sse"], and the client falls back after two connects that never open. -
Types: protocol frames are hand-written from the spec and schemas. REST types live in
src/rest/openapi.tsin theopenapi-typescriptshape, re-exported under stable names fromsrc/rest/types.ts.pnpm generate:typesregenerates the file oncepriv/static/openapi.jsonexists.ConvergerApiErrorreads both the planned error envelope and today's shapes.
Consequences
Positive
- The acceptance test runs against the real server: kill it mid-session, and the client reconnects, receives what it missed through the watermark, and puts the unacked message on the wire exactly once. It also runs on the Phoenix and SSE transports.
- No runtime dependencies, one code path for all transports, typed events and typedoc output.
Negative and trade-offs
- On the Phoenix binding, seqs are derived until #22 sends v1 frames there. Routed sockets with halting middleware can get wrong seqs; the native endpoint is the recommended transport.
- The resend after a reconnect waits for the replay, up to 5 s on a conversation with a large backlog.
- An own turn that is replayed without a clientId (sent with a token whose
user_iddiffers from the session's) cannot be told apart from someone else's and is reported as anactivity. - No MessagePack and no channel-scoped sockets in this package.
- The package
licenseisUNLICENSEDuntil the project settles the npm license.
Follow-ups
- #45: run
generate:typesagainst the OpenAPI document and check the aliases insrc/rest/types.ts. - #67:
@converger/agentand the shared@converger/protocolpackage (frame codec, reconnect, outbox). Generating protocol types frompriv/protocol/v1/belongs there. - #22: v1 framing on the Phoenix binding removes the seq derivation.
Implementation
packages/client-ts/src/conversation.ts(session core),src/client.ts,src/transport/{native,phoenix,sse}.ts,src/storage/{storage,outbox}.ts,src/token.ts,src/rest/*,src/errors.ts,src/protocol/*.- Tests:
test/unit(vitest againsttest/support/mock-server.ts, a mock of the native endpoint, plus transport and REST tests) andtest/e2e(mix phx.serverstarted, killed and restarted bytest/e2e/server.ts). - CI:
.github/workflows/client-ts.yml(typecheck, unit, build, typedoc, e2e with PostgreSQL) andclient-ts-publish.yml(npm publish with provenance on aclient-ts-v*tag). - Examples:
examples/typescript/react-widget,examples/typescript/node-bot.converger_js/was removed.
Links
- TypeScript SDK
- Converger Protocol v1, sections 3, 6, 7 and 12
- WebSocket limits and draining