@ai-matrx/realtime

中文文档
0.10.9发布于 2026/10/16MITTypeScriptESM + CJS近 30 天下载 4.2万

The ONE Supabase Realtime layer for Matrx clients: channel-naming registry, enforced channel lifecycle, echo suppression on by default, at-least-once delivery with idempotent dedup, a decoupled ordered handler queue, reconnect with jittered backoff and a

原始使用文档
文档目录24 节
  1. Why this package exists
  2. The doctrine
  3. 1. Suppress your own echoes — revision-monotonic FIRST, tim…
  4. 2. Echo suppression is ON by default; opting out is the dis…
  5. 3. At-least-once in, exactly-once out
  6. 4. Handlers never run on the socket path
  7. 5. Reconnect means RE-READ. Realtime has no replay.
  8. 6. Tab sleep and app background are the normal failure, not…
  9. 7. One place names channels
  10. 8. Choosing the transport
  11. 9. A foreign peer owns its wire — say so with wire: {mode:"…
  12. Silent-zero-events checklist
  13. Using it
  14. React
  15. One provider per app — and how a sibling package rides it
  16. Redux middleware, thunks, and other non-React owners
  17. Private channels (Database Broadcast)
  18. Autosave coexistence
  19. Presence and typing
  20. React Native / Expo
  21. Diagnostics
  22. Entry points
  23. Migrating off hand-rolled channels
  24. License

@ai-matrx/realtime

The ONE Supabase Realtime layer for Matrx clients. Hand-writing .channel( in a consuming app is a code-review defect.

pnpm add @ai-matrx/realtime

Why this package exists

"We struggle with it so much across our system. We've struggled with it trying to set it up for messaging, trying to set it up for notes. To this day, after hundreds of hours of development, the realtime in our note system still doesn't work perfectly." — Arman, 2026-08-29

Realtime is not hard because subscribing is hard. Subscribing is four lines. It is hard because of eight things that are invisible until they hurt, each of which was learned here the expensive way — a browser that froze, a note that lost an edit, a chat that showed "Ada is typing…" for an hour. This package writes those eight things down ONCE, as code, and makes every consumer use them.

That list is the doctrine below. It replaces the prose version that used to live in matrx-frontend's supabase-realtime skill; the skill now points here.


The doctrine

1. Suppress your own echoes — revision-monotonic FIRST, timestamp second

Supabase sends you your own writes. Postgres Changes has no "not to self" option. The echo of your UPDATE arrives 50–500ms after your REST response already returned the fresh row. By the time it lands, any "save in flight" flag has been cleared. Flag-only suppression always misses the echo.

What an unsuppressed echo does: the echo of save N arrives while the user typed toward N+1 → dirty-local ≠ server → a false conflict → the conflict UI does O(n²) diff work per render → the next save clears it → its echo re-arms it. Self-sustaining main-thread saturation.

What OVER-suppression does (the phantom conflict, 2026-09-13): every platform table's version moves on EVERY update — a desktop sync stamping file_path, an ingest job writing metadata. Same actor, identical content, our write pending: the old ledger called it an echo, the client kept the old number, and its next compare-and-swap missed into a conflict dialog for a row whose text equalled the client's own base. The client had suppressed the one event that carried the number it needed.

The package's write ledger classifies every payload, by revision first (the row's version, read from revisionField, default version):

  • R1. Older revision than we hold → stale.
  • R2. The revision an unsettled write of ours will produce, with matching content → own-echo (the echo that beats the REST response, recognized by number).
  • R3. An already-settled revision with matching content → own-echo; divergent → remote.
  • R4. Anything newer → remote, always — same actor, same content, pending write or not. Delivering it is harmless; swallowing it is the next phantom conflict.

Only when no revision is present does the timestamp doctrine apply:

  1. Strictly older updated_at than what we hold → stale, dropped (merging it regresses updated_at, which fails the next save's optimistic lock into a sticky false conflict).
  2. Content we are known to have written and not yet settled → own-echo.
  3. Equal updated_at → own-echo only if the content fingerprint also matches; a same-millisecond collaborator write still lands.
  4. Unparseable timestamps → deliver. Degrade to delivering, never to silently dropping.

Register your own writes with their number: ledger.begin({ …, revision: expectedVersion + 1 }), ledger.settle(ticket, { revision: response.version }), ledger.observe({ …, revision }). And pair it with @ai-matrx/data's guardedUpdate({ rebase }), so a bump the client still missed (a slept tab) is retried against the current number instead of shown as a conflict.

Broadcast echoes are handled by the envelope: every send carries this client's session id and the receiver drops its own.

2. Echo suppression is ON by default; opting out is the discouraged path

echoSuppression: false exists for the rare consumer that genuinely needs its own events back over the wire. It is never the default and never the fix for something else.

3. At-least-once in, exactly-once out

Resubscribes, rejoins, and a consumer that broadcasts a row it also INSERTs all deliver the same logical event twice. Supply an eventKey (or accept the default) and the package dedups.

4. Handlers never run on the socket path

A Supabase channel calls your handler on the socket's callback path. A handler that dispatches to Redux, diffs content, or makes an RPC therefore executes back-to-back with no yield when a burst arrives — and the tab freezes. Every historical Matrx browser lockup is a variant of this; a background tab waking with 500 queued events is the classic trigger.

The package puts a bounded, ordered, yielding queue between the socket and your handler. Order is preserved per channel (an async handler is awaited before the next event starts), the drain yields to the event loop every few milliseconds, and depth is capped — an overflow sheds the oldest work, says so, and triggers a reconcile, which is the correct recovery for "we could not keep up".

5. Reconnect means RE-READ. Realtime has no replay.

Every event that happened while the socket was down is gone forever. A reconnect without a re-read leaves a permanently wrong screen that looks perfectly healthy. So every channel declares onBackfill, and the package calls it on every recovery path: reconnect, tab wake, network restore, and queue overflow. A channel with no onBackfill gets a loud warning.

Backoff is jittered and has a stability reset: the attempt counter resets only after the channel has held for 30s, never on SUBSCRIBED. Resetting on subscribe makes a flapping channel cycle at the 1s floor forever, firing a full catch-up fetch every cycle.

6. Tab sleep and app background are the normal failure, not the exotic one

A backgrounded tab gets throttled until the socket idles out; a suspended phone comes back with a WebSocket that still looks open and will never deliver another event. Both map onto the same wake path: reconcile, and re-join anything not demonstrably healthy. Web uses visibilitychange; React Native uses AppState (@ai-matrx/realtime/native).

7. One place names channels

Supabase's client.channel(topic) dedupes by topic: two features that both picked "messages" silently share one channel and lose each other's bindings. Declare a namespace instead — a second, different declaration of the same name throws.

Separately, React 19 double-invokes effects and removeChannel is async, so a remount can be handed the previous mount's still-joined channel and .on(...) throws "cannot add postgres_changes callbacks after subscribe()".

🚨 THE TOPIC-SEMANTICS SPLIT — the two transports do not mean the same thing by "topic", and the package treats them differently.

  • Postgres Changes: the topic is only a client-side connection key. The server pushes on the declared filter, whatever the channel is called. So every connection attempt gets a unique instance topic (mx:notes:u1#7), which makes the remount collision above impossible for free.
  • Broadcast and presence: the topic IS the room. It is the routing key the server fans messages out on. A unique instance topic there does not cost nothing — it costs everything: two browsers that both declared mx:room:1 land in #1 and #2 and never see each other. (That shipped in 0.1.x and was found by @ai-matrx/messaging; fixed in 0.2.0.)

So a channel carrying broadcast or presence puts its declared topic on the wire verbatim, and the manager keeps one underlying channel per declared topic, ref-counted: a second open() of that topic attaches its bindings to the same channel, and the channel is removed only when the last holder closes. The remount collision is handled there by deferring the real teardown one turn, so React's cleanup-then-re-run re-attaches instead of racing an async removeChannel.

Consequences worth knowing before you open two holders on one topic:

  • They share ONE channel config. The first one's echoSuppression and presence key win, and a disagreement is a loud channel.room.config-mismatch diagnostic — not a silent surprise.
  • They hear each other locally. Same-tab holders share one client id and the channel is opened broadcast.self:false, so the wire never echoes one holder's frame to its sibling. The manager hands every send() directly to the other open holders of that topic (same envelope, same dedup; never back to the sender; raw-wire channels excluded). Cross-tab delivery is unchanged.
  • Exactly ONE of them is the tab leader. handle.isLocalLeader() is true for the longest-open holder only, and onLeaderChange(isLeader) fires when leadership moves because the leader closed. Run once-per-tab work (autosave, CRDT host election) on the leader — two holders each concluding "I am alone, I am host" is the two-autosaves, last-snapshot-wins bug.
  • They share ONE presence entry (one key per channel), so a topic needs exactly ONE presence publisher. A surface that only READS the roster — a typists label, an online-dot list — passes presence: { publish: false } (or publish: false to useTyping): it sees everyone and stops fighting over the entry. Two publishers is a loud channel.room.presence.multiple-publishers warning, not a mystery.
  • Echo suppression still applies per client, so one holder does not see its sibling's own send() — that is the point of echo suppression, not a room failure. What both holders see is every remote peer's message.
  • A holder that brings Postgres Changes bindings to an already-joined room forces a rebuild (those bindings ride the JOIN payload and cannot be added later). It is announced as channel.room.rebuild, and every holder reconciles through its backfill door. Open a mixed channel's holders together at mount and it never happens.

8. Choosing the transport

  • Postgres Changes — durable rows where RLS must gate delivery (notes, files, tasks). The default for entity sync.
  • Broadcast — ephemeral or high-frequency data with no authorization nuance (CRDT updates, typing, signaling). Never manually broadcast a row you also INSERT — every subscriber gets it twice.
  • Presence — who's-here rosters. Ephemeral only.
  • One channel per feature. Postgres Changes, broadcast, and presence can share one; do not run three.
  • If the table carries updated_by (the platform _stamp_actor trigger), every UPDATE already identifies its editor — you may not need presence at all.

9. A foreign peer owns its wire — say so with wire: {mode:"raw"}

Every broadcast this package sends is wrapped in the Matrx envelope ({v, cid, eid, ts, data}), and that envelope is what makes echo suppression and dedup possible at all. Some channels do not get to choose: the other end is a separately released program that reads a payload shape it defined, and re-shaping the wire under it would simply stop the peer hearing us with nothing failing loudly at either end. The case that produced this mode is the Chrome extension's matrx-extension-bridge:<userId> channel — the deployed extension parses a bare BridgeEnvelope ({direction, action, requestId, payload, timestamp}) with a Zod schema.

const bridge = defineChannelNamespace({
  namespace: "extension-bridge",
  parts: ["userId"],
  description: "matrx-extend ↔ matrx-frontend RPC bridge (foreign wire).",
  foreignTopic: "matrx-extension-bridge", // the peer's topic root, used verbatim
});

manager.open({
  topic: bridge.topic({ userId }),
  wire: { mode: "raw", acceptEchoFromSelf: true },
  broadcast: [{ event: "FRONTEND_RPC", onMessage: ({ data }) => route(data) }],
  eventKey: (_source, payload) => `${payload.direction}:${payload.requestId}`,
  onBackfill: () => {},
});

Raw mode means exactly two things, in both directions: send puts your payload on the wire verbatim (byte-identical to a hand-rolled channel.send), and receive never reads an envelope — not even from a payload that happens to look enveloped. BroadcastMessage.enveloped is always false there.

It is a named downgrade, and it is never silent. Two protections ride the envelope:

  • Echo suppression used cid, and a raw wire has none. Supply wire.isOwnMessage when the peer's shape identifies its sender; otherwise set wire.acceptEchoFromSelf: true to state in code that the server's broadcast.self:false is the only protection. Supplying neither is legal and produces channel.raw.echo-unguarded (warn); the explicit opt-out still announces itself as channel.raw.echo-accepted (info), because the diagnostics stream must show which channels run unguarded.
  • Dedup keyed off eid. Without one, every arrival is a distinct key. Supply eventKey — it receives the raw payload — or take channel.raw.dedup-unguarded (warn).

foreignTopic is the topic half of the same idea. The namespace is still declared and still collision-checked; only the mx: root is replaced by the peer's. Renaming a foreign topic to mx:… does not migrate anything — it puts this client in a room of one while the peer keeps listening where it always did.

Raw mode is transitional by intent: it exists so both ends of a foreign wire can ride this package today. When the peer ships an envelope-aware release, the change is deleting wire from the spec — coordinate the two releases and record the order in the peer's channel contract.

Silent-zero-events checklist

A subscription that compiles but receives nothing has one of these:

  1. The table is not in the supabase_realtime publication.
  2. RLS filters the events (realtime is RLS-authorized — that is a feature, but grants gate delivery).
  3. REPLICA IDENTITY FULL is missing and you need old-row data or RLS-on-DELETE.
  4. The table is not yet in the PostgREST/realtime schema cache (notify pgrst, 'reload schema').

Using it

React

import { RealtimeProvider, useChannel } from "@ai-matrx/realtime/react";
import { defineChannelNamespace } from "@ai-matrx/realtime";

// Once, beside the feature. Declaring twice with a different shape throws.
export const notesChannel = defineChannelNamespace({
  namespace: "notes",
  parts: ["userId"],
  description: "Note rows for one user",
});

function App({ supabase, userId }) {
  return (
    <RealtimeProvider client={supabase} actorId={userId}>
      <Notes userId={userId} />
    </RealtimeProvider>
  );
}

function Notes({ userId }) {
  const { status } = useChannel({
    topic: notesChannel.topic({ userId }),
    postgresChanges: [
      {
        event: "*",
        schema: "workbench",
        table: "notes",
        rowId: (row) => String(row.id),
        fingerprint: (row) => String(row.content),
        onChange: ({ row }) => applyNote(row),
      },
    ],
    // Realtime has no replay. This is how the gap gets closed.
    onBackfill: () => refetchNotes(userId),
  });

  return <StatusDot status={status} />;
}

Handlers may be fresh closures on every render — the subscription is keyed on the channel's shape, not on handler identity, and the latest handler is always the one that runs.

One provider per app — and how a sibling package rides it

<RealtimeProvider> builds exactly ONE manager, and that manager owns the write ledger that makes an optimistic write's own echo recognizable. A second provider anywhere in the tree is a second ledger and a second set of channels, so one manager's own writes look REMOTE to the other. Apps mount one.

A PACKAGE that does realtime work of its own (@ai-matrx/messaging, @ai-matrx/meet) must therefore ride the host's manager when the host has one, and mount its own only in an app that wires none:

const hostProvidesRealtime = useIsRealtimeProviderMounted();
if (hostProvidesRealtime) return <Runtime {...props} />;
return <RealtimeProvider client={client}><Runtime {...props} /></RealtimeProvider>;

Ask useIsRealtimeProviderMounted(), never useRealtimeManager() !== null — the manager is also null while a mounted provider waits for its Supabase client, and a package that guessed from it would mount a provider and then tear it, and every channel under it, back down the instant the host's client arrived.

Redux middleware, thunks, and other non-React owners

Rule 4 prefers a Redux middleware as a feature's subscription owner — a component effect re-subscribes on identity churn and multiplies channels across mounts. Middleware cannot call a hook, so the provider publishes its manager and non-React owners read it. This is a pointer to the ONE manager the provider built; constructing your own in middleware is a second write ledger, and every optimistic write of yours would then classify as remote and cost a refetch.

import { subscribeToRealtimeManager } from "@ai-matrx/realtime";

// Opens on arrival, closes on departure, RE-OPENS on the new manager when the
// provider rebuilds one (the signed-in user changed). Returns a stop function.
const stop = subscribeToRealtimeManager(() => ({
  topic: notesChannel.topic({ userId: currentUserId() }),
  postgresChanges: [ /* … */ ],
  onBackfill: () => store.dispatch(fetchNotes()),
}));

currentRealtimeManager() and onRealtimeManagerChange(listener) are the primitives underneath. Reading currentRealtimeManager() once at module load is almost always wrong: it is null before auth resolves, and the manager is rebuilt (and the old one disposed, with every channel it owned) when the signed-in user changes. Prefer subscribeToRealtimeManager.

The spec is a factory so the owner captures the current user id / dispatch at open time rather than at module load.

Private channels (Database Broadcast)

A topic authorized against realtime.messages RLS — how realtime.broadcast_changes() from a Postgres trigger delivers rows only to the entitled users — declares private:

manager.open({
  topic: schedulerChannel.topic({ userId }),
  private: true,
  broadcast: [{ event: "UPDATE", onMessage: ({ data }) => apply(data) }],
  onBackfill: () => refetchSchedules(),
});

The package creates the channel with config.private: true and authorizes the socket (client.realtime.setAuth()) before subscribing. That second step is not optional and is the quietest failure in this whole surface: without it the join is refused, the channel receives nothing forever, and it reports itself perfectly healthy. A client that cannot authorize gets channel.private.unsupported; a token that will not attach gets channel.private.auth-failed.

Autosave coexistence

Register the write on the shared ledger before the optimistic update, and the echo is suppressed while a genuine collaborator write still lands:

const manager = useRealtimeManager();
const ticket = manager.ledger.begin({
  table: "workbench.notes",
  id: note.id,
  fingerprint: draft.content,
});
try {
  const saved = await saveNote(draft);
  manager.ledger.settle(ticket, { updatedAt: saved.updated_at, fingerprint: saved.content });
} catch (error) {
  manager.ledger.abandon(ticket);
  throw error;
}

ledger.classify(...) returns own-echo | remote | stale plus localWritePending, so an optimistic write, its echo, and a real remote write are all distinguishable.

Presence and typing

const { others } = usePresence({
  topic: roomChannel.topic({ roomId }),
  presence: { state: { userId, name } },
});

const { typists, onKeystroke, stopTyping } = useTyping({
  topic: chatChannel.topic({ conversationId }),
  identity: { userId, name },
});

A crashed client cannot leave a ghost participant (heartbeat + local-clock expiry), and "X is typing…" cannot stick (typing is a renewed lease, never a latch that waits for a stop message that may never arrive).

React Native / Expo

import { NativeRealtimeProvider, useChannel } from "@ai-matrx/realtime/native";

<NativeRealtimeProvider client={supabase} actorId={userId}>
  {children}
</NativeRealtimeProvider>;

Identical hooks, with the AppState seam already wired: background → foreground reconciles and re-joins. react-native is an optional peer; the web entries never load it.

Diagnostics

import { realtimeDiagnostics } from "@ai-matrx/realtime";
// or: const snapshot = useRealtimeDiagnostics();

Live channels, subscription state, events/minute, and the numbers nobody ever had: suppressedEcho, deduped, stale, queueDepth, queueDropped, handlerErrors, backfills. Realtime bugs are meant to be observed, not guessed at.


Entry points

EntryContains"use client"
@ai-matrx/realtimeThe framework-free core: manager, write ledger, topics, presence/typing runtimes, queue, backoff, dedup, diagnosticsno (safe from RSC, Redux middleware, workers, node)
@ai-matrx/realtime/reactRealtimeProvider, useChannel, usePresence, useTyping, useRealtimeDiagnostics, useRealtimeHealth — plus the whole coreyes
@ai-matrx/realtime/nativeNativeRealtimeProvider, createReactNativeEnvironment — plus the whole React bindingyes

Zero runtime dependencies. react and react-native are optional peers. The package never imports @supabase/supabase-js: the client is accepted structurally, and a compile-time proof against the real declarations lives in src/core/supabase-shape.test.ts.


Migrating off hand-rolled channels

  1. Declare a namespace for the feature; delete the hand-written topic string.
  2. Replace the .channel(...) block with useChannel (or manager.open in middleware).
  3. Move the catch-up read into onBackfill — if the module had none, this is the bug you are fixing.
  4. Delete the module's own echo/dedup/backoff code. Keeping a second copy beside the package is the named failure, not the safe option.
  5. uniqueChannelTopic lives HERE. @ai-matrx/data/db re-exports it (data 0.6.3) so its existing call sites keep working — but a migrating feature stops calling it at all: a namespace names the channel and the manager decides the wire topic per the topic-semantics split in rule 7. A hand-rolled broadcast or presence channel that was calling uniqueChannelTopic itself was splitting its own room — that is a bug you are fixing, not behavior to preserve.

License

MIT

版本与发布记录

@ai-matrx/realtime 共发布 43 个版本,首次发布于 2026/08/31;过去 12 个月发布了 43 个版本。

版本发布日期距今
0.10.9latest2026/10/1621 天前
0.10.82026/10/1126 天前
0.10.72026/10/1126 天前
0.10.62026/10/0927 天前
0.10.52026/10/0927 天前
0.10.42026/10/0927 天前

关于 @ai-matrx/realtime 的常见问题

@ai-matrx/realtime 是什么?

The ONE Supabase Realtime layer for Matrx clients: channel-naming registry, enforced channel lifecycle, echo suppression on by default, at-least-once delivery with idempotent dedup, a decoupled ordered handler queue, reconnect with jittered backoff and a

如何安装 @ai-matrx/realtime?

在项目目录运行 npm install @ai-matrx/realtime,或使用 pnpm add / yarn add / bun add。

@ai-matrx/realtime 支持 TypeScript 吗?

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

@ai-matrx/realtime 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@ai-matrx/realtime 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@ai-matrx/realtime 可以商用吗?

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

@ai-matrx/realtime 的下载量有多少?

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

@ai-matrx/realtime 还在维护吗?

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