@sanity/sdk 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ })
@@ -0,0 +1,108 @@
1
+ import {type BifurClient, fromUrl} from '@sanity/bifur-client'
2
+ import {type SanityClient} from '@sanity/client'
3
+ import {EMPTY, fromEvent, type Observable} from 'rxjs'
4
+ import {map, share, switchMap} 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
+ if (typeof window !== 'undefined') {
102
+ fromEvent(window, 'beforeunload')
103
+ .pipe(switchMap(() => dispatchMessage({type: 'disconnect'})))
104
+ .subscribe()
105
+ }
106
+
107
+ return [incomingEvents$.pipe(share()), dispatchMessage]
108
+ }
@@ -0,0 +1,247 @@
1
+ import {type SanityClient} from '@sanity/client'
2
+ import {delay, firstValueFrom, of, Subject} from 'rxjs'
3
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
4
+
5
+ import {getTokenState} from '../auth/authStore'
6
+ import {getClient} from '../client/clientStore'
7
+ import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
8
+ import {type SanityUser} from '../users/types'
9
+ import {getUserState} from '../users/usersStore'
10
+ import {createBifurTransport} from './bifurTransport'
11
+ import {getPresence} from './presenceStore'
12
+ import {type PresenceLocation, type TransportEvent} from './types'
13
+
14
+ vi.mock('../auth/authStore')
15
+ vi.mock('../client/clientStore')
16
+ vi.mock('../users/usersStore')
17
+ vi.mock('./bifurTransport')
18
+
19
+ describe('presenceStore', () => {
20
+ let instance: SanityInstance
21
+ let mockClient: SanityClient
22
+ let mockTokenState: Subject<string | null>
23
+ let mockIncomingEvents: Subject<TransportEvent>
24
+ let mockDispatchMessage: ReturnType<typeof vi.fn>
25
+ let mockGetUserState: ReturnType<typeof vi.fn>
26
+
27
+ const mockUser: SanityUser = {
28
+ sanityUserId: 'u123',
29
+ profile: {
30
+ id: 'user-1',
31
+ displayName: 'Test User',
32
+ email: 'test@example.com',
33
+ provider: 'google',
34
+ createdAt: '2023-01-01T00:00:00Z',
35
+ },
36
+ memberships: [],
37
+ }
38
+
39
+ beforeEach(() => {
40
+ vi.clearAllMocks()
41
+
42
+ // Mock crypto.randomUUID
43
+ Object.defineProperty(global, 'crypto', {
44
+ value: {
45
+ randomUUID: vi.fn(() => 'test-session-id'),
46
+ },
47
+ })
48
+
49
+ mockClient = {
50
+ withConfig: vi.fn().mockReturnThis(),
51
+ } as unknown as SanityClient
52
+
53
+ mockTokenState = new Subject<string | null>()
54
+ mockIncomingEvents = new Subject<TransportEvent>()
55
+ mockDispatchMessage = vi.fn(() => of(undefined))
56
+
57
+ vi.mocked(getClient).mockReturnValue(mockClient)
58
+ vi.mocked(getTokenState).mockReturnValue({
59
+ observable: mockTokenState.asObservable(),
60
+ getCurrent: vi.fn(),
61
+ subscribe: vi.fn(),
62
+ })
63
+
64
+ vi.mocked(createBifurTransport).mockReturnValue([
65
+ mockIncomingEvents.asObservable(),
66
+ mockDispatchMessage,
67
+ ])
68
+
69
+ mockGetUserState = vi.fn(() => of(mockUser))
70
+ vi.mocked(getUserState).mockImplementation(mockGetUserState)
71
+
72
+ instance = createSanityInstance({projectId: 'test-project', dataset: 'test-dataset'})
73
+ })
74
+
75
+ afterEach(() => {
76
+ instance.dispose()
77
+ })
78
+
79
+ describe('getPresence', () => {
80
+ it('creates bifur transport with correct parameters', () => {
81
+ getPresence(instance)
82
+
83
+ expect(createBifurTransport).toHaveBeenCalledWith({
84
+ client: mockClient,
85
+ token$: expect.any(Object),
86
+ sessionId: 'test-session-id',
87
+ })
88
+ })
89
+
90
+ it('sends rollCall message on initialization', () => {
91
+ getPresence(instance)
92
+
93
+ expect(mockDispatchMessage).toHaveBeenCalledWith({type: 'rollCall'})
94
+ })
95
+
96
+ it('returns empty array when no users present', () => {
97
+ const source = getPresence(instance)
98
+ expect(source.getCurrent()).toEqual([])
99
+ })
100
+
101
+ it('handles state events from other users', async () => {
102
+ const source = getPresence(instance)
103
+
104
+ // Subscribe to initialize the store
105
+ const unsubscribe = source.subscribe(() => {})
106
+
107
+ // Wait a bit for initialization
108
+ await firstValueFrom(of(null).pipe(delay(10)))
109
+
110
+ const locations: PresenceLocation[] = [
111
+ {
112
+ type: 'document',
113
+ documentId: 'doc-1',
114
+ path: ['title'],
115
+ lastActiveAt: '2023-01-01T12:00:00Z',
116
+ },
117
+ ]
118
+
119
+ mockIncomingEvents.next({
120
+ type: 'state',
121
+ userId: 'user-1',
122
+ sessionId: 'other-session',
123
+ timestamp: '2023-01-01T12:00:00Z',
124
+ locations,
125
+ })
126
+
127
+ // Wait for processing
128
+ await firstValueFrom(of(null).pipe(delay(20)))
129
+
130
+ const presence = source.getCurrent()
131
+ expect(presence).toHaveLength(1)
132
+ expect(presence[0].sessionId).toBe('other-session')
133
+ expect(presence[0].locations).toEqual(locations)
134
+
135
+ unsubscribe()
136
+ })
137
+
138
+ it('ignores events from own session', async () => {
139
+ const source = getPresence(instance)
140
+ const unsubscribe = source.subscribe(() => {})
141
+
142
+ await firstValueFrom(of(null).pipe(delay(10)))
143
+
144
+ mockIncomingEvents.next({
145
+ type: 'state',
146
+ userId: 'user-1',
147
+ sessionId: 'test-session-id', // Same as our session
148
+ timestamp: '2023-01-01T12:00:00Z',
149
+ locations: [],
150
+ })
151
+
152
+ await firstValueFrom(of(null).pipe(delay(20)))
153
+
154
+ const presence = source.getCurrent()
155
+ expect(presence).toHaveLength(0)
156
+
157
+ unsubscribe()
158
+ })
159
+
160
+ it('handles disconnect events', async () => {
161
+ const source = getPresence(instance)
162
+ const unsubscribe = source.subscribe(() => {})
163
+
164
+ await firstValueFrom(of(null).pipe(delay(10)))
165
+
166
+ // First add a user
167
+ mockIncomingEvents.next({
168
+ type: 'state',
169
+ userId: 'user-1',
170
+ sessionId: 'other-session',
171
+ timestamp: '2023-01-01T12:00:00Z',
172
+ locations: [],
173
+ })
174
+
175
+ await firstValueFrom(of(null).pipe(delay(20)))
176
+ expect(source.getCurrent()).toHaveLength(1)
177
+
178
+ // Then disconnect them
179
+ mockIncomingEvents.next({
180
+ type: 'disconnect',
181
+ userId: 'user-1',
182
+ sessionId: 'other-session',
183
+ timestamp: '2023-01-01T12:01:00Z',
184
+ })
185
+
186
+ await firstValueFrom(of(null).pipe(delay(20)))
187
+ expect(source.getCurrent()).toHaveLength(0)
188
+
189
+ unsubscribe()
190
+ })
191
+
192
+ it('fetches user data for present users', async () => {
193
+ const source = getPresence(instance)
194
+ const unsubscribe = source.subscribe(() => {})
195
+
196
+ await firstValueFrom(of(null).pipe(delay(10)))
197
+
198
+ mockIncomingEvents.next({
199
+ type: 'state',
200
+ userId: 'user-1',
201
+ sessionId: 'other-session',
202
+ timestamp: '2023-01-01T12:00:00Z',
203
+ locations: [
204
+ {
205
+ type: 'document',
206
+ documentId: 'doc-1',
207
+ path: ['title'],
208
+ lastActiveAt: '2023-01-01T12:00:00Z',
209
+ },
210
+ ],
211
+ })
212
+
213
+ await firstValueFrom(of(null).pipe(delay(50)))
214
+
215
+ expect(getUserState).toHaveBeenCalledWith(instance, {
216
+ userId: 'user-1',
217
+ resourceType: 'project',
218
+ projectId: 'test-project',
219
+ })
220
+
221
+ unsubscribe()
222
+ })
223
+
224
+ it('handles presence events correctly', async () => {
225
+ const source = getPresence(instance)
226
+ const unsubscribe = source.subscribe(() => {})
227
+
228
+ await firstValueFrom(of(null).pipe(delay(10)))
229
+
230
+ mockIncomingEvents.next({
231
+ type: 'state',
232
+ userId: 'test-user',
233
+ sessionId: 'other-session',
234
+ timestamp: '2023-01-01T12:00:00Z',
235
+ locations: [],
236
+ })
237
+
238
+ await firstValueFrom(of(null).pipe(delay(50)))
239
+
240
+ const presence = source.getCurrent()
241
+ expect(presence).toHaveLength(1)
242
+ expect(presence[0].sessionId).toBe('other-session')
243
+
244
+ unsubscribe()
245
+ })
246
+ })
247
+ })