@cloudbase/realtime-js
v1.0.1
Published
Listen to realtime updates to your PostgreSQL database
Downloads
362
Readme
CloudBase Realtime JS SDK
Send ephemeral messages with Broadcast, track and synchronize state with Presence, and listen to database changes with Postgres Change Data Capture (CDC).
This package is @cloudbase/realtime-js. It is standalone and does not depend on the supabase-js monorepo.
Overview
This SDK enables you to use the following Realtime features:
- Broadcast: send ephemeral messages from client to clients with minimal latency. Use cases include sharing cursor positions between users.
- Presence: track and synchronize shared state across clients with the help of CRDTs. Use cases include tracking which users are currently viewing a specific webpage.
- Postgres Change Data Capture (CDC): listen for changes in your PostgreSQL database and send them to clients.
Usage
Installing the Package
npm install @cloudbase/realtime-jsCreating a Channel
import { RealtimeClient } from '@cloudbase/realtime-js'
const client = new RealtimeClient(REALTIME_URL, {
params: {
apikey: API_KEY,
},
})
const channel = client.channel('test-channel', {})
channel.subscribe((status, err) => {
if (status === 'SUBSCRIBED') {
console.log('Connected!')
}
if (status === 'CHANNEL_ERROR') {
console.log(`There was an error subscribing to channel: ${err.message}`)
}
if (status === 'TIMED_OUT') {
console.log('Realtime server did not respond in time.')
}
if (status === 'CLOSED') {
console.log('Realtime channel was unexpectedly closed.')
}
})Notes:
REALTIME_URLis'ws://localhost:4000/socket'when developing locally and'wss://<project_ref>.supabase.co/realtime/v1'when connecting to your Supabase project.API_KEYis a JWT whose claims must containexpandrole(existing database role).- Channel name can be any
string. - Setting
privatetotruemeans that the client will use RLS to determine if the user can connect or not to a given channel.
CloudBase / 微信小程序
通过 @cloudbase/js-sdk 接入时,不要单独 new RealtimeClient()。小程序没有全局 WebSocket / fetch / URL,必须走 host 已注册的 adapter(wsClass / reqClass):
const app = cloudbase.init({ env: 'xxxx-yyy' })
const realtime = app.realtime()
const channel = realtime.channel('room')
channel.on('broadcast', { event: 'cursor-pos' }, (payload) => {
console.log(payload)
})
channel.subscribe()@cloudbase/app 会在小程序环境自动 useAdapters(adapter-wx_mp)。基础库只要有 wx.connectSocket 即可订阅;httpSend() 走 adapter.reqClass,不依赖浏览器 fetch / URL。
小程序默认开启双连接热切(与浏览器 / Node 相同,270s)。worker 在没有 window.Worker 时不会开启。
真机验证清单
- [ ]
channel.subscribe()成功(开发者工具 + 真机) - [ ] broadcast 收发
- [ ] 断网后重连
- [ ] 登录 / 刷新 token 后连接仍可用
- [ ] (可选)
httpSend()返回成功或明确的 adapter 错误,而不是fetch is not defined
Broadcast
Your client can send and receive messages based on the event.
// Setup...
const channel = client.channel('broadcast-test', { broadcast: { ack: false, self: false } })
channel.on('broadcast', { event: 'some-event' }, (payload) => console.log(payload))
channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
// Send message to other clients listening to 'broadcast-test' channel
await channel.send({
type: 'broadcast',
event: 'some-event',
payload: { hello: 'world' },
})
}
})Notes:
- Setting
acktotruemeans that thechannel.sendpromise will resolve once server replies with acknowledgment that it received the broadcast message request. - Setting
selftotruemeans that the client will receive the broadcast message it sent out.
Broadcast Replay
Broadcast Replay enables private channels to access messages that were sent earlier. Only messages published via Broadcast From the Database are available for replay.
You can configure replay with the following options:
since(Required): The epoch timestamp in milliseconds, specifying the earliest point from which messages should be retrieved.limit(Optional): The number of messages to return. This must be a positive integer, with a maximum value of 25.
Example:
const twelveHours = 12 * 60 * 60 * 1000
const twelveHoursAgo = Date.now() - twelveHours
const config = { private: true, broadcast: { replay: { since: twelveHoursAgo, limit: 10 } } }
supabase
.channel('main:room', { config })
.on('broadcast', { event: 'my_event' }, (payload) => {
if (payload?.meta?.replayed) {
console.log('This message was sent earlier:', payload)
} else {
console.log('This is a new message', payload)
}
// ...
})
.subscribe()Presence
Your client can track and sync state that's stored in the channel.
// Setup...
const channel = client.channel('presence-test', {
config: {
presence: {
key: '',
},
},
})
channel.on('presence', { event: 'sync' }, () => {
console.log('Online users: ', channel.presenceState())
})
channel.on('presence', { event: 'join' }, ({ newPresences }) => {
console.log('New users have joined: ', newPresences)
})
channel.on('presence', { event: 'leave' }, ({ leftPresences }) => {
console.log('Users have left: ', leftPresences)
})
channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
const status = await channel.track({ user_id: 1 })
console.log(status)
}
})
config.presence.enabled(set automatically if you add an.on('presence', ...)listener) controls whether this client receives presence state and updates from other clients — without it,presenceState()stays empty for you. It does not affect whether other clients see you: callingtrack()always makes you visible to subscribers that do have presence enabled. On RLS-protected channels, receiving presence updates additionally requires thepresence.readpolicy to authorize this client.
Postgres CDC
Receive database changes on the client.
// Setup...
const channel = client.channel('db-changes')
channel.on('postgres_changes', { event: '*', schema: 'public' }, (payload) => {
console.log('All changes in public schema: ', payload)
})
channel.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'messages' },
(payload) => {
console.log('All inserts in messages table: ', payload)
}
)
channel.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'users', filter: 'username=eq.Realtime' },
(payload) => {
console.log('All updates on users table when username is Realtime: ', payload)
}
)
channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
console.log('Ready to receive database changes!')
}
})Filters
The filter option accepts either a raw string or a
postgresChangesFilter() builder — both produce the exact same wire format, so
you can mix and match and existing string filters keep working unchanged:
// Raw string — always supported, fully backward compatible
{ event: 'UPDATE', schema: 'public', table: 'users', filter: 'id=eq.1' }
// Builder — type-checked, ergonomic; the SDK serializes it for you
{ event: 'UPDATE', schema: 'public', table: 'users', filter: postgresChangesFilter().eq('id', 1) }A filter is a column=operator.value expression evaluated server-side. The
following operators are supported:
| Operator | String form | Builder | Meaning |
| --------------------- | ---------------------------- | -------------------------------------- | --------------------------------- |
| eq | id=eq.1 | .eq('id', 1) | equal |
| neq | id=neq.1 | .neq('id', 1) | not equal |
| lt lte gt gte | age=gte.18 | .gte('age', 18) | comparison |
| in | status=in.(active,pending) | .in('status', ['active', 'pending']) | in list |
| like ilike | title=like.%foo% | .like('title', '%foo%') | pattern match (case in/sensitive) |
| is | deleted_at=is.null | .is('deleted_at', null) | IS null/true/false/unknown |
| match imatch | title=match.^foo | .match('title', '^foo') | POSIX regex match (~ / ~*) |
| isdistinct | value=isdistinct.1 | .isDistinct('value', 1) | NULL-safe inequality |
Negation — prefix any operator with not. (string) or use
.not(column, operator, value) (builder):
// String
{ event: '*', schema: 'public', table: 'posts', filter: 'status=not.in.(draft,archived)' }
// Builder
{
event: '*',
schema: 'public',
table: 'posts',
filter: postgresChangesFilter().not('status', 'in', ['draft', 'archived']),
}AND composition — multiple conditions are combined with commas and applied
as an AND. With the builder you just chain calls:
// String
{ event: 'UPDATE', schema: 'public', table: 'orders', filter: 'amount=gt.100,status=in.(open,pending)' }
// Builder — equivalent, chained
{
event: 'UPDATE',
schema: 'public',
table: 'orders',
filter: postgresChangesFilter().gt('amount', 100).in('status', ['open', 'pending']),
}Building filters with postgresChangesFilter()
The builder (modeled on the postgrest-js filter methods) is the recommended,
type-checked way to compose filters — but it is entirely optional; raw strings
remain fully supported.
import { postgresChangesFilter } from '@cloudbase/realtime-js'
channel.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'orders',
// → 'amount=gt.100,status=not.in.(draft,archived)'
filter: postgresChangesFilter().gt('amount', 100).not('status', 'in', ['draft', 'archived']),
},
(payload) => console.log(payload)
)The builder exposes eq, neq, gt, gte, lt, lte, in, like,
ilike, match, imatch, is, isDistinct and not. Call .build() if you
need the raw string yourself (e.g. to log it or store it).
Values are sent verbatim — the server has no quoting/escaping, so spaces and
quotes are preserved as-is. The server separates conditions by commas outside
parentheses, so a literal comma in a scalar value can't be expressed (commas
inside in.(…) are fine); the builder throws on such values rather than
silently producing a broken filter.
Note for PostgREST users: Realtime evaluates filters server-side over a single table's WAL — there is no resource embedding (
!inner, embedded filters) and noor()grouping. Use%(not*) forlike/ilikewildcards, since filters travel in the WebSocket payload rather than a URL.
Selecting columns
Use select to receive only a subset of columns instead of the full row. This
reduces payload size (helpful for large bytea/jsonb columns). The selected
columns must be selectable by the subscribing role:
channel.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'users', select: ['id', 'first_name'] },
(payload) => {
// payload.new only contains { id, first_name }
console.log(payload)
}
)Get All Channels
You can see all the channels that your client has instantiatied.
// Setup...
client.getChannels()Cleanup
It is highly recommended that you clean up your channels after you're done with them.
- Remove a single channel
// Setup...
const channel = client.channel('some-channel-to-remove')
channel.unsubscribe()
client.removeChannel(channel)- Remove all channels and close the connection
// Setup...
client.removeAllChannels()
client.disconnect()Development
This package is standalone. All tooling lives in packages/realtime-js.
Building
cd packages/realtime-js
# Complete build
npm run build
# Individual build targets
npm run build:main # CommonJS build (dist/main/)
npm run build:module # ES Modules build (dist/module/)
# Other useful commands
npm run lint
npm run check-exportsBuild Outputs
- CommonJS (
dist/main/) - For Node.js environments - ES Modules (
dist/module/) - For modern bundlers (Webpack, Vite, Rollup) - TypeScript definitions (
dist/module/index.d.ts) - Type definitions for TypeScript projects
Validating Package Exports
npm run check-exportsThis command uses "Are the types wrong?" to verify that the package exports work correctly in different environments.
Testing
The tests use mocked WebSocket connections and do not need Docker or an external Realtime instance.
cd packages/realtime-js
npm test
npm run test:coverage
npm run test:watch- test - Runs all unit tests once using Vitest
- test:coverage - Runs tests and generates coverage report with terminal output
- test:watch - Runs tests in interactive watch mode for development
Credits
This package is based on @supabase/realtime-js and draws heavily from phoenix-js.
License
MIT.
