diff --git a/__mocks__/ably/promises/index.ts b/__mocks__/ably/promises/index.ts index cfaa5706..30e57b77 100644 --- a/__mocks__/ably/promises/index.ts +++ b/__mocks__/ably/promises/index.ts @@ -11,8 +11,8 @@ const mockPresence = { enter: methodReturningVoidPromise, leave: methodReturningVoidPromise, subscriptions: { - once: async (_, fn) => { - return await fn(); + once: async (_: unknown, fn: Function) => { + fn(); }, }, subscribe: () => {}, @@ -51,8 +51,11 @@ class MockRealtime { }; public connection: { id?: string; + state: string; }; + public time() {} + constructor() { this.channels = { get: () => mockChannel, @@ -62,7 +65,10 @@ class MockRealtime { }; this.connection = { id: '1', + state: 'connected', }; + + this['options'] = {}; } } diff --git a/__mocks__/nanoid/index.ts b/__mocks__/nanoid/index.ts new file mode 100644 index 00000000..8850e4ac --- /dev/null +++ b/__mocks__/nanoid/index.ts @@ -0,0 +1,7 @@ +const nanoidId = 'NanoidID'; + +function nanoid(): string { + return nanoidId; +} + +export { nanoid, nanoidId }; diff --git a/src/CursorBatching.ts b/src/CursorBatching.ts index 66ca5c69..de085c9c 100644 --- a/src/CursorBatching.ts +++ b/src/CursorBatching.ts @@ -1,8 +1,9 @@ import { Types } from 'ably'; -import { CursorUpdate } from './Cursors.js'; -import { CURSOR_UPDATE } from './utilities/Constants.js'; -import type { StrictCursorsOptions } from './options/CursorsOptions.js'; +import { CURSOR_UPDATE } from './utilities/constants.js'; + +import type { CursorUpdate } from './types.js'; +import type { CursorsOptions } from './types.js'; type OutgoingBuffer = Pick[]; @@ -20,7 +21,7 @@ export default class CursorBatching { // Set to `true` if there is more than one user listening to cursors shouldSend: boolean = false; - constructor(readonly outboundBatchInterval: StrictCursorsOptions['outboundBatchInterval']) { + constructor(readonly outboundBatchInterval: CursorsOptions['outboundBatchInterval']) { this.batchTime = outboundBatchInterval; } diff --git a/src/CursorHistory.ts b/src/CursorHistory.ts index 04474e8b..d99d86a6 100644 --- a/src/CursorHistory.ts +++ b/src/CursorHistory.ts @@ -1,7 +1,7 @@ import { Types } from 'ably'; -import type { CursorUpdate } from './Cursors.js'; -import type { StrictCursorsOptions } from './options/CursorsOptions.js'; +import type { CursorUpdate } from './types.js'; +import type { CursorsOptions } from './types.js'; type ConnectionId = string; type ConnectionsLastPosition = Record; @@ -47,7 +47,7 @@ export default class CursorHistory { async getLastCursorUpdate( channel: Types.RealtimeChannelPromise, - paginationLimit: StrictCursorsOptions['paginationLimit'], + paginationLimit: CursorsOptions['paginationLimit'], ): Promise { const members = await channel.presence.get(); diff --git a/src/Cursors.mockClient.test.ts b/src/Cursors.test.ts similarity index 96% rename from src/Cursors.mockClient.test.ts rename to src/Cursors.test.ts index 4496f797..29b63d80 100644 --- a/src/Cursors.mockClient.test.ts +++ b/src/Cursors.test.ts @@ -1,14 +1,16 @@ import { it, describe, expect, vi, beforeEach, vitest, afterEach } from 'vitest'; import { Realtime, Types } from 'ably/promises'; -import Space, { SpaceMember } from './Space.js'; +import Space from './Space.js'; import Cursors from './Cursors.js'; import { createPresenceMessage } from './utilities/test/fakes.js'; import CursorBatching from './CursorBatching.js'; -import { CURSOR_UPDATE } from './utilities/Constants.js'; +import { CURSOR_UPDATE } from './utilities/constants.js'; import CursorDispensing from './CursorDispensing.js'; import CursorHistory from './CursorHistory.js'; -import type { CursorUpdate } from './Cursors.js'; +import type { CursorUpdate, SpaceMember } from './types.js'; + +import type { RealtimeMessage } from './utilities/types.js'; interface CursorsTestContext { client: Types.RealtimePromise; @@ -18,9 +20,9 @@ interface CursorsTestContext { batching: CursorBatching; dispensing: CursorDispensing; history: CursorHistory; - fakeMessageStub: Types.Message; selfStub: SpaceMember; lastCursorPositionsStub: Record; + fakeMessageStub: RealtimeMessage; } vi.mock('ably/promises'); @@ -29,7 +31,7 @@ function createPresenceCount(length: number) { return async () => Array.from({ length }, (_, i) => createPresenceMessage('enter', { clientId: '' + i })); } -describe('Cursors (mockClient)', () => { +describe('Cursors', () => { beforeEach((context) => { const client = new Realtime({}); context.client = client; @@ -470,7 +472,7 @@ describe('Cursors (mockClient)', () => { selfStub, }) => { vi.spyOn(space.cursors, 'getAll').mockImplementation(async () => lastCursorPositionsStub); - vi.spyOn(space, 'getSelf').mockReturnValue(selfStub); + vi.spyOn(space.members, 'getSelf').mockReturnValue(selfStub); const selfCursor = await space.cursors.getSelf(); expect(selfCursor).toEqual(lastCursorPositionsStub['connectionId1']); @@ -478,7 +480,7 @@ describe('Cursors (mockClient)', () => { it('returns an empty object if self is not present in cursors', async ({ space }) => { vi.spyOn(space.cursors, 'getAll').mockResolvedValue({}); - vi.spyOn(space, 'getSelf').mockReturnValue(undefined); + vi.spyOn(space.members, 'getSelf').mockReturnValue(undefined); const others = await space.cursors.getOthers(); expect(others).toEqual({}); @@ -500,7 +502,7 @@ describe('Cursors (mockClient)', () => { }; vi.spyOn(space.cursors, 'getAll').mockResolvedValue(onlyMyCursor); - vi.spyOn(space, 'getSelf').mockReturnValue(selfStub); + vi.spyOn(space.members, 'getSelf').mockReturnValue(selfStub); const others = await space.cursors.getOthers(); expect(others).toEqual({}); @@ -512,7 +514,7 @@ describe('Cursors (mockClient)', () => { lastCursorPositionsStub, }) => { vi.spyOn(space.cursors, 'getAll').mockResolvedValue(lastCursorPositionsStub); - vi.spyOn(space, 'getSelf').mockReturnValue(selfStub); + vi.spyOn(space.members, 'getSelf').mockReturnValue(selfStub); const others = await space.cursors.getOthers(); expect(others).toEqual({ diff --git a/src/Cursors.ts b/src/Cursors.ts index 15c12825..d9812cc9 100644 --- a/src/Cursors.ts +++ b/src/Cursors.ts @@ -3,7 +3,6 @@ import { Types } from 'ably'; import Space from './Space.js'; import CursorBatching from './CursorBatching.js'; import CursorDispensing from './CursorDispensing.js'; -import { OUTGOING_BATCH_TIME_DEFAULT, PAGINATION_LIMIT_DEFAULT } from './utilities/Constants.js'; import EventEmitter, { InvalidArgumentError, inspect, @@ -11,13 +10,13 @@ import EventEmitter, { type EventListener, } from './utilities/EventEmitter.js'; import CursorHistory from './CursorHistory.js'; -import { CURSOR_UPDATE } from './utilities/Constants.js'; +import { CURSOR_UPDATE } from './utilities/constants.js'; -import type { CursorUpdate } from './types.js'; +import type { CursorsOptions, CursorUpdate } from './types.js'; import type { RealtimeMessage } from './utilities/types.js'; type CursorsEventMap = { - cursorsUpdate: Record; + cursorsUpdate: CursorUpdate; }; const emitterHasListeners = (emitter) => { @@ -39,15 +38,12 @@ export default class Cursors extends EventEmitter { private readonly cursorDispensing: CursorDispensing; private readonly cursorHistory: CursorHistory; private channel?: Types.RealtimeChannelPromise; - readonly options: StrictCursorsOptions; + readonly options: CursorsOptions; - constructor(private space: Space, options: CursorsOptions = {}) { + constructor(private space: Space) { super(); - this.options = { - outboundBatchInterval: options['outboundBatchInterval'] ?? OUTGOING_BATCH_TIME_DEFAULT, - paginationLimit: options['paginationLimit'] ?? PAGINATION_LIMIT_DEFAULT, - }; + this.options = this.space.options.cursors; this.cursorHistory = new CursorHistory(); this.cursorBatching = new CursorBatching(this.options.outboundBatchInterval); @@ -64,7 +60,7 @@ export default class Cursors extends EventEmitter { * @return {void} */ set(cursor: Pick): void { - const self = this.space.getSelf(); + const self = this.space.members.getSelf(); if (!self) { throw new Error('Must enter a space before setting a cursor update'); @@ -79,7 +75,7 @@ export default class Cursors extends EventEmitter { } private initializeCursorsChannel(): Types.RealtimeChannelPromise { - const spaceChannelName = this.space.getChannelName(); + const spaceChannelName = this.space.channelName; const channel = this.space.client.channels.get(`${spaceChannelName}_cursors`); channel.presence.subscribe(this.onPresenceUpdate.bind(this)); channel.presence.enter(); @@ -147,13 +143,8 @@ export default class Cursors extends EventEmitter { } } - async getAll() { - const channel = this.getChannel(); - return await this.cursorHistory.getLastCursorUpdate(channel, this.options.paginationLimit); - } - async getSelf(): Promise { - const self = this.space.getSelf(); + const self = this.space.members.getSelf(); if (!self) return; const allCursors = await this.getAll(); @@ -161,7 +152,7 @@ export default class Cursors extends EventEmitter { } async getOthers(): Promise> { - const self = this.space.getSelf(); + const self = this.space.members.getSelf(); if (!self) return {}; const allCursors = await this.getAll(); @@ -169,4 +160,9 @@ export default class Cursors extends EventEmitter { delete allCursorsFiltered[self.connectionId]; return allCursorsFiltered; } + + async getAll() { + const channel = this.getChannel(); + return await this.cursorHistory.getLastCursorUpdate(channel, this.options.paginationLimit); + } } diff --git a/src/Leavers.ts b/src/Leavers.ts new file mode 100644 index 00000000..dc2f6557 --- /dev/null +++ b/src/Leavers.ts @@ -0,0 +1,47 @@ +import type Space from './Space.js'; +import type { SpaceMember } from './types.js'; + +type SpaceLeaver = SpaceMember & { + timeoutId: ReturnType; +}; + +class Leavers { + private leavers: SpaceLeaver[] = []; + + constructor(private space: Space) {} + + getByConnectionId(connectionId: string): SpaceLeaver | undefined { + return this.leavers.find((leaver) => leaver.connectionId === connectionId); + } + + addLeaver(connectionId: string) { + const timeoutCallback = () => { + this.space.members.removeMember(connectionId); + }; + + const member = this.space.members.getByConnectionId(connectionId); + + if (member) { + this.leavers.push({ + ...member, + timeoutId: setTimeout(timeoutCallback, this.space.options.offlineTimeout), + }); + } + } + + removeLeaver(connectionId: string) { + const leaverIndex = this.leavers.findIndex((leaver) => leaver.connectionId === connectionId); + + if (leaverIndex >= 0) { + clearTimeout(this.leavers[leaverIndex].timeoutId); + this.leavers.splice(leaverIndex, 1); + } + } + + refreshTimeout(connectionId: string) { + this.removeLeaver(connectionId); + this.addLeaver(connectionId); + } +} + +export default Leavers; diff --git a/src/Locations.mockClient.test.ts b/src/Locations.test.ts similarity index 51% rename from src/Locations.mockClient.test.ts rename to src/Locations.test.ts index 7f4f628d..0f3ada4a 100644 --- a/src/Locations.mockClient.test.ts +++ b/src/Locations.test.ts @@ -1,9 +1,9 @@ import { it, describe, expect, vi, beforeEach } from 'vitest'; import { Realtime, Types } from 'ably/promises'; -import Space, { SpaceMember } from './Space.js'; -import { createPresenceMessage } from './utilities/test/fakes.js'; -import { LOCATION_UPDATE } from './utilities/Constants.js'; +import Space from './Space.js'; + +import { createPresenceMessage, createLocationUpdate, createSpaceMember } from './utilities/test/fakes.js'; interface SpaceTestContext { client: Types.RealtimePromise; @@ -12,8 +12,9 @@ interface SpaceTestContext { } vi.mock('ably/promises'); +vi.mock('nanoid'); -describe('Locations (mockClient)', () => { +describe('Locations', () => { beforeEach((context) => { const client = new Realtime({}); const presence = client.channels.get('').presence; @@ -37,46 +38,38 @@ describe('Locations (mockClient)', () => { const spy = vi.spyOn(presence, 'update'); await space.enter(); space.locations.set('location1'); - expect(spy).toHaveBeenCalledOnce(); + expect(spy).toHaveBeenCalledWith(createLocationUpdate({ current: 'location1' })); }); it('fires an event when a location is set', async ({ space }) => { const spy = vi.fn(); - await space.enter(); - space.locations.subscribe(LOCATION_UPDATE, spy); - space.locations['onPresenceUpdate']( - createPresenceMessage('update', { clientId: '2', connectionId: '2', data: { location: 'location2' } }), + space.locations.subscribe('update', spy); + space['onPresenceUpdate']( + createPresenceMessage('update', { + data: createLocationUpdate({ current: 'location1' }), + }), ); expect(spy).toHaveBeenCalledOnce(); }); it('correctly sets previousLocation', async ({ space }) => { const spy = vi.fn(); - await space.enter(); - space.locations.subscribe(LOCATION_UPDATE, spy); - space.locations['onPresenceUpdate']( + space.locations.subscribe('update', spy); + + space['onPresenceUpdate']( createPresenceMessage('update', { - clientId: '2', - connectionId: '2', - data: { currentLocation: 'location1', previousLocation: null }, + data: createLocationUpdate({ current: 'location1' }), }), ); - space.locations['onPresenceUpdate']( + + space['onPresenceUpdate']( createPresenceMessage('update', { - clientId: '2', - connectionId: '2', - data: { currentLocation: 'location2', previousLocation: 'location1' }, + data: createLocationUpdate({ current: 'location2', previous: 'location1', id: 'newId' }), }), ); - expect(spy).toHaveBeenLastCalledWith<{ member: SpaceMember; currentLocation: any; previousLocation: any }[]>({ - member: { - clientId: '2', - connectionId: '2', - isConnected: true, - profileData: { a: 1 }, - location: 'location2', - lastEvent: { name: 'update', timestamp: 1 }, - }, + + expect(spy).toHaveBeenLastCalledWith({ + member: createSpaceMember({ location: 'location2' }), currentLocation: 'location2', previousLocation: 'location1', }); @@ -85,41 +78,44 @@ describe('Locations (mockClient)', () => { describe('location getters', () => { it('getSelf returns the location only for self', async ({ space }) => { - await space.enter(); - space.locations['onPresenceUpdate']( - createPresenceMessage('update', { data: { currentLocation: 'location33', previousLocation: null } }), + space['onPresenceUpdate']( + createPresenceMessage('update', { + data: createLocationUpdate({ current: 'location1' }), + }), ); - expect(space.locations.getSelf()).toEqual('location33'); + expect(space.locations.getSelf()).toEqual('location1'); }); it('getOthers returns the locations only for others', async ({ space }) => { - await space.enter(); - space.locations['onPresenceUpdate']( - createPresenceMessage('update', { data: { currentLocation: '23', previousLocation: null } }), + space['onPresenceUpdate']( + createPresenceMessage('update', { data: createLocationUpdate({ current: 'location1' }) }), ); - space.locations['onPresenceUpdate']( + + space['onPresenceUpdate']( createPresenceMessage('update', { connectionId: '2', - data: { currentLocation: 'location22', previousLocation: null }, + data: createLocationUpdate({ current: 'location2' }), }), ); + const othersLocations = space.locations.getOthers(); - expect(othersLocations).toEqual({ '2': 'location22' }); + expect(othersLocations).toEqual({ '2': 'location2' }); }); it('getAll returns the locations for self and others', async ({ space }) => { - await space.enter(); - space.locations['onPresenceUpdate']( - createPresenceMessage('update', { data: { currentLocation: 'location11', previousLocation: null } }), + space['onPresenceUpdate']( + createPresenceMessage('update', { data: createLocationUpdate({ current: 'location1' }) }), ); - space.locations['onPresenceUpdate']( + + space['onPresenceUpdate']( createPresenceMessage('update', { connectionId: '2', - data: { currentLocation: 'location22', previousLocation: null }, + data: createLocationUpdate({ current: 'location2' }), }), ); + const allLocations = space.locations.getAll(); - expect(allLocations).toEqual({ '1': 'location11', '2': 'location22' }); + expect(allLocations).toEqual({ '1': 'location1', '2': 'location2' }); }); }); }); diff --git a/src/Locations.ts b/src/Locations.ts index f8839ce0..2df2101c 100644 --- a/src/Locations.ts +++ b/src/Locations.ts @@ -1,52 +1,80 @@ -import { Types } from 'ably'; +import { nanoid } from 'nanoid'; -import Space from './Space.js'; import EventEmitter, { InvalidArgumentError, inspect, type EventKey, type EventListener, } from './utilities/EventEmitter.js'; -import { LOCATION_UPDATE } from './utilities/Constants.js'; -type LocationUpdate = typeof LOCATION_UPDATE; +import type { SpaceMember } from './types.js'; +import type { PresenceMember } from './utilities/types.js'; +import type Space from './Space.js'; -type LocationEventMap = Record; +type LocationsEventMap = { + update: { member: SpaceMember; currentLocation: unknown; previousLocation: unknown }; +}; -export default class Locations extends EventEmitter { - constructor(public space: Space, private channel: Types.RealtimeChannelPromise) { +export default class Locations extends EventEmitter { + private lastLocationUpdate: Record = {}; + + constructor(private space: Space, private presenceUpdate: (update: PresenceMember['data']) => Promise) { super(); - this.channel.presence.subscribe(this.onPresenceUpdate.bind(this)); } - private onPresenceUpdate(message: Types.PresenceMessage) { - if (!['update', 'leave'].includes(message.action)) return; + processPresenceMessage(message: PresenceMember) { + // Only an update action is currently a valid location update. + if (message.action !== 'update') return; + + // Emit updates only if they are different then the last held update. + if ( + !message.data.locationUpdate.id || + this.lastLocationUpdate[message.connectionId] === message.data.locationUpdate.id + ) { + return; + } + + const update = message.data.locationUpdate; - const member = this.space.getMemberFromConnection(message.connectionId); + const { previous } = update; + const member = this.space.members.getByConnectionId(message.connectionId); if (member) { - const { previousLocation, currentLocation } = message.data; - member.location = currentLocation; - this.emit(LOCATION_UPDATE, { member: { ...member }, currentLocation, previousLocation }); + this.emit('update', { + member, + currentLocation: member.location, + previousLocation: previous, + }); + + this.lastLocationUpdate[message.connectionId] = message.data.locationUpdate.id; } } set(location: unknown) { - const self = this.space.getSelf(); + const self = this.space.members.getSelf(); + if (!self) { - throw new Error('Must enter a space before setting a location'); + throw new Error('You must enter a space before setting a location.'); } - return this.channel.presence.update({ - profileData: self.profileData, - previousLocation: self.location, - currentLocation: location, - }); + const update: PresenceMember['data'] = { + profileUpdate: { + id: null, + current: self.profileData, + }, + locationUpdate: { + id: nanoid(), + previous: self.location, + current: location, + }, + }; + + return this.presenceUpdate(update); } - subscribe>( - listenerOrEvents?: K | K[] | EventListener, - listener?: EventListener, + subscribe>( + listenerOrEvents?: K | K[] | EventListener, + listener?: EventListener, ) { try { super.on(listenerOrEvents, listener); @@ -61,9 +89,9 @@ export default class Locations extends EventEmitter { } } - unsubscribe>( - listenerOrEvents?: K | K[] | EventListener, - listener?: EventListener, + unsubscribe>( + listenerOrEvents?: K | K[] | EventListener, + listener?: EventListener, ) { try { super.off(listenerOrEvents, listener); @@ -78,16 +106,16 @@ export default class Locations extends EventEmitter { } } - getSelf(): Location | undefined { - const self = this.space.getSelf(); - return self ? self.location : undefined; + getSelf(): unknown { + const self = this.space.members.getSelf(); + return self ? self.location : null; } - getOthers(): Record { - const self = this.space.getSelf(); + getOthers(): Record { + const self = this.space.members.getSelf(); - return this.space - .getMembers() + return this.space.members + .getAll() .filter((member) => member.connectionId !== self?.connectionId) .reduce((acc, member) => { acc[member.connectionId] = member.location; @@ -95,8 +123,8 @@ export default class Locations extends EventEmitter { }, {}); } - getAll(): Record { - return this.space.getMembers().reduce((acc, member) => { + getAll(): Record { + return this.space.members.getAll().reduce((acc, member) => { acc[member.connectionId] = member.location; return acc; }, {}); diff --git a/src/Members.ts b/src/Members.ts new file mode 100644 index 00000000..673de015 --- /dev/null +++ b/src/Members.ts @@ -0,0 +1,154 @@ +import EventEmitter, { + InvalidArgumentError, + inspect, + type EventKey, + type EventListener, +} from './utilities/EventEmitter.js'; +import Leavers from './Leavers.js'; + +import type { SpaceMember } from './types.js'; +import type { PresenceMember } from './utilities/types.js'; +import type Space from './Space.js'; + +type MemberEventsMap = { + leave: SpaceMember; + enter: SpaceMember; + update: SpaceMember; + remove: SpaceMember; +}; + +class Members extends EventEmitter { + private lastMemberUpdate: Record = {}; + private leavers: Leavers; + + members: SpaceMember[] = []; + + constructor(private space: Space) { + super(); + this.leavers = new Leavers(this.space); + } + + processPresenceMessage(message: PresenceMember) { + const { action, connectionId } = message; + const isLeaver = !!this.leavers.getByConnectionId(connectionId); + const isMember = !!this.getByConnectionId(connectionId); + const memberUpdate = this.createMember(message); + + if (action === 'leave' && !isLeaver) { + this.leavers.addLeaver(connectionId); + this.emit('leave', memberUpdate); + } else if (action === 'leave' && isLeaver) { + this.leavers.refreshTimeout(connectionId); + this.emit('leave', memberUpdate); + } else if (isLeaver) { + this.leavers.removeLeaver(connectionId); + } + + if (!isMember) { + this.members.push(memberUpdate); + this.emit('enter', memberUpdate); + } else if (['enter', 'update', 'leave'].includes(action) && isMember) { + const index = this.members.findIndex((m) => m.connectionId === connectionId); + this.members[index] = memberUpdate; + } + + // Emit profileData updates only if they are different then the last held update. + // A locationUpdate is handled in Locations. + if (message.data.profileUpdate.id && this.lastMemberUpdate[connectionId] !== message.data.profileUpdate.id) { + this.lastMemberUpdate[message.connectionId] = message.data.locationUpdate.id; + this.emit('update', memberUpdate); + } + } + + getSelf(): SpaceMember | undefined { + return this.space.connectionId ? this.getByConnectionId(this.space.connectionId) : undefined; + } + + getAll(): SpaceMember[] { + return this.members; + } + + getOthers(): SpaceMember[] { + return this.members.filter((m) => m.connectionId !== this.space.connectionId); + } + + subscribe>( + listenerOrEvents?: K | K[] | EventListener, + listener?: EventListener, + ) { + try { + super.on(listenerOrEvents, listener); + } catch (e: unknown) { + if (e instanceof InvalidArgumentError) { + throw new InvalidArgumentError( + 'Members.subscribe(): Invalid arguments: ' + inspect([listenerOrEvents, listener]), + ); + } else { + throw e; + } + } + } + + unsubscribe>( + listenerOrEvents?: K | K[] | EventListener, + listener?: EventListener, + ) { + try { + super.off(listenerOrEvents, listener); + } catch (e: unknown) { + if (e instanceof InvalidArgumentError) { + throw new InvalidArgumentError( + 'Members.unsubscribe(): Invalid arguments: ' + inspect([listenerOrEvents, listener]), + ); + } else { + throw e; + } + } + } + + mapPresenceMembersToSpaceMembers(messages: PresenceMember[]) { + const members = messages.map((message) => this.createMember(message)); + this.members = [...members]; + return members; + } + + getByConnectionId(connectionId: string): SpaceMember | undefined { + return this.members.find((m) => m.connectionId === connectionId); + } + + createMember(message: PresenceMember): SpaceMember { + return { + clientId: message.clientId, + connectionId: message.connectionId, + isConnected: message.action !== 'leave', + profileData: message.data.profileUpdate.current, + location: message.data.locationUpdate.current, + lastEvent: { + name: message.action, + timestamp: message.timestamp, + }, + }; + } + + removeMember(connectionId: string): void { + const index = this.members.findIndex((m) => m.connectionId === connectionId); + + if (index >= 0) { + const member = this.members.splice(index, 1)[0]; + + this.emit('remove', member); + + if (member.location) { + this.space.locations.emit('update', { + previousLocation: member.location, + currentLocation: null, + member: { ...member, location: null }, + }); + } + + this.space.emit('update', { members: this.getAll() }); + } + } +} + +export default Members; diff --git a/src/Space.mockClient.test.ts b/src/Space.mockClient.test.ts deleted file mode 100644 index 1ee6056c..00000000 --- a/src/Space.mockClient.test.ts +++ /dev/null @@ -1,504 +0,0 @@ -import { it, describe, expect, vi, beforeEach, expectTypeOf, afterEach } from 'vitest'; -import { Realtime, Types } from 'ably/promises'; - -import Space, { SpaceMember } from './Space.js'; -import { createPresenceEvent, createPresenceMessage } from './utilities/test/fakes.js'; -import Locations from './Locations.js'; -import Cursors from './Cursors.js'; -import { MEMBERS_UPDATE } from './utilities/Constants.js'; - -interface SpaceTestContext { - client: Types.RealtimePromise; - space: Space; - presence: Types.RealtimePresencePromise; -} - -vi.mock('ably/promises'); - -describe('Space (mockClient)', () => { - beforeEach((context) => { - const client = new Realtime({}); - const presence = client.channels.get('').presence; - - context.client = client; - context.space = new Space('test', client); - context.presence = presence; - }); - - describe('get', () => { - it('creates a space with the correct name', ({ client }) => { - const channels = client.channels; - const channelSpy = vi.spyOn(channels, 'get'); - const space = new Space('test', client); - - expect(channelSpy).toHaveBeenNthCalledWith(1, '_ably_space_test'); - expectTypeOf(space).toMatchTypeOf(); - }); - }); - - describe('enter', () => { - it('enter a space successfully', async ({ presence, space }) => { - const spy = vi.spyOn(presence, 'enter').mockResolvedValueOnce(); - await space.enter({ a: 1 }); - expect(spy).toHaveBeenNthCalledWith(1, { profileData: { a: 1 } }); - }); - - it('returns current space members', async ({ presence, space }) => { - vi.spyOn(presence, 'get').mockImplementationOnce(async () => [ - createPresenceMessage('enter'), - createPresenceMessage('update', { clientId: '2', connectionId: '2' }), - ]); - const spaceMembers = await space.enter(); - expect(spaceMembers).toEqual([ - { - clientId: '1', - connectionId: '1', - isConnected: true, - profileData: {}, - location: null, - lastEvent: { name: 'enter', timestamp: 1 }, - }, - { - clientId: '2', - connectionId: '2', - isConnected: true, - profileData: { a: 1 }, - location: null, - lastEvent: { name: 'update', timestamp: 1 }, - }, - ]); - }); - - it('retrieves active space members by connection', async ({ presence, space }) => { - vi.spyOn(presence, 'get').mockImplementationOnce(async () => [ - createPresenceMessage('enter', { connectionId: 'testConnectionId' }), - ]); - await space.enter(); - const member = space.getMemberFromConnection('testConnectionId'); - expect(member).toEqual({ - clientId: '1', - connectionId: 'testConnectionId', - isConnected: true, - location: null, - lastEvent: { - name: 'enter', - timestamp: 1, - }, - profileData: {}, - }); - const noMember = space.getMemberFromConnection('nonExistentConnectionId'); - expect(noMember).toBe(undefined); - }); - }); - - describe('updateProfileData', () => { - describe('did not enter', () => { - it('enter & update profileData successfully', async ({ presence, space }) => { - const enterSpy = vi.spyOn(presence, 'enter'); - const updateSpy = vi.spyOn(presence, 'update'); - await space.updateProfileData({ a: 1 }); - expect(enterSpy).toHaveBeenNthCalledWith(1, { profileData: { a: 1 } }); - expect(updateSpy).not.toHaveBeenCalled(); - }); - - it('enter & update profileData with function successfully', async ({ presence, space }) => { - const enterSpy = vi.spyOn(presence, 'enter'); - const updateSpy = vi.spyOn(presence, 'update'); - await space.updateProfileData((profileData) => ({ ...profileData, a: 1 })); - expect(enterSpy).toHaveBeenNthCalledWith(1, { profileData: { a: 1 } }); - expect(updateSpy).not.toHaveBeenCalled(); - }); - }); - - describe('did enter', () => { - it('update profileData successfully', async ({ presence, space }) => { - vi.spyOn(space, 'getSelf').mockResolvedValueOnce({ - clientId: '1', - connectionId: 'testConnectionId', - isConnected: true, - location: null, - lastEvent: { - name: 'enter', - timestamp: 1, - }, - profileData: { - a: 1, - }, - }); - const updateSpy = vi.spyOn(presence, 'update'); - await space.updateProfileData({ a: 2 }); - expect(updateSpy).toHaveBeenNthCalledWith(1, { profileData: { a: 2 } }); - }); - - it('enter & update profileData with function successfully', async ({ presence, space }) => { - vi.spyOn(space, 'getSelf').mockResolvedValueOnce({ - clientId: '1', - connectionId: 'testConnectionId', - isConnected: true, - location: null, - lastEvent: { - name: 'enter', - timestamp: 1, - }, - profileData: { - a: 1, - }, - }); - const updateSpy = vi.spyOn(presence, 'update'); - await space.updateProfileData((profileData) => ({ ...profileData, a: 2 })); - expect(updateSpy).toHaveBeenNthCalledWith(1, { profileData: { a: 2 } }); - }); - }); - }); - - describe('leave', () => { - it('leaves a space successfully', async ({ presence, space }) => { - const spy = vi.spyOn(presence, 'leave'); - await space.leave(); - expect(spy).toHaveBeenCalledOnce(); - }); - }); - - describe('subscribe', () => { - it('subscribes to presence updates', async () => { - const client = new Realtime({}); - const presence = client.channels.get('').presence; - const spy = vi.spyOn(presence, 'subscribe'); - new Space('test', client); - // Called by Space instantiation and by Locations instantiation - expect(spy).toHaveBeenCalledTimes(2); - }); - - it('does not include the connected client in the members result', async ({ space, client }) => { - const spy = vi.fn(); - space['onPresenceUpdate'](createPresenceMessage('enter', { clientId: client.auth.clientId })); - space.subscribe(MEMBERS_UPDATE, spy); - expect(spy).not.toHaveBeenCalled(); - }); - - it('adds new members', async ({ space }) => { - const callbackSpy = vi.fn(); - space.subscribe(MEMBERS_UPDATE, callbackSpy); - - createPresenceEvent(space, 'enter'); - - expect(callbackSpy).toHaveBeenNthCalledWith(1, [ - { - clientId: '1', - connectionId: '1', - profileData: {}, - isConnected: true, - location: null, - lastEvent: { name: 'enter', timestamp: 1 }, - }, - ]); - - createPresenceEvent(space, 'enter', { clientId: '2', connectionId: '2', data: { profileData: { a: 1 } } }); - expect(callbackSpy).toHaveBeenNthCalledWith(1, [ - { - clientId: '1', - connectionId: '1', - profileData: {}, - isConnected: true, - location: null, - lastEvent: { name: 'enter', timestamp: 1 }, - }, - { - clientId: '2', - connectionId: '2', - profileData: { a: 1 }, - isConnected: true, - location: null, - lastEvent: { name: 'enter', timestamp: 1 }, - }, - ]); - }); - - it('updates the data of members', async ({ space }) => { - const callbackSpy = vi.fn(); - space.subscribe(MEMBERS_UPDATE, callbackSpy); - - createPresenceEvent(space, 'enter'); - expect(callbackSpy).toHaveBeenNthCalledWith(1, [ - { - clientId: '1', - connectionId: '1', - profileData: {}, - isConnected: true, - location: null, - lastEvent: { name: 'enter', timestamp: 1 }, - }, - ]); - - createPresenceEvent(space, 'enter', { data: { profileData: { a: 1 } } }); - expect(callbackSpy).toHaveBeenNthCalledWith(1, [ - { - clientId: '1', - connectionId: '1', - profileData: { a: 1 }, - isConnected: true, - location: null, - lastEvent: { name: 'enter', timestamp: 1 }, - }, - ]); - }); - - it('updates the connected status of clients who have left', async ({ space }) => { - const callbackSpy = vi.fn(); - space.subscribe(MEMBERS_UPDATE, callbackSpy); - - createPresenceEvent(space, 'enter'); - expect(callbackSpy).toHaveBeenNthCalledWith(1, [ - { - clientId: '1', - connectionId: '1', - profileData: {}, - isConnected: true, - location: null, - lastEvent: { name: 'enter', timestamp: 1 }, - }, - ]); - - createPresenceEvent(space, 'leave'); - expect(callbackSpy).toHaveBeenNthCalledWith(1, [ - { - clientId: '1', - connectionId: '1', - profileData: {}, - isConnected: false, - location: null, - lastEvent: { name: 'leave', timestamp: 1 }, - }, - ]); - }); - - it('fires an enter message on join', async ({ space }) => { - const callbackSpy = vi.fn(); - space.subscribe('enter', callbackSpy); - - createPresenceEvent(space, 'enter'); - - expect(callbackSpy).toHaveBeenNthCalledWith(1, { - clientId: '1', - connectionId: '1', - profileData: {}, - isConnected: true, - location: null, - lastEvent: { name: 'enter', timestamp: 1 }, - }); - }); - - describe('leavers', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('removes a member who has left after the offlineTimeout', async ({ space }) => { - const callbackSpy = vi.fn(); - space.subscribe(MEMBERS_UPDATE, callbackSpy); - - createPresenceEvent(space, 'enter'); - expect(callbackSpy).toHaveBeenNthCalledWith(1, [ - { - clientId: '1', - connectionId: '1', - profileData: {}, - isConnected: true, - location: null, - lastEvent: { name: 'enter', timestamp: 1 }, - }, - ]); - - createPresenceEvent(space, 'leave'); - expect(callbackSpy).toHaveBeenNthCalledWith(1, [ - { - clientId: '1', - connectionId: '1', - profileData: {}, - isConnected: false, - location: null, - lastEvent: { name: 'leave', timestamp: 1 }, - }, - ]); - - vi.advanceTimersByTime(130_000); - - expect(callbackSpy).toHaveBeenNthCalledWith(1, []); - expect(callbackSpy).toHaveBeenCalledTimes(3); - }); - - it('sends a leave event after offlineTimeout', async ({ space }) => { - const callbackSpy = vi.fn(); - space.subscribe('leave', callbackSpy); - - createPresenceEvent(space, 'enter'); - createPresenceEvent(space, 'leave'); - - vi.advanceTimersByTime(130_000); - - expect(callbackSpy).toHaveBeenNthCalledWith(1, { - clientId: '1', - connectionId: '1', - profileData: {}, - isConnected: false, - location: null, - lastEvent: { name: 'leave', timestamp: 1 }, - }); - }); - - it('does not remove a member that has rejoined', async ({ space }) => { - const callbackSpy = vi.fn(); - space.subscribe(MEMBERS_UPDATE, callbackSpy); - - createPresenceEvent(space, 'enter'); - createPresenceEvent(space, 'enter', { clientId: '2', connectionId: '2' }); - expect(callbackSpy).toHaveBeenNthCalledWith(1, [ - { - clientId: '1', - connectionId: '1', - profileData: {}, - isConnected: true, - location: null, - lastEvent: { name: 'enter', timestamp: 1 }, - }, - { - clientId: '2', - connectionId: '2', - profileData: {}, - isConnected: true, - location: null, - lastEvent: { name: 'enter', timestamp: 1 }, - }, - ]); - - createPresenceEvent(space, 'leave'); - expect(callbackSpy).toHaveBeenNthCalledWith(1, [ - { - clientId: '1', - connectionId: '1', - profileData: {}, - isConnected: false, - location: null, - lastEvent: { name: 'leave', timestamp: 1 }, - }, - { - clientId: '2', - connectionId: '2', - profileData: {}, - isConnected: true, - location: null, - lastEvent: { name: 'enter', timestamp: 1 }, - }, - ]); - - vi.advanceTimersByTime(60_000); - createPresenceEvent(space, 'enter'); - expect(callbackSpy).toHaveBeenNthCalledWith(1, [ - { - clientId: '1', - connectionId: '1', - profileData: {}, - isConnected: true, - location: null, - lastEvent: { name: 'enter', timestamp: 1 }, - }, - { - clientId: '2', - connectionId: '2', - profileData: {}, - isConnected: true, - location: null, - lastEvent: { name: 'enter', timestamp: 1 }, - }, - ]); - - vi.advanceTimersByTime(70_000); // 2:10 passed, default timeout is 2 min - expect(callbackSpy).toHaveBeenNthCalledWith(1, [ - { - clientId: '1', - connectionId: '1', - profileData: {}, - isConnected: true, - location: null, - lastEvent: { name: 'enter', timestamp: 1 }, - }, - { - clientId: '2', - connectionId: '2', - profileData: {}, - isConnected: true, - location: null, - lastEvent: { name: 'enter', timestamp: 1 }, - }, - ]); - - expect(callbackSpy).toHaveBeenCalledTimes(4); - }); - - it('unsubscribes when unsubscribe is called', async ({ space }) => { - const spy = vi.fn(); - space.subscribe(MEMBERS_UPDATE, spy); - createPresenceEvent(space, 'enter', { clientId: '123456' }); - space.unsubscribe(MEMBERS_UPDATE, spy); - createPresenceEvent(space, 'enter', { clientId: '123456' }); - - expect(spy).toHaveBeenCalledOnce(); - }); - - it('unsubscribes when unsubscribe is called with no arguments', async ({ space }) => { - const spy = vi.fn(); - space.subscribe(MEMBERS_UPDATE, spy); - createPresenceEvent(space, 'enter', { clientId: '123456' }); - space.unsubscribe(); - createPresenceEvent(space, 'enter', { clientId: '123456' }); - - expect(spy).toHaveBeenCalledOnce(); - }); - - it('emits a location event when a user leaves and when offlineTimeout passes', async ({ - space, - }) => { - const spy = vi.spyOn(space.locations, 'emit'); - - // These share the same connection/client ids - createPresenceEvent(space, 'enter'); - - // Simulate a "set" location for a user - space.locations['onPresenceUpdate']( - createPresenceMessage('update', { data: { previousLocation: null, currentLocation: { location: '1' } } }), - ); - - expect(spy).toHaveBeenCalledTimes(1); - - // We need to mock the message for both space & locations - const msg = createPresenceMessage('leave', { - data: { previousLocation: { location: '1' }, currentLocation: { location: '2' } }, - }); - space['onPresenceUpdate'](msg); - space.locations['onPresenceUpdate'](msg); - - expect(spy).toHaveBeenCalledTimes(2); - - vi.advanceTimersByTime(130_000); - - expect(spy).toHaveBeenCalledTimes(3); - }); - }); - }); - - describe('locations', () => { - it('returns a Locations object', ({ space }) => { - expect(space.locations).toBeInstanceOf(Locations); - }); - }); - - describe('cursors', () => { - it('returns a Cursors object', ({ space }) => { - expect(space.cursors).toBeInstanceOf(Cursors); - }); - }); -}); diff --git a/src/Space.test.ts b/src/Space.test.ts new file mode 100644 index 00000000..5816a4b7 --- /dev/null +++ b/src/Space.test.ts @@ -0,0 +1,307 @@ +import { it, describe, expect, vi, beforeEach, expectTypeOf, afterEach } from 'vitest'; +import { Realtime, Types } from 'ably/promises'; + +import Space from './Space.js'; +import Locations from './Locations.js'; +import Cursors from './Cursors.js'; + +import { + createPresenceEvent, + createPresenceMessage, + createSpaceMember, + createProfileUpdate, +} from './utilities/test/fakes.js'; + +interface SpaceTestContext { + client: Types.RealtimePromise; + space: Space; + presence: Types.RealtimePresencePromise; +} + +vi.mock('ably/promises'); +vi.mock('nanoid'); + +describe('Space', () => { + beforeEach((context) => { + const client = new Realtime({}); + const space = new Space('test', client); + + context.client = client; + context.space = space; + context.presence = space.channel.presence; + }); + + describe('get', () => { + it('creates a space with the correct name', ({ client }) => { + const channels = client.channels; + const channelSpy = vi.spyOn(channels, 'get'); + const space = new Space('test', client); + + expect(channelSpy).toHaveBeenNthCalledWith(1, '_ably_space_test'); + expectTypeOf(space).toMatchTypeOf(); + }); + }); + + describe('enter', () => { + it('enter a space successfully', async ({ space, presence }) => { + const spy = vi.spyOn(presence, 'enter').mockResolvedValueOnce(); + await space.enter({ name: 'Betty' }); + expect(spy).toHaveBeenNthCalledWith(1, createProfileUpdate({ current: { name: 'Betty' } })); + }); + + it('returns current space members', async ({ presence, space }) => { + vi.spyOn(presence, 'get').mockImplementationOnce(async () => [ + createPresenceMessage('enter'), + createPresenceMessage('update', { clientId: '2', connectionId: '2' }), + ]); + + const spaceMembers = await space.enter(); + + expect(spaceMembers).toEqual([ + createSpaceMember({ lastEvent: { name: 'enter', timestamp: 1 } }), + createSpaceMember({ clientId: '2', connectionId: '2', lastEvent: { name: 'update', timestamp: 1 } }), + ]); + }); + + it('retrieves active space members by connection', async ({ presence, space }) => { + vi.spyOn(presence, 'get').mockImplementationOnce(async () => [createPresenceMessage('update')]); + + await space.enter(); + const member = space.members.getByConnectionId('1'); + expect(member).toEqual(createSpaceMember()); + + const noMember = space.members.getByConnectionId('nonExistentConnectionId'); + expect(noMember).toBe(undefined); + }); + }); + + describe('updateProfileData', () => { + describe('did not enter', () => { + it('enter & update profileData successfully', async ({ presence, space }) => { + const enterSpy = vi.spyOn(presence, 'enter'); + const updateSpy = vi.spyOn(presence, 'update'); + await space.updateProfileData({ name: 'Betty' }); + expect(enterSpy).toHaveBeenNthCalledWith(1, createProfileUpdate({ current: { name: 'Betty' } })); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('enter & update profileData with function successfully', async ({ presence, space }) => { + const enterSpy = vi.spyOn(presence, 'enter'); + const updateSpy = vi.spyOn(presence, 'update'); + await space.updateProfileData((profileData) => ({ ...profileData, name: 'Betty' })); + expect(enterSpy).toHaveBeenNthCalledWith(1, createProfileUpdate({ current: { name: 'Betty' } })); + expect(updateSpy).not.toHaveBeenCalled(); + }); + }); + + describe('did enter', () => { + it('update profileData successfully', async ({ presence, space }) => { + vi.spyOn(space.members, 'getSelf').mockResolvedValueOnce(createSpaceMember()); + const updateSpy = vi.spyOn(presence, 'update'); + + await space.updateProfileData({ name: 'Betty' }); + expect(updateSpy).toHaveBeenNthCalledWith(1, createProfileUpdate({ current: { name: 'Betty' } })); + }); + + it('enter & update profileData with function successfully', async ({ presence, space }) => { + vi.spyOn(space.members, 'getSelf').mockResolvedValueOnce(createSpaceMember()); + const updateSpy = vi.spyOn(presence, 'update'); + await space.updateProfileData((profileData) => ({ ...profileData, name: 'Betty' })); + expect(updateSpy).toHaveBeenNthCalledWith(1, createProfileUpdate({ current: { name: 'Betty' } })); + }); + }); + }); + + describe('leave', () => { + it('leaves a space successfully', async ({ presence, space }) => { + vi.spyOn(presence, 'get').mockImplementationOnce(async () => [createPresenceMessage('enter')]); + + await space.enter(); + const spy = vi.spyOn(presence, 'leave'); + await space.leave(); + expect(spy).toHaveBeenCalledOnce(); + }); + }); + + describe('subscribe', () => { + it('subscribes to presence updates', async () => { + const client = new Realtime({}); + const presence = client.channels.get('').presence; + const spy = vi.spyOn(presence, 'subscribe'); + new Space('test', client); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('does not include the connected client in the members result', async ({ space, client }) => { + const spy = vi.fn(); + space['onPresenceUpdate'](createPresenceMessage('enter', { clientId: client.auth.clientId })); + space.subscribe('update', spy); + expect(spy).not.toHaveBeenCalled(); + }); + + it('adds new members', async ({ space }) => { + const callbackSpy = vi.fn(); + space.subscribe('update', callbackSpy); + createPresenceEvent(space, 'enter'); + + const member1 = createSpaceMember({ lastEvent: { name: 'enter', timestamp: 1 } }); + expect(callbackSpy).toHaveBeenNthCalledWith(1, { + members: [member1], + }); + + createPresenceEvent(space, 'enter', { + clientId: '2', + connectionId: '2', + data: createProfileUpdate({ current: { name: 'Betty' } }), + }); + + expect(callbackSpy).toHaveBeenNthCalledWith(1, { + members: [ + member1, + createSpaceMember({ + clientId: '2', + connectionId: '2', + lastEvent: { name: 'enter', timestamp: 1 }, + profileData: { name: 'Betty' }, + }), + ], + }); + }); + + it('updates the data of members', async ({ space }) => { + const callbackSpy = vi.fn(); + space.subscribe('update', callbackSpy); + + createPresenceEvent(space, 'enter'); + expect(callbackSpy).toHaveBeenNthCalledWith(1, { + members: [createSpaceMember({ lastEvent: { name: 'enter', timestamp: 1 } })], + }); + + createPresenceEvent(space, 'update', { + data: createProfileUpdate({ current: { name: 'Betty' } }), + }); + + expect(callbackSpy).toHaveBeenNthCalledWith(1, { + members: [createSpaceMember({ profileData: { name: 'Betty' } })], + }); + }); + + it('updates the connected status of clients who have left', async ({ space }) => { + const callbackSpy = vi.fn(); + space.subscribe('update', callbackSpy); + + createPresenceEvent(space, 'enter'); + expect(callbackSpy).toHaveBeenNthCalledWith(1, { + members: [createSpaceMember({ lastEvent: { name: 'enter', timestamp: 1 } })], + }); + + createPresenceEvent(space, 'leave'); + expect(callbackSpy).toHaveBeenNthCalledWith(1, { + members: [createSpaceMember({ isConnected: false, lastEvent: { name: 'leave', timestamp: 1 } })], + }); + }); + + describe('leavers', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('removes a member who has left after the offlineTimeout', async ({ space }) => { + const callbackSpy = vi.fn(); + space.subscribe('update', callbackSpy); + + createPresenceEvent(space, 'enter'); + expect(callbackSpy).toHaveBeenNthCalledWith(1, { + members: [createSpaceMember({ lastEvent: { name: 'enter', timestamp: 1 } })], + }); + + createPresenceEvent(space, 'leave'); + expect(callbackSpy).toHaveBeenNthCalledWith(1, { + members: [createSpaceMember({ isConnected: false, lastEvent: { name: 'leave', timestamp: 1 } })], + }); + + vi.advanceTimersByTime(130_000); + + expect(callbackSpy).toHaveBeenNthCalledWith(1, { members: [] }); + expect(callbackSpy).toHaveBeenCalledTimes(3); + }); + + it('does not remove a member that has rejoined', async ({ space }) => { + const callbackSpy = vi.fn(); + space.subscribe('update', callbackSpy); + + createPresenceEvent(space, 'enter'); + createPresenceEvent(space, 'enter', { clientId: '2', connectionId: '2' }); + expect(callbackSpy).toHaveBeenNthCalledWith(1, { + members: [ + createSpaceMember({ lastEvent: { name: 'enter', timestamp: 1 } }), + createSpaceMember({ clientId: '2', connectionId: '2', lastEvent: { name: 'enter', timestamp: 1 } }), + ], + }); + + createPresenceEvent(space, 'leave'); + expect(callbackSpy).toHaveBeenNthCalledWith(1, { + members: [ + createSpaceMember({ lastEvent: { name: 'leave', timestamp: 1 }, isConnected: false }), + createSpaceMember({ clientId: '2', connectionId: '2', lastEvent: { name: 'enter', timestamp: 1 } }), + ], + }); + + vi.advanceTimersByTime(60_000); + createPresenceEvent(space, 'enter'); + expect(callbackSpy).toHaveBeenNthCalledWith(1, { + members: [ + createSpaceMember({ lastEvent: { name: 'enter', timestamp: 1 } }), + createSpaceMember({ clientId: '2', connectionId: '2', lastEvent: { name: 'enter', timestamp: 1 } }), + ], + }); + + vi.advanceTimersByTime(130_000); // 2:10 passed, default timeout is 2 min + expect(callbackSpy).toHaveBeenNthCalledWith(1, { + members: [ + createSpaceMember({ lastEvent: { name: 'enter', timestamp: 1 } }), + createSpaceMember({ clientId: '2', connectionId: '2', lastEvent: { name: 'enter', timestamp: 1 } }), + ], + }); + + expect(callbackSpy).toHaveBeenCalledTimes(4); + }); + + it('unsubscribes when unsubscribe is called', async ({ space }) => { + const spy = vi.fn(); + space.subscribe('update', spy); + createPresenceEvent(space, 'enter', { clientId: '2' }); + space.unsubscribe('update', spy); + createPresenceEvent(space, 'enter', { clientId: '2' }); + + expect(spy).toHaveBeenCalledOnce(); + }); + + it('unsubscribes when unsubscribe is called with no arguments', async ({ space }) => { + const spy = vi.fn(); + space.subscribe('update', spy); + createPresenceEvent(space, 'enter', { clientId: '2' }); + space.unsubscribe(); + createPresenceEvent(space, 'enter', { clientId: '2' }); + + expect(spy).toHaveBeenCalledOnce(); + }); + }); + }); + + describe('locations', () => { + it('returns a Locations object', ({ space }) => { + expect(space.locations).toBeInstanceOf(Locations); + }); + }); + + describe('cursors', () => { + it('returns a Cursors object', ({ space }) => { + expect(space.cursors).toBeInstanceOf(Cursors); + }); + }); +}); diff --git a/src/Space.ts b/src/Space.ts index c7ee02de..680bc09d 100644 --- a/src/Space.ts +++ b/src/Space.ts @@ -1,6 +1,6 @@ import { Types } from 'ably'; +import { nanoid } from 'nanoid'; -import SpaceOptions from './options/SpaceOptions.js'; import EventEmitter, { InvalidArgumentError, inspect, @@ -9,229 +9,164 @@ import EventEmitter, { } from './utilities/EventEmitter.js'; import Locations from './Locations.js'; import Cursors from './Cursors.js'; +import Members from './Members.js'; -// Unique prefix to avoid conflicts with channels -import { LOCATION_UPDATE, MEMBERS_UPDATE, SPACE_CHANNEL_PREFIX } from './utilities/Constants.js'; -import { isFunction } from './utilities/TypeOf.js'; - -export type SpaceMember = { - clientId: string; - connectionId: string; - isConnected: boolean; - profileData: { [key: string]: any }; - location: any; - lastEvent: { - name: Types.PresenceAction; - timestamp: number; - }; -}; +import { SPACE_CHANNEL_PREFIX } from './utilities/constants.js'; +import { isFunction, isObject } from './utilities/typeOf.js'; -type SpaceLeaver = { - clientId: string; - connectionId: string; - timeoutId: ReturnType; -}; +import type { SpaceOptions, SpaceMember, ProfileData } from './types.js'; +import type { Subset, PresenceMember } from './utilities/types.js'; const SPACE_OPTIONS_DEFAULTS = { offlineTimeout: 120_000, + cursors: { + outboundBatchInterval: 100, + paginationLimit: 5, + }, }; -type SpaceEventsMap = { membersUpdate: SpaceMember[]; leave: SpaceMember; enter: SpaceMember; update: SpaceMember }; +type SpaceEventsMap = { + update: { members: SpaceMember[] }; +}; class Space extends EventEmitter { - private channelName: string; - private connectionId: string | undefined; - private channel: Types.RealtimeChannelPromise; - private members: SpaceMember[]; - private leavers: SpaceLeaver[]; - private options: SpaceOptions; - + readonly channelName: string; + readonly connectionId: string | undefined; + readonly options: SpaceOptions; readonly locations: Locations; readonly cursors: Cursors; + readonly members: Members; + readonly channel: Types.RealtimeChannelPromise; - constructor(readonly name: string, readonly client: Types.RealtimePromise, options?: SpaceOptions) { + constructor(name: string, readonly client: Types.RealtimePromise, options?: Subset) { super(); - this.options = { ...SPACE_OPTIONS_DEFAULTS, ...options }; + + this.options = this.setOptions(options); this.connectionId = this.client.connection.id; - this.members = []; - this.leavers = []; - this.onPresenceUpdate = this.onPresenceUpdate.bind(this); - this.setChannel(this.name); - this.locations = new Locations(this, this.channel); - this.cursors = new Cursors(this, options?.cursors); - } + this.channelName = `${SPACE_CHANNEL_PREFIX}${name}`; - private setChannel(rootName: string) { - // Remove the old subscription if the channel is switching - if (this.channel) { - this.channel.presence.unsubscribe(this.onPresenceUpdate); - } - this.channelName = `${SPACE_CHANNEL_PREFIX}${rootName}`; this.channel = this.client.channels.get(this.channelName); + this.onPresenceUpdate = this.onPresenceUpdate.bind(this); this.channel.presence.subscribe(this.onPresenceUpdate); - } - - getChannelName() { - return this.channelName; - } - - getMemberFromConnection(connectionId: string): SpaceMember | undefined { - return this.members.find((m) => m.connectionId === connectionId); - } - getMemberIndexFromConnection(connectionId: string): number { - return this.members.findIndex((m) => m.connectionId === connectionId); + this.locations = new Locations(this, this.presenceUpdate); + this.cursors = new Cursors(this); + this.members = new Members(this); } - private updateOrCreateMember(message: Types.PresenceMessage): SpaceMember { - const member = this.getMemberFromConnection(message.connectionId); - const lastEvent = { - name: message.action, - timestamp: message.timestamp, - }; - - if (!member) { - return { - clientId: message.clientId, - connectionId: message.connectionId, - isConnected: message.action !== 'leave', - profileData: message.data.profileData, - location: message?.data?.currentLocation || null, - lastEvent, - }; - } - - member.isConnected = message.action !== 'leave'; - member.lastEvent = lastEvent; - member.profileData = message.data?.profileData ?? member.profileData; - - return member; - } - - private mapPresenceMembersToSpaceMembers(messages: Types.PresenceMessage[]) { - return messages.map((message) => this.updateOrCreateMember(message)); - } + private presenceUpdate = (data: PresenceMember['data']) => { + return this.channel.presence.update(data); + }; - private addLeaver(message: Types.PresenceMessage) { - const timeoutCallback = () => { - const member = this.getMemberFromConnection(message.connectionId); + private presenceEnter = (data: PresenceMember['data']) => { + return this.channel.presence.enter(data); + }; - this.emit('leave', member); - this.removeMember(message.connectionId); - this.emit(MEMBERS_UPDATE, this.members); + private presenceLeave = (data: PresenceMember['data']) => { + return this.channel.presence.leave(data); + }; - if (member?.location) { - this.locations.emit(LOCATION_UPDATE, { - previousLocation: member.location, - currentLocation: null, - member: { ...member, location: null }, - }); - } + private setOptions(options?: Subset): SpaceOptions { + const { + offlineTimeout, + cursors: { outboundBatchInterval, paginationLimit }, + } = SPACE_OPTIONS_DEFAULTS; + + return { + offlineTimeout: options?.offlineTimeout ?? offlineTimeout, + cursors: { + outboundBatchInterval: options?.cursors?.outboundBatchInterval ?? outboundBatchInterval, + paginationLimit: options?.cursors?.paginationLimit ?? paginationLimit, + }, }; - - this.leavers.push({ - clientId: message.clientId, - connectionId: message.connectionId, - timeoutId: setTimeout(timeoutCallback, this.options.offlineTimeout), - }); - } - - private removeLeaver(leaverIndex: number): void { - clearTimeout(this.leavers[leaverIndex].timeoutId); - this.leavers.splice(leaverIndex, 1); - } - - private updateLeavers(message: Types.PresenceMessage): void { - const index = this.leavers.findIndex(({ connectionId }) => message.connectionId === connectionId); - - if (message.action === 'leave' && index < 0) { - this.addLeaver(message); - } else if (message.action === 'leave' && index >= 0) { - this.removeLeaver(index); - this.addLeaver(message); - } else if (index >= 0) { - this.removeLeaver(index); - } } - private updateMembers(message: Types.PresenceMessage): void { - const index = this.getMemberIndexFromConnection(message.connectionId); - const spaceMember = this.updateOrCreateMember(message); - - if (index >= 0) { - this.emit('update', spaceMember); - this.members[index] = spaceMember; - } else { - this.emit('enter', spaceMember); - this.members.push(spaceMember); - } - } - - private removeMember(connectionId: string): void { - const index = this.getMemberIndexFromConnection(connectionId); - - if (index >= 0) { - this.members.splice(index, 1); - } - } - - private onPresenceUpdate(message: Types.PresenceMessage) { - if (!message) return; - - this.updateLeavers(message); - this.updateMembers(message); - - this.emit(MEMBERS_UPDATE, this.members); + private onPresenceUpdate(message: PresenceMember) { + this.members.processPresenceMessage(message); + this.locations.processPresenceMessage(message); + this.emit('update', { members: this.members.getAll() }); } - async enter(profileData?: unknown): Promise { + async enter(profileData: ProfileData = null): Promise { return new Promise((resolve) => { const presence = this.channel.presence; - presence.enter({ profileData }); presence['subscriptions'].once('enter', async () => { const presenceMessages = await presence.get(); - this.members = this.mapPresenceMembersToSpaceMembers(presenceMessages); + const members = this.members.mapPresenceMembersToSpaceMembers(presenceMessages); - resolve(this.members); + resolve(members); + }); + + this.presenceEnter({ + profileUpdate: { + id: nanoid(), + current: profileData, + }, + locationUpdate: { + id: null, + current: null, + previous: null, + }, }); }); } - async updateProfileData(profileDataOrUpdateFn: unknown | ((unknown) => unknown)): Promise { - const self = this.getSelf(); + async updateProfileData( + profileDataOrUpdateFn: + | Record + | ((update: Record | null) => Record), + ): Promise { + const self = this.members.getSelf(); - if (isFunction(profileDataOrUpdateFn) && !self) { - const update = profileDataOrUpdateFn(); - await this.enter(update); - return; - } else if (!self) { - await this.enter(profileDataOrUpdateFn); - return; - } else if (isFunction(profileDataOrUpdateFn) && self) { - const update = profileDataOrUpdateFn(self.profileData); - await this.channel.presence.update({ profileData: update }); - return; + if (!isObject(profileDataOrUpdateFn) && !isFunction(profileDataOrUpdateFn)) { + throw new Error('Space.updateProfileData(): Invalid arguments: ' + inspect([profileDataOrUpdateFn])); } - await this.channel.presence.update({ profileData: profileDataOrUpdateFn }); - return; - } + const update = { + profileUpdate: { + id: nanoid(), + current: isFunction(profileDataOrUpdateFn) ? profileDataOrUpdateFn(null) : profileDataOrUpdateFn, + }, + locationUpdate: { + id: null, + current: self?.location ?? null, + previous: null, + }, + }; - leave(profileData?: unknown) { - return this.channel.presence.leave({ profileData }); - } + if (!self) { + await this.presenceEnter(update); + return; + } - getMembers(): SpaceMember[] { - return this.members; + return this.presenceUpdate(update); } - getSelf(): SpaceMember | undefined { - if (this.connectionId) { - return this.getMemberFromConnection(this.connectionId); + leave(profileData: ProfileData = null) { + const self = this.members.getSelf(); + + if (!self) { + throw new Error('You must enter a space before attempting to leave it'); } - return; + const update = { + profileUpdate: { + id: profileData ? nanoid() : null, + current: profileData ?? null, + }, + locationUpdate: { + id: null, + current: self?.location ?? null, + previous: null, + }, + }; + + return this.presenceLeave(update); + } + + getState(): { members: SpaceMember[] } { + return { members: this.members.getAll() }; } subscribe>( diff --git a/src/Spaces.test.ts b/src/Spaces.test.ts new file mode 100644 index 00000000..3e859ca2 --- /dev/null +++ b/src/Spaces.test.ts @@ -0,0 +1,48 @@ +import { it, describe, expect, expectTypeOf, vi, beforeEach } from 'vitest'; +import { Realtime, Types } from 'ably/promises'; + +import Spaces from './Spaces.js'; + +interface SpacesTestContext { + client: Types.RealtimePromise; +} + +vi.mock('ably/promises'); + +describe('Spaces', () => { + beforeEach((context) => { + context.client = new Realtime({ key: 'asd' }); + }); + + it('expects the injected client to be of the type RealtimePromise', ({ client }) => { + const spaces = new Spaces(client); + expectTypeOf(spaces.ably).toMatchTypeOf(); + }); + + it('creates and retrieves spaces successfully', async ({ client }) => { + const channels = client.channels; + const spy = vi.spyOn(channels, 'get'); + + const spaces = new Spaces(client); + await spaces.get('test'); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('_ably_space_test'); + }); + + it('applies the agent header to an existing SDK instance', ({ client }) => { + const spaces = new Spaces(client); + expect(client['options'].agents).toEqual({ 'ably-spaces': spaces.version, 'space-custom-client': true }); + }); + + it('extend the agents array when it already exists', ({ client }) => { + client['options']['agents'] = { 'some-client': '1.2.3' }; + const spaces = new Spaces(client); + + expect(spaces.ably['options'].agents).toEqual({ + 'some-client': '1.2.3', + 'ably-spaces': spaces.version, + 'space-custom-client': true, + }); + }); +}); diff --git a/src/Spaces.ts b/src/Spaces.ts index 40b53b4d..1a8ef033 100644 --- a/src/Spaces.ts +++ b/src/Spaces.ts @@ -1,36 +1,28 @@ -import * as Ably from 'ably'; import { Types } from 'ably'; -import SpaceOptions from './options/SpaceOptions.js'; import Space from './Space.js'; +import type { SpaceOptions } from './types.js'; +import type { Subset } from './utilities/types.js'; + class Spaces { - private spaces: Record; - private channel: Types.RealtimeChannelPromise; + private spaces: Record = {}; ably: Types.RealtimePromise; readonly version = '0.0.12'; - constructor(optionsOrAbly: Types.RealtimePromise | Types.ClientOptions | string) { - this.spaces = {}; - if (optionsOrAbly['options']) { - this.ably = optionsOrAbly as Types.RealtimePromise; - this.addAgent(this.ably['options'], false); - } else { - let options: Types.ClientOptions = typeof optionsOrAbly === 'string' ? { key: optionsOrAbly } : optionsOrAbly; - this.addAgent(options, true); - this.ably = new Ably.Realtime.Promise(options); - } + constructor(client: Types.RealtimePromise) { + this.ably = client; + this.addAgent(this.ably['options']); this.ably.time(); } - private addAgent(options: any, isDefault: boolean) { - const agent = { 'ably-spaces': this.version, [isDefault ? 'space-default-client' : 'space-custom-client']: true }; - + private addAgent(options: { agents?: Record }) { + const agent = { 'ably-spaces': this.version, 'space-custom-client': true }; options.agents = { ...(options.agents ?? options.agents), ...agent }; } - async get(name: string, options?: SpaceOptions): Promise { + async get(name: string, options?: Subset): Promise { if (typeof name !== 'string' || name.length === 0) { throw new Error('Spaces must have a non-empty name'); } diff --git a/src/options/CursorsOptions.d.ts b/src/options/CursorsOptions.d.ts deleted file mode 100644 index 1cebf207..00000000 --- a/src/options/CursorsOptions.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -interface CursorsOptions { - outboundBatchInterval?: number; - paginationLimit?: number; -} - -interface StrictCursorsOptions extends CursorsOptions { - outboundBatchInterval: number; - paginationLimit: number; -} - -export type { StrictCursorsOptions, CursorsOptions }; diff --git a/src/options/SpaceOptions.d.ts b/src/options/SpaceOptions.d.ts deleted file mode 100644 index b8fcc951..00000000 --- a/src/options/SpaceOptions.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { CursorsOptions } from './CursorsOptions.js'; - -type SpaceOptions = { - offlineTimeout?: number; - cursors?: CursorsOptions; -}; - -export default SpaceOptions; diff --git a/src/utilities/Constants.ts b/src/utilities/Constants.ts index 994d425c..e89d92b8 100644 --- a/src/utilities/Constants.ts +++ b/src/utilities/Constants.ts @@ -1,19 +1,4 @@ const SPACE_CHANNEL_PREFIX = '_ably_space_'; - const CURSOR_UPDATE = 'cursorUpdate'; -const LOCATION_UPDATE = 'locationUpdate'; -const MEMBERS_UPDATE = 'membersUpdate'; - -const OUTGOING_BATCH_TIME_DEFAULT = 100; -const INCOMING_BATCH_TIME_DEFAULT = 1; -const PAGINATION_LIMIT_DEFAULT = 5; -export { - SPACE_CHANNEL_PREFIX, - CURSOR_UPDATE, - LOCATION_UPDATE, - MEMBERS_UPDATE, - OUTGOING_BATCH_TIME_DEFAULT, - INCOMING_BATCH_TIME_DEFAULT, - PAGINATION_LIMIT_DEFAULT, -}; +export { SPACE_CHANNEL_PREFIX, CURSOR_UPDATE }; diff --git a/src/utilities/EventEmitter.ts b/src/utilities/EventEmitter.ts index a8681189..023da9fc 100644 --- a/src/utilities/EventEmitter.ts +++ b/src/utilities/EventEmitter.ts @@ -1,4 +1,4 @@ -import { isArray, isFunction, isObject, isString } from './TypeOf.js'; +import { isArray, isFunction, isObject, isString } from './typeOf.js'; function callListener(eventThis: { event: string }, listener: Function, args: unknown[]) { try { @@ -63,7 +63,7 @@ export class InvalidArgumentError extends Error { } } -type EventMap = Record; +export type EventMap = Record; // extract all the keys of an event map and use them as a type export type EventKey = string & keyof T; export type EventListener = (params: T) => void; diff --git a/src/utilities/test/fakes.ts b/src/utilities/test/fakes.ts index 1e1d8180..49e5c2a1 100644 --- a/src/utilities/test/fakes.ts +++ b/src/utilities/test/fakes.ts @@ -1,6 +1,22 @@ +import type { SpaceMember } from '../../types.js'; +import type { PresenceMember } from '../../utilities/types.js'; + +// import { nanoidId } from '../../../__mocks__/nanoid/index.js'; +const nanoidId = 'NanoidID'; + const enterPresenceMessage = { clientId: '1', - data: { profileData: {} }, + data: { + profileUpdate: { + id: null, + current: null, + }, + locationUpdate: { + id: null, + current: null, + previous: null, + }, + }, action: 'enter', connectionId: '1', id: '1', @@ -10,7 +26,6 @@ const enterPresenceMessage = { const updatePresenceMessage = { ...enterPresenceMessage, - data: { profileData: { a: 1 } }, action: 'update', }; @@ -33,7 +48,49 @@ const createPresenceMessage = (type, override?) => { }; const createPresenceEvent = (space, type, override?) => { - space.onPresenceUpdate(createPresenceMessage(type, override)); + space['onPresenceUpdate'](createPresenceMessage(type, override)); +}; + +const createLocationUpdate = (update?: Partial): PresenceMember['data'] => { + return { + locationUpdate: { + current: null, + id: nanoidId, + previous: null, + ...update, + }, + profileUpdate: { + current: null, + id: null, + }, + }; +}; + +const createProfileUpdate = (update?: Partial): PresenceMember['data'] => { + return { + locationUpdate: { + current: null, + id: null, + previous: null, + }, + profileUpdate: { + current: null, + id: nanoidId, + ...update, + }, + }; +}; + +const createSpaceMember = (override?: Partial): SpaceMember => { + return { + clientId: '1', + connectionId: '1', + isConnected: true, + profileData: null, + location: null, + lastEvent: { name: 'update', timestamp: 1 }, + ...override, + }; }; -export { createPresenceMessage, createPresenceEvent }; +export { createPresenceMessage, createPresenceEvent, createSpaceMember, createLocationUpdate, createProfileUpdate };