@chatto/client

中文文档
0.5.0发布于 2026/11/09Apache-2.0TypeScriptES Module构建来源已验证近 30 天下载 2514

Framework-neutral Chatto client: connections, realtime projection, and reactive stores

原始使用文档
文档目录17 节
  1. Clients and servers
  2. Answering messages
  3. Events, status, and state
  4. Live timelines and room stores
  5. Readiness and failures
  6. Requests
  7. Stateless requests
  8. Debug output
  9. Privacy
  10. Applications with a UI
  11. Native storage and browser sign-in
  12. Native password sign-in
  13. State of the host
  14. Svelte
  15. Voice calls
  16. Reactivity
  17. Development

Chatto client

@chatto/client is the client for Chatto 0.5 servers. It connects to one or more servers, keeps their data current through the realtime API, and exposes that data as reactive state. It also has the requests and the message loop that bots and integrations use. It keeps server data only: state of a host's UI belongs to the host. It has no dependency on a UI framework. The bundled Chatto frontend, ChattoBot, and the Runling bot example use it.

Install with npm install @chatto/client. The npm package contains ES modules and TypeScript declarations, including generated protocol types. Use Node.js 22 or later for bots. Svelte is optional; install Svelte 5 when you use @chatto/client/svelte. Module paths can change before 1.0.

Clients and servers

A client is an isolated set of servers with their realtime transports and background work. Create one client for each independent host. A process can have several clients, and each client can connect several servers:

import { createClient } from '@chatto/client';

const client = createClient();
process.on('SIGTERM', () => client.close());
try {
  const eu = client.connect({ serverUrl: 'https://eu.example', apiKey: euKey });
  const us = client.connect({ serverUrl: 'https://us.example', apiKey: usKey });
  await Promise.all([
    eu.run((ctx) => ctx.reply(`Hello from EU, you said: ${ctx.message.body}`)),
    us.run((ctx) => ctx.reply('Hello from US'))
  ]);
} finally {
  // Close every server and stop all background work.
  client.close();
}

connect() adds a server with a fixed bearer token, such as a bot API key, and returns it. The token is kept only in memory: it is never renewed or written to device storage. client.server(id) returns a server that the client has. Both return the same Server type: the server's data, events, and requests are one object.

server.reportPresence() shows the account as online while the server is open: once the server accepts the key, the client sends a presence heartbeat every 30 seconds. After close(), the account shows as offline within 60 seconds. It never changes the account's saved presence choice.

Answering messages

server.run(handler) waits until the server accepted the key, also while the server is unreachable, then calls handler for each message addressed to the viewer (the account of the key). By default, these are direct messages, mentions of the viewer by name, and replies to the viewer's messages. Role, @here, and @all mentions are separate reasons (role_mention, here_mention, all_mention) that you enable with reasons. Messages are handled in order. The handler receives a context:

await server.run(
  async (ctx) => {
    ctx.message; // the addressed message: id, roomId, threadRootId, authorId, body, reasons
    const thread = await ctx.readThread(); // messages with `fromViewer`
    await ctx.withTyping(async () => {
      await ctx.reply(await answer(thread.messages));
    });
    await ctx.addReaction('eyes');
  },
  {
    signal, // stops the loop; without it, the loop stops when the server closes
    reasons: ['mention', 'direct_message'], // default: also 'reply'
    onStatus: (status) => console.log(status.state),
    onError: (error) => console.error(error) // keep running after a failure
  }
);

ctx.signal aborts when the loop stops for any reason; pass it to your own cancellable work. ctx.conversationKey scopes a conversation to the viewer, room, thread, and sender. ctx.refreshTyping() refreshes the typing indicator once.

run resolves when signal aborts or the server closes. It rejects when the server rejects the key or does not support this client, and, without onError, with the first failure of the handler.

Events, status, and state

  • server.consumeEvents({ signal, onEvent, onStatus }) handles every realtime event in order. run uses it. onEvent(event, { signal }) receives a signal that aborts when the loop stops for any reason, for example when the server ends the session. Up to 1000 received events wait for a slow handler; beyond that, they are dropped and a gap is reported.
  • onStatus receives connecting, ready, and reconnecting. A ready status with gap: true means that events can be missing, for example because the server could not resume the stream after a reconnect. While run waits for the server, a connecting status with an error reports each failed attempt, for example an unreachable server.
  • A server that connect added receives events from its creation, also before ready() resolves, and keeps them for the first consumeEvents or run call. A later call reports a gap first.
  • onEvent(listener) and onSnapshot(listener) receive events and new snapshots directly, without a loop.
  • The server's data is reactive: for example roomList.rooms, notifications.occurrences, projection.users, and rooms.messages(id) for a paged, live room timeline.

