@supabase/realtime-js
Version:
Listen to realtime updates to your PostgreSQL database
365 lines (307 loc) • 9.3 kB
text/typescript
/*
This file draws heavily from https://github.com/phoenixframework/phoenix/blob/d344ec0a732ab4ee204215b31de69cf4be72e3bf/assets/js/phoenix/presence.js
License: https://github.com/phoenixframework/phoenix/blob/d344ec0a732ab4ee204215b31de69cf4be72e3bf/LICENSE.md
*/
import type {
PresenceOpts,
PresenceOnJoinCallback,
PresenceOnLeaveCallback,
} from 'phoenix'
import type RealtimeChannel from './RealtimeChannel'
type Presence<T extends { [key: string]: any } = {}> = {
presence_ref: string
} & T
export type RealtimePresenceState<T extends { [key: string]: any } = {}> = {
[key: string]: Presence<T>[]
}
export type RealtimePresenceJoinPayload<T extends { [key: string]: any }> = {
event: `${REALTIME_PRESENCE_LISTEN_EVENTS.JOIN}`
key: string
currentPresences: Presence<T>[]
newPresences: Presence<T>[]
}
export type RealtimePresenceLeavePayload<T extends { [key: string]: any }> = {
event: `${REALTIME_PRESENCE_LISTEN_EVENTS.LEAVE}`
key: string
currentPresences: Presence<T>[]
leftPresences: Presence<T>[]
}
export enum REALTIME_PRESENCE_LISTEN_EVENTS {
SYNC = 'sync',
JOIN = 'join',
LEAVE = 'leave',
}
type PresenceDiff = {
joins: RealtimePresenceState
leaves: RealtimePresenceState
}
type RawPresenceState = {
[key: string]: {
metas: {
phx_ref?: string
phx_ref_prev?: string
[key: string]: any
}[]
}
}
type RawPresenceDiff = {
joins: RawPresenceState
leaves: RawPresenceState
}
type PresenceChooser<T> = (key: string, presences: Presence[]) => T
export default class RealtimePresence {
state: RealtimePresenceState = {}
pendingDiffs: RawPresenceDiff[] = []
joinRef: string | null = null
caller: {
onJoin: PresenceOnJoinCallback
onLeave: PresenceOnLeaveCallback
onSync: () => void
} = {
onJoin: () => {},
onLeave: () => {},
onSync: () => {},
}
/**
* Initializes the Presence.
*
* @param channel - The RealtimeChannel
* @param opts - The options,
* for example `{events: {state: 'state', diff: 'diff'}}`
*/
constructor(public channel: RealtimeChannel, opts?: PresenceOpts) {
const events = opts?.events || {
state: 'presence_state',
diff: 'presence_diff',
}
this.channel._on(events.state, {}, (newState: RawPresenceState) => {
const { onJoin, onLeave, onSync } = this.caller
this.joinRef = this.channel._joinRef()
this.state = RealtimePresence.syncState(
this.state,
newState,
onJoin,
onLeave
)
this.pendingDiffs.forEach((diff) => {
this.state = RealtimePresence.syncDiff(
this.state,
diff,
onJoin,
onLeave
)
})
this.pendingDiffs = []
onSync()
})
this.channel._on(events.diff, {}, (diff: RawPresenceDiff) => {
const { onJoin, onLeave, onSync } = this.caller
if (this.inPendingSyncState()) {
this.pendingDiffs.push(diff)
} else {
this.state = RealtimePresence.syncDiff(
this.state,
diff,
onJoin,
onLeave
)
onSync()
}
})
this.onJoin((key, currentPresences, newPresences) => {
this.channel._trigger('presence', {
event: 'join',
key,
currentPresences,
newPresences,
})
})
this.onLeave((key, currentPresences, leftPresences) => {
this.channel._trigger('presence', {
event: 'leave',
key,
currentPresences,
leftPresences,
})
})
this.onSync(() => {
this.channel._trigger('presence', { event: 'sync' })
})
}
/**
* Used to sync the list of presences on the server with the
* client's state.
*
* An optional `onJoin` and `onLeave` callback can be provided to
* react to changes in the client's local presences across
* disconnects and reconnects with the server.
*
* @internal
*/
private static syncState(
currentState: RealtimePresenceState,
newState: RawPresenceState | RealtimePresenceState,
onJoin: PresenceOnJoinCallback,
onLeave: PresenceOnLeaveCallback
): RealtimePresenceState {
const state = this.cloneDeep(currentState)
const transformedState = this.transformState(newState)
const joins: RealtimePresenceState = {}
const leaves: RealtimePresenceState = {}
this.map(state, (key: string, presences: Presence[]) => {
if (!transformedState[key]) {
leaves[key] = presences
}
})
this.map(transformedState, (key, newPresences: Presence[]) => {
const currentPresences: Presence[] = state[key]
if (currentPresences) {
const newPresenceRefs = newPresences.map(
(m: Presence) => m.presence_ref
)
const curPresenceRefs = currentPresences.map(
(m: Presence) => m.presence_ref
)
const joinedPresences: Presence[] = newPresences.filter(
(m: Presence) => curPresenceRefs.indexOf(m.presence_ref) < 0
)
const leftPresences: Presence[] = currentPresences.filter(
(m: Presence) => newPresenceRefs.indexOf(m.presence_ref) < 0
)
if (joinedPresences.length > 0) {
joins[key] = joinedPresences
}
if (leftPresences.length > 0) {
leaves[key] = leftPresences
}
} else {
joins[key] = newPresences
}
})
return this.syncDiff(state, { joins, leaves }, onJoin, onLeave)
}
/**
* Used to sync a diff of presence join and leave events from the
* server, as they happen.
*
* Like `syncState`, `syncDiff` accepts optional `onJoin` and
* `onLeave` callbacks to react to a user joining or leaving from a
* device.
*
* @internal
*/
private static syncDiff(
state: RealtimePresenceState,
diff: RawPresenceDiff | PresenceDiff,
onJoin: PresenceOnJoinCallback,
onLeave: PresenceOnLeaveCallback
): RealtimePresenceState {
const { joins, leaves } = {
joins: this.transformState(diff.joins),
leaves: this.transformState(diff.leaves),
}
if (!onJoin) {
onJoin = () => {}
}
if (!onLeave) {
onLeave = () => {}
}
this.map(joins, (key, newPresences: Presence[]) => {
const currentPresences: Presence[] = state[key] ?? []
state[key] = this.cloneDeep(newPresences)
if (currentPresences.length > 0) {
const joinedPresenceRefs = state[key].map(
(m: Presence) => m.presence_ref
)
const curPresences: Presence[] = currentPresences.filter(
(m: Presence) => joinedPresenceRefs.indexOf(m.presence_ref) < 0
)
state[key].unshift(...curPresences)
}
onJoin(key, currentPresences, newPresences)
})
this.map(leaves, (key, leftPresences: Presence[]) => {
let currentPresences: Presence[] = state[key]
if (!currentPresences) return
const presenceRefsToRemove = leftPresences.map(
(m: Presence) => m.presence_ref
)
currentPresences = currentPresences.filter(
(m: Presence) => presenceRefsToRemove.indexOf(m.presence_ref) < 0
)
state[key] = currentPresences
onLeave(key, currentPresences, leftPresences)
if (currentPresences.length === 0) delete state[key]
})
return state
}
/** @internal */
private static map<T = any>(
obj: RealtimePresenceState,
func: PresenceChooser<T>
): T[] {
return Object.getOwnPropertyNames(obj).map((key) => func(key, obj[key]))
}
/**
* Remove 'metas' key
* Change 'phx_ref' to 'presence_ref'
* Remove 'phx_ref' and 'phx_ref_prev'
*
* @example
* // returns {
* abc123: [
* { presence_ref: '2', user_id: 1 },
* { presence_ref: '3', user_id: 2 }
* ]
* }
* RealtimePresence.transformState({
* abc123: {
* metas: [
* { phx_ref: '2', phx_ref_prev: '1' user_id: 1 },
* { phx_ref: '3', user_id: 2 }
* ]
* }
* })
*
* @internal
*/
private static transformState(
state: RawPresenceState | RealtimePresenceState
): RealtimePresenceState {
state = this.cloneDeep(state)
return Object.getOwnPropertyNames(state).reduce((newState, key) => {
const presences = state[key]
if ('metas' in presences) {
newState[key] = presences.metas.map((presence) => {
presence['presence_ref'] = presence['phx_ref']
delete presence['phx_ref']
delete presence['phx_ref_prev']
return presence
}) as Presence[]
} else {
newState[key] = presences
}
return newState
}, {} as RealtimePresenceState)
}
/** @internal */
private static cloneDeep(obj: { [key: string]: any }) {
return JSON.parse(JSON.stringify(obj))
}
/** @internal */
private onJoin(callback: PresenceOnJoinCallback): void {
this.caller.onJoin = callback
}
/** @internal */
private onLeave(callback: PresenceOnLeaveCallback): void {
this.caller.onLeave = callback
}
/** @internal */
private onSync(callback: () => void): void {
this.caller.onSync = callback
}
/** @internal */
private inPendingSyncState(): boolean {
return !this.joinRef || this.joinRef !== this.channel._joinRef()
}
}