Production-oriented WebSocket live updates plugin for Type-R collections.
This package is intentionally separate from @type-r/endpoints. The core endpoints package contains only the basic WebSocket transport. @type-r/live-websocket is a plugin-level package with peer dependencies on Type-R packages, so an application uses the same @type-r/models and @type-r/endpoints instances that it already has installed.
This package extends the basic @type-r/endpoints WebSocket transport with:
- subscribe acknowledgement;
- one WebSocket connection shared by multiple collections;
- reconnect and resubscribe;
- heartbeat;
- per-collection version cursors;
sincereplay after reconnect;- batch replay events;
- gap detection and replay requests;
resync_required/replay_failedhandling;- structured protocol errors;
- graceful server shutdown handling;
- REST snapshot coordination.
yarn add @type-r/live-websocketPeer dependencies expected in the host application:
yarn add @type-r/models @type-r/endpointsimport { auto, CollectionConstructor, define, Record } from '@type-r/models'
import { restfulWebsocketIO } from '@type-r/live-websocket'
const apiRoot = 'http://localhost:3000'
const liveUrl = 'ws://localhost:3000/live'
function liveEndpoint(restUrl: string, channel: string) {
return restfulWebsocketIO(restUrl, liveUrl, {
protocolVersion: 1,
subscribeMessage: () => ({
type: 'subscribe',
channel
}),
unsubscribeMessage: () => ({
type: 'unsubscribe',
channel
}),
subscribeAck: message =>
message.type === 'subscribed' &&
message.channel === channel,
ackVersion: ack => ack.version,
sinceParam: 'since',
version: event => event.version,
enforceEventOrder: true,
replayRequestMessage: (collection, from, to) => ({
type: 'replay_request',
channel,
from,
to
}),
replayFailed: message => message.type === 'replay_failed',
resyncRequired: message => message.type === 'resync_required',
snapshotParam: 'at',
snapshotVersion: ack => ack.version,
snapshotData: json => json.items,
match: message =>
message.channel === channel ||
message.type === 'server_shutdown' ||
message.type === 'pong',
errorMessage: message =>
message.type === 'error'
? { code: message.code, message: message.message }
: undefined,
serverShutdown: message =>
message.type === 'server_shutdown'
? message.retry_after
: undefined,
heartbeat: {
interval: 30000,
timeout: 10000,
ping: { type: 'ping' },
pong: message => message.type === 'pong'
},
reconnect: {
attempts: Infinity,
minDelay: 1000,
maxDelay: 30000,
factor: 2
}
})
}
@define
class User extends Record {
static Collection: CollectionConstructor<User>
static endpoint = liveEndpoint(`${apiRoot}/api/users`, 'users')
@auto name: string
}collection.fetch({ liveUpdates: true }) performs these steps:
- Open or reuse the shared WebSocket connection.
- Send
subscribe. - Wait for
subscribedacknowledgement. - Run REST
GETwith?at=<ack.version>whensnapshotParamandsnapshotVersionare configured. - Apply later WebSocket events.
This avoids the usual race where an event can happen between REST fetch and WebSocket subscribe.
When reconnecting, the plugin sends the last applied version:
{
"protocol": 1,
"type": "subscribe",
"channel": "users",
"since": 42
}The server should replay events newer than since:
{
"type": "batch",
"channel": "users",
"events": [
{
"type": "updated",
"version": 43,
"payload": { "id": "1", "name": "Ann" }
}
]
}With enforceEventOrder: true, if the client has version 42 and receives event 44, it does not apply 44 immediately. It sends:
{
"type": "replay_request",
"channel": "users",
"from": 43,
"to": 43
}The server responds with a batch containing the missing range. If it cannot, it sends:
{
"type": "replay_failed",
"channel": "users",
"reason": "gap_not_available"
}The plugin then calls resync(). By default it calls collection.fetch({ liveUpdates: true }).
Structured errors are converted to ProtocolError:
{
"type": "error",
"channel": "users",
"code": "forbidden",
"message": "Forbidden"
}users.on('error', (collection, error) => {
console.error(error.code, error.message)
})Before deploy or process shutdown, server can send:
{
"type": "server_shutdown",
"retry_after": 1000
}The client closes the socket and reconnects after at least retry_after milliseconds.
@type-r/endpoints REST methods support:
idempotencyKey->Idempotency-Key;expectedVersion->If-Match.
await user.save({
idempotencyKey: crypto.randomUUID(),
expectedVersion: user._version
} as any)See examples/python-backend.md for a focused FastAPI backend implementation.
See examples/client-server.md for a full client/server example with Russian comments.