Live timelines and room stores

rooms.messages(roomId) and rooms.thread(roomId, rootId) return a live timeline. The timeline reads its latest page when you first get it. Realtime delivery then adds new messages and applies edits and deletions, so a host does not read the thread again for each change.

  • isCurrent is true when the latest page loaded and no read or reconnect reload is pending. events then has the latest messages.
  • loadError has the error of a failed read, or of lost room access. Call restoreLatestWindow() to read again after a failed read.
  • By default, the client keeps the stores of the 10 most recently used rooms (timeline, file list, pin list, and member list) and the 20 most recently used threads. A call to rooms.messages, rooms.files, rooms.pins, or rooms.members uses the room; a call to rooms.thread uses only the thread. The client disposes the stores of older ones; the next call returns a new store, which reads again. Do not use more rooms or threads at the same time. To keep more, set the client option keepRecent, for example createClient({ keepRecent: { threads: 50 } }).
  • A disposed timeline gets no more updates and is never current. If you keep a timeline across a long wait, for example until isCurrent, also stop when isDisposed becomes true, and get the timeline again.

Use readThread instead when you need one snapshot and no live updates.

rooms.members(roomId) returns a member store without starting a directory scan. Call store.loadRoomMembers(roomId) when a consumer needs the full list; the server store defers this request until room access is known. Use members.searchMembers(query) for bounded suggestions without loading the full list. An empty query returns a bounded page of suggestions. Recovery refreshes only explicitly requested directories.

For rendered mentions, call members.retainMentions(handles) and release the returned subscription when the content leaves the view. Read mentionMembers for current profiles. These shared, room-scoped lookups search only matching pages and do not request the full directory. Missing handles stay plain text. Access and membership changes discard cached matches and fence older reads.

Readiness and failures

  • ready() rejects when the server rejects the key, when the server is unreachable or fails, or when the server release does not support this client. The server retries in the background; call ready() again to wait for the next attempt. run() waits through these retries.
  • sessionEnded becomes true when the server rejects or revokes the key. realtimeUnsupported becomes true when the server does not support this client's realtime protocol. In both cases no events arrive, and the event loops reject; close the server.
  • A server that connect added keeps a persistent WebSocket, also in a client with one selected live server.
  • server.close() removes the server from its client and stops its realtime delivery. Requests in flight fail, even when the server applied them, and no new requests are sent. When the last server that connect added closes, the client stops its timers, so a Node host can exit.

Requests

A server and a stateless Api have the same request helpers. Each request has a ten-second timeout and is not automatically retried; a failed write can still have reached the server.

HelperDoes
getMessage({ roomId, messageId })Reads one message, including attachment metadata without download addresses
createMessage(destination, body, { inReplyTo })Sends one message; returns its id
prepareMessage(destination, body, { inReplyTo })Prepares one logical send for explicit retries
postMessage(destination, body)Sends text of any length, in parts of 8000 code points
reply(message, body)Posts in the message's thread with a reference to it
readThread(location, { after, limit })Reads a thread; after reads only newer messages
readAttachment({ roomId, attachmentId }, opts)Reads a file; maxImageSize resizes images on the server
refreshTyping(destination)Refreshes the typing indicator once
withTyping(destination, work)Shows the typing indicator while work runs
addReaction({ roomId, messageId }, emoji)Reacts to a message
addressedMessage(event, { reasons })Recognizes an event as a message to the viewer

Request helpers take { signal } as their last argument. A prepared operation takes it in send({ signal }). postMessage, reply, and ctx.reply return the ids of the new messages. Other helpers are conversationKey, replyDestination, withTyping and startTyping for custom typing updates, and createDeliveryTracker for replay filters.

For a final reply, await withTyping(destination, work) before posting the message. The helper stops its timer and waits for any pending refresh, with a two-second request timeout. This prevents a successful refresh from arriving after the message and restoring the typing indicator.

Message bodies can contain emoji shortcodes, such as Hello :wave:. The server keeps this text. Clients choose how to display it. For custom renderers, @chatto/client/messaging/emojiShortcodes exports resolveEmoji(name) for case-sensitive built-in names and emojiShortcodes(text) for source ranges with Unicode or unknown results. Tags beside Unicode letters, numbers, combining marks, or underscores stay literal. emojiShortcodeQuery(textBeforeCursor) reads a partial name with the same opening boundary for autocomplete. Parse Markdown first and scan only prose; leave code and URLs unchanged. Custom emoji assets are not supported yet.

