Skip to content

Commit 2bd70c1

Browse files
authored
feat: add bifur dependency and presence events (#577)
* feat: add bifur dependency and presence events
1 parent 27df27b commit 2bd70c1

File tree

6 files changed

+437
-2
lines changed

6 files changed

+437
-2
lines changed

knip.config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@ const baseConfig = {
6464
entry: ['src/index.ts', 'src/setup/**/*.ts', 'src/teardown/**/*.ts'],
6565
ignoreDependencies: ['@repo/tsconfig'],
6666
},
67+
// TODO: Remove this once we have presence fully implemented in the SDK
68+
'packages/core': {
69+
typescript: {
70+
config: 'tsconfig.settings.json',
71+
},
72+
project,
73+
entry: ['package.bundle.ts'],
74+
ignore: ['src/presence/bifurTransport.ts', 'src/presence/types.ts'],
75+
ignoreDependencies: ['@sanity/bifur-client', '@sanity/browserslist-config'],
76+
},
6777
},
6878
} satisfies KnipConfig
6979

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"browserslist": "extends @sanity/browserslist-config",
5757
"prettier": "@sanity/prettier-config",
5858
"dependencies": {
59+
"@sanity/bifur-client": "^0.4.1",
5960
"@sanity/client": "^7.2.1",
6061
"@sanity/comlink": "^3.0.4",
6162
"@sanity/diff-match-patch": "^3.2.0",
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import {fromUrl} from '@sanity/bifur-client'
2+
import {type SanityClient} from '@sanity/client'
3+
import {of, Subject} from 'rxjs'
4+
import {beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
5+
6+
import {createBifurTransport} from './bifurTransport'
7+
import {type PresenceLocation, type TransportEvent} from './types'
8+
9+
vi.mock('@sanity/bifur-client', () => ({
10+
fromUrl: vi.fn(),
11+
}))
12+
13+
const fromUrlMock = fromUrl as Mock
14+
15+
type BifurStateMessage = {
16+
type: 'state'
17+
i: string
18+
m: {
19+
sessionId: string
20+
locations: PresenceLocation[]
21+
}
22+
}
23+
24+
type BifurDisconnectMessage = {
25+
type: 'disconnect'
26+
i: string
27+
m: {session: string}
28+
}
29+
30+
type BifurRollCallEvent = {
31+
type: 'rollCall'
32+
i: string
33+
session: string
34+
}
35+
36+
type IncomingBifurEvent = BifurRollCallEvent | BifurStateMessage | BifurDisconnectMessage
37+
38+
describe('createBifurTransport', () => {
39+
let mockBifurClient: {
40+
listen: Mock
41+
request: Mock
42+
}
43+
let mockSanityClient: SanityClient
44+
let token$: Subject<string | null>
45+
46+
beforeEach(() => {
47+
vi.useFakeTimers()
48+
mockBifurClient = {
49+
listen: vi.fn(() => new Subject<never>()),
50+
request: vi.fn(() => of(undefined)),
51+
}
52+
fromUrlMock.mockReturnValue(mockBifurClient)
53+
54+
mockSanityClient = {
55+
config: () => ({
56+
dataset: 'test-dataset',
57+
url: 'http://localhost:3333',
58+
requestTagPrefix: 'test-tag',
59+
}),
60+
withConfig: vi.fn().mockReturnThis(),
61+
} as unknown as SanityClient
62+
63+
token$ = new Subject<string | null>()
64+
})
65+
66+
it('constructs the bifur client with the correct URL', () => {
67+
createBifurTransport({
68+
client: mockSanityClient,
69+
token$,
70+
sessionId: 'session-id-123',
71+
})
72+
73+
expect(fromUrlMock).toHaveBeenCalledWith(
74+
'ws://localhost:3333/socket/test-dataset?tag=test-tag',
75+
{
76+
token$,
77+
},
78+
)
79+
})
80+
81+
it('handles incoming rollCall events', () => {
82+
const incomingBifurEvents$ = new Subject<IncomingBifurEvent>()
83+
mockBifurClient.listen.mockReturnValue(incomingBifurEvents$)
84+
85+
const [incomingEvents$] = createBifurTransport({
86+
client: mockSanityClient,
87+
token$,
88+
sessionId: 'session-id-123',
89+
})
90+
91+
const receivedEvents: TransportEvent[] = []
92+
incomingEvents$.subscribe((event) => receivedEvents.push(event))
93+
94+
incomingBifurEvents$.next({
95+
type: 'rollCall',
96+
i: 'user-1',
97+
session: 'session-id-456',
98+
})
99+
100+
expect(receivedEvents).toEqual([
101+
{
102+
type: 'rollCall',
103+
userId: 'user-1',
104+
sessionId: 'session-id-456',
105+
},
106+
])
107+
})
108+
109+
it('handles incoming state events', () => {
110+
const date = new Date('2024-01-01T12:00:00.000Z')
111+
vi.setSystemTime(date)
112+
113+
const incomingBifurEvents$ = new Subject<IncomingBifurEvent>()
114+
mockBifurClient.listen.mockReturnValue(incomingBifurEvents$)
115+
116+
const [incomingEvents$] = createBifurTransport({
117+
client: mockSanityClient,
118+
token$,
119+
sessionId: 'session-id-123',
120+
})
121+
122+
const receivedEvents: TransportEvent[] = []
123+
incomingEvents$.subscribe((event) => receivedEvents.push(event))
124+
125+
const locations: PresenceLocation[] = [
126+
{type: 'document', documentId: 'doc1', path: ['a'], lastActiveAt: new Date().toISOString()},
127+
]
128+
incomingBifurEvents$.next({
129+
type: 'state',
130+
i: 'user-1',
131+
m: {
132+
sessionId: 'session-id-456',
133+
locations,
134+
},
135+
})
136+
137+
expect(receivedEvents).toEqual([
138+
{
139+
type: 'state',
140+
userId: 'user-1',
141+
sessionId: 'session-id-456',
142+
timestamp: date.toISOString(),
143+
locations,
144+
},
145+
])
146+
})
147+
148+
it('handles incoming disconnect events', () => {
149+
const date = new Date('2024-01-01T12:00:00.000Z')
150+
vi.setSystemTime(date)
151+
152+
const incomingBifurEvents$ = new Subject<IncomingBifurEvent>()
153+
mockBifurClient.listen.mockReturnValue(incomingBifurEvents$)
154+
155+
const [incomingEvents$] = createBifurTransport({
156+
client: mockSanityClient,
157+
token$,
158+
sessionId: 'session-id-123',
159+
})
160+
161+
const receivedEvents: TransportEvent[] = []
162+
incomingEvents$.subscribe((event) => receivedEvents.push(event))
163+
164+
incomingBifurEvents$.next({
165+
type: 'disconnect',
166+
i: 'user-1',
167+
m: {
168+
session: 'session-id-456',
169+
},
170+
})
171+
172+
expect(receivedEvents).toEqual([
173+
{
174+
type: 'disconnect',
175+
userId: 'user-1',
176+
sessionId: 'session-id-456',
177+
timestamp: date.toISOString(),
178+
},
179+
])
180+
})
181+
182+
it('throws an error for unknown incoming events', () => {
183+
const incomingBifurEvents$ = new Subject<IncomingBifurEvent>()
184+
mockBifurClient.listen.mockReturnValue(incomingBifurEvents$)
185+
186+
const [incomingEvents$] = createBifurTransport({
187+
client: mockSanityClient,
188+
token$,
189+
sessionId: 'session-id-123',
190+
})
191+
192+
const errors: Error[] = []
193+
incomingEvents$.subscribe({
194+
error: (err) => errors.push(err),
195+
})
196+
197+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
198+
incomingBifurEvents$.next({type: 'unknown'} as any)
199+
200+
expect(errors.length).toBe(1)
201+
expect(errors[0]).toBeInstanceOf(Error)
202+
expect(errors[0].message).toContain('Got unknown presence event')
203+
})
204+
205+
describe('dispatchMessage', () => {
206+
it('sends a "rollCall" message', () => {
207+
const [, dispatchMessage] = createBifurTransport({
208+
client: mockSanityClient,
209+
token$,
210+
sessionId: 'my-session',
211+
})
212+
dispatchMessage({type: 'rollCall'})
213+
expect(mockBifurClient.request).toHaveBeenCalledWith('presence_rollcall', {
214+
session: 'my-session',
215+
})
216+
})
217+
218+
it('sends a "state" message', () => {
219+
const [, dispatchMessage] = createBifurTransport({
220+
client: mockSanityClient,
221+
token$,
222+
sessionId: 'my-session',
223+
})
224+
const locations: PresenceLocation[] = [
225+
{type: 'document', documentId: 'doc1', path: ['a'], lastActiveAt: new Date().toISOString()},
226+
]
227+
dispatchMessage({type: 'state', locations})
228+
expect(mockBifurClient.request).toHaveBeenCalledWith('presence_announce', {
229+
data: {locations, sessionId: 'my-session'},
230+
})
231+
})
232+
233+
it('sends a "disconnect" message', () => {
234+
const [, dispatchMessage] = createBifurTransport({
235+
client: mockSanityClient,
236+
token$,
237+
sessionId: 'my-session',
238+
})
239+
dispatchMessage({type: 'disconnect'})
240+
expect(mockBifurClient.request).toHaveBeenCalledWith('presence_disconnect', {
241+
session: 'my-session',
242+
})
243+
})
244+
245+
it('does nothing for unknown message types', () => {
246+
const [, dispatchMessage] = createBifurTransport({
247+
client: mockSanityClient,
248+
token$,
249+
sessionId: 'my-session',
250+
})
251+
// The type assertion is needed to test this case
252+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
253+
dispatchMessage({type: 'unknown'} as any)
254+
expect(mockBifurClient.request).not.toHaveBeenCalled()
255+
})
256+
})
257+
})
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import {type BifurClient, fromUrl} from '@sanity/bifur-client'
2+
import {type SanityClient} from '@sanity/client'
3+
import {EMPTY, type Observable} from 'rxjs'
4+
import {map, share} from 'rxjs/operators'
5+
6+
import {
7+
type BifurTransportOptions,
8+
type PresenceLocation,
9+
type PresenceTransport,
10+
type TransportEvent,
11+
type TransportMessage,
12+
} from './types'
13+
14+
type BifurStateMessage = {
15+
type: 'state'
16+
i: string
17+
m: {
18+
sessionId: string
19+
locations: PresenceLocation[]
20+
}
21+
}
22+
23+
type BifurDisconnectMessage = {
24+
type: 'disconnect'
25+
i: string
26+
m: {session: string}
27+
}
28+
29+
type RollCallEvent = {
30+
type: 'rollCall'
31+
i: string
32+
session: string
33+
}
34+
35+
type IncomingBifurEvent = RollCallEvent | BifurStateMessage | BifurDisconnectMessage
36+
37+
function getBifurClient(client: SanityClient, token$: Observable<string | null>): BifurClient {
38+
const bifurVersionedClient = client.withConfig({apiVersion: '2022-06-30'})
39+
const {dataset, url: baseUrl, requestTagPrefix = 'sanity.studio'} = bifurVersionedClient.config()
40+
const url = `${baseUrl.replace(/\/+$/, '')}/socket/${dataset}`.replace(/^http/, 'ws')
41+
const urlWithTag = `${url}?tag=${requestTagPrefix}`
42+
43+
return fromUrl(urlWithTag, {token$})
44+
}
45+
46+
const handleIncomingMessage = (event: IncomingBifurEvent): TransportEvent => {
47+
switch (event.type) {
48+
case 'rollCall':
49+
return {
50+
type: 'rollCall',
51+
userId: event.i,
52+
sessionId: event.session,
53+
}
54+
case 'state': {
55+
const {sessionId, locations} = event.m
56+
return {
57+
type: 'state',
58+
userId: event.i,
59+
sessionId,
60+
timestamp: new Date().toISOString(),
61+
locations,
62+
}
63+
}
64+
case 'disconnect':
65+
return {
66+
type: 'disconnect',
67+
userId: event.i,
68+
sessionId: event.m.session,
69+
timestamp: new Date().toISOString(),
70+
}
71+
default: {
72+
throw new Error(`Got unknown presence event: ${JSON.stringify(event)}`)
73+
}
74+
}
75+
}
76+
77+
export const createBifurTransport = (options: BifurTransportOptions): PresenceTransport => {
78+
const {client, token$, sessionId} = options
79+
const bifur = getBifurClient(client, token$)
80+
81+
const incomingEvents$: Observable<TransportEvent> = bifur
82+
.listen<IncomingBifurEvent>('presence')
83+
.pipe(map(handleIncomingMessage))
84+
85+
const dispatchMessage = (message: TransportMessage): Observable<void> => {
86+
switch (message.type) {
87+
case 'rollCall':
88+
return bifur.request('presence_rollcall', {session: sessionId})
89+
case 'state':
90+
return bifur.request('presence_announce', {
91+
data: {locations: message.locations, sessionId},
92+
})
93+
case 'disconnect':
94+
return bifur.request('presence_disconnect', {session: sessionId})
95+
default: {
96+
return EMPTY
97+
}
98+
}
99+
}
100+
101+
return [incomingEvents$.pipe(share()), dispatchMessage]
102+
}

0 commit comments

Comments
 (0)