Skin-tone shortcodes use Slack syntax: :thumbsup::skin-tone-4: displays as 👍🏽. Tones range from 2 (light) to 6 (dark). Supported mixed-tone pairs use two numbers in one suffix, such as :couple::skin-tone-2-6:. Unsupported combinations stay literal. Reaction APIs take the same name without the surrounding colons. Older servers can reject these reaction names; no connection-floor increase is required. Existing names and Unicode message content remain unchanged.

emojiNameFromUnicode(emoji) returns a stable reaction name for a selected Unicode emoji, including its tone. Unknown sequences return undefined. Use this helper for new selections; keep existing reaction names unchanged.

mise codegen-emoji builds the client tone map, server map, and converter map (tools/emoji-map.json) from gemoji and the bundled Unicode Emoji 15.0 data. Converters should look up complete names in this map rather than append a modifier to a base glyph. The generated maps require no network calls at runtime.

For a send that needs retry safety, retain the prepared operation:

const send = server.prepareMessage(destination, 'Hello');
const { id } = await send.send({ signal });
// After a lost response, call send.send() again with the same operation.

Each prepared send allocates one UUID. Exact retries use that key and the same arguments. The UI message API also exposes prepareMessage(input) and retains uploaded attachment IDs. Prepared sends stay in memory. They stop retries at 30 minutes from the first post attempt and throw MessageSendExpiredError. Check the original result before starting a new send. Retry safety requires all serving replicas to support message-create idempotency; older servers can ignore the key. postMessage and reply can send multiple parts; retrying the whole helper starts new sends for those parts.

For a server with renewable credentials, a privacy reset invalidates retained sends. Their next send() throws StaleResponseError without posting again. Check the original result before starting a new send. Fixed-token sends remain valid across privacy resets, but cannot send after the server is closed.

For any other request, service(Service) creates a typed Connect client with the server's authentication. @chatto/client/types has the protocol's messages and services, and @chatto/client/types/admin the administration API:

import { UserService } from '@chatto/client/types';

const { user } = await server.service(UserService).getUser({
  target: { case: 'userId', value: authorId }
});

Stateless requests

createApi makes requests without a realtime connection or retained state, for example in a webhook handler, or for work that can continue after a server closes:

import { createApi } from '@chatto/client';

const api = createApi({ serverUrl, apiKey });
const { viewerId } = await api.ready({ signal }); // read once, then reused
await api.reply(message, 'Done', { signal });

It uses Connect JSON, rejects redirects, and loads no stores. Pass viewerId when the host already knows the account of the key, for example from server.ready(); ready() then sends no request.

Debug output

Debug output is off outside browsers. Call setDebugLogging(true) to write it to the console.

Privacy

Requests contact only the configured servers. Each server receives the host's IP address, its credentials, and the request data. The client does not log tokens or message content. The client removes its copies of private data at authentication, authorization, and privacy boundaries, and reports these boundaries to the host; see State of the host.

Applications with a UI

An application creates one client with device storage and the origin server, starts its runtime, and reports the server that the user looks at. Only that server keeps a persistent WebSocket; the others catch up by polling:

import { createClient } from '@chatto/client';

export const client = createClient({
  storage: 'device', // restore and keep the server catalogue and sessions
  originServer: true, // the page's own server uses its cookie session
  liveServers: 'selected'
});

client.start(); // pair each start() with a stop(), for example on unmount
client.setActiveServer(serverId);
const server = client.server(serverId);

A process can have one client with device storage and one for the origin server.

If a host fetches discovery before registry.addServer, pass that response as serverInfo in the third argument to avoid another startup request. server.serverInfo.init() shares an in-flight discovery request. After it settles, read server.serverInfo.discovery for the response, or null on failure. A later call makes a fresh request.

Native storage and browser sign-in

Native hosts can pass persistence: ServerRegistryPersistence to createClient instead of storage: 'device'. This adapter restores server registrations and stores authentication separately. The latest authentication record overrides the combined catalogue snapshot; null is a cleared-session record. Writes must complete durably before returning. A failed write must throw or return false from writeAuthentication. Read-back must return the committed value. The host owns encryption, access control, and exclusive process access. Do not let two processes renew the same saved session without shared coordination.

The SDK saves a refresh request ID before sending a renewal. On restart it reuses that ID, then saves and verifies the replacement credentials before returning the new access token. Renewal requests omit cookies and reject redirects. Keep all authentication fields, including the OAuth client ID and refresh request ID, in the durable record.

@chatto/client/auth/oauth provides createOAuthFlow, isOAuthCallback, and exchangeOAuthCode for native hosts. Supply discovery's authorization URL, the host's public OAuth client ID, and a literal loopback HTTP callback URL or the built-in chatto://desktop/servers/callback?mode=popup callback. The host opens the returned authorization URL in the system browser and receives the callback. The helpers use S256 PKCE, transaction state, exact callback validation, an optional issuer check, and a bounded token exchange. Keep the flow and callback in memory. Never log their code or verifier. The host owns the loopback listener, cancellation, consent UI, and client metadata document.

Pass returned credentials through persistedBearerSession, combine them with emptyServerSession(), and supply the session to registry.addServer. The SDK then owns renewal and realtime state.

Native password sign-in

passwordLogin(origin, { login, password }, { signal }) returns renewable human credentials on servers that permit direct password login. Native hosts supply their own secure password field. The request has a 15-second timeout and a 64 KiB response limit. It omits cookies, rejects redirects, and accepts HTTPS or loopback HTTP origins without paths, queries, or fragments.

Pass the credentials through persistedBearerSession from @chatto/client/auth/bearerSession, combine them with emptyServerSession() from @chatto/client/server/sessions, and supply that session to client.registry.addServer(registration, session). The SDK then owns token renewal and realtime state. This function does not save passwords or sessions. Errors expose a status and a fixed message, never the server response body. Remote signOutServer requests also omit cookies and reject redirects.

State of the host

The client keeps server data. A host keeps its own state, such as sidebar grouping, search results, call media, or a query cache, next to each server:

  • Derive state from the server where possible. Derived state follows the server and needs no cleanup.
  • A host that copies server data must clear the copy at the server's boundary events: onReset, onRoomAccessLost, onRoomAccessRestored, onUserDeleted, onAuthorityChanged, onPermissionsChanged, onSessionEnded, and onDispose. onUpdate reports every applied event and resource. The server emits each event synchronously, after it removed its own copies.
  • A listener that throws at a privacy boundary fails the private-data cleanup: the server then does not report its projection as current. The server waits for a promise that an onPermissionsChanged listener returns.
  • registry.watchStores(setup) calls setup for each server when the registry creates it, before the server receives realtime data.

Svelte

Import @chatto/client/svelte once, before a component reads a server. Svelte then tracks reads in components, $derived, and $effect:

<script lang="ts">
  import '@chatto/client/svelte';
  import { client } from '$lib/client';

  let { serverId } = $props();
  const server = $derived(client.server(serverId));
</script>

{#each server?.roomList.rooms ?? [] as room (room.id)}
  <p>{room.name}</p>
{/each}

Other frameworks can use setReadHook and subscribe from @chatto/client/reactivity in the same way.

Voice calls

A server tracks its active calls in projection.activeCalls. The client contains no media implementation. A host that joins calls implements the media, reads call permissions from the projected rooms, and leaves a call at onRoomAccessLost and onDispose.

Reactivity

The package has a small signal library in @chatto/client/reactivity: signal, computed, effect, effectRoot, batch, untrack, subscribe, ReactiveMap, and ReactiveSet. Computed values are lazy. Effects run synchronously when the outermost write ends.

Development

mise test-chatto-client   # type checks, lint, and tests

src/types.ts and src/types/admin.ts are generated from @chatto/api-types; mise codegen-proto updates them, and the package check fails when they are out of date.

See MAINTAINING.md for package checks and release instructions.

The client and bundled generated protocol code use Apache-2.0; the npm package includes its license and a NOTICE file.

版本与发布记录

@chatto/client 共发布 17 个版本,首次发布于 2026/10/24;过去 12 个月发布了 17 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.5.0latest2026/11/09今天
0.5.0-rc.5next2026/11/063 天前
0.5.0-rc.42026/11/035 天前
0.5.0-rc.32026/11/036 天前
0.5.0-rc.22026/11/027 天前
0.5.0-beta.292026/11/017 天前

关于 @chatto/client 的常见问题

@chatto/client 是什么?

Framework-neutral Chatto client: connections, realtime projection, and reactive stores

如何安装 @chatto/client?

在项目目录运行 npm install @chatto/client,或使用 pnpm add / yarn add / bun add。

@chatto/client 支持 TypeScript 吗?

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

@chatto/client 支持 ESM 还是 CommonJS?

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

@chatto/client 可以商用吗?

@chatto/client 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@chatto/client 的下载量有多少?

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

@chatto/client 还在维护吗?

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