@sanity/sdk 0.0.0-alpha.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.
Files changed (36) hide show
  1. package/dist/index.d.ts +339 -0
  2. package/dist/index.js +492 -0
  3. package/dist/index.js.map +1 -0
  4. package/package.json +77 -0
  5. package/src/_exports/index.ts +39 -0
  6. package/src/auth/authStore.test.ts +296 -0
  7. package/src/auth/authStore.ts +125 -0
  8. package/src/auth/getAuthStore.test.ts +14 -0
  9. package/src/auth/getInternalAuthStore.ts +20 -0
  10. package/src/auth/internalAuthStore.test.ts +334 -0
  11. package/src/auth/internalAuthStore.ts +519 -0
  12. package/src/client/getClient.test.ts +41 -0
  13. package/src/client/getClient.ts +13 -0
  14. package/src/client/getSubscribableClient.test.ts +71 -0
  15. package/src/client/getSubscribableClient.ts +17 -0
  16. package/src/client/store/actions/getClientEvents.test.ts +95 -0
  17. package/src/client/store/actions/getClientEvents.ts +33 -0
  18. package/src/client/store/actions/getOrCreateClient.test.ts +56 -0
  19. package/src/client/store/actions/getOrCreateClient.ts +40 -0
  20. package/src/client/store/actions/receiveToken.test.ts +18 -0
  21. package/src/client/store/actions/receiveToken.ts +31 -0
  22. package/src/client/store/clientStore.test.ts +152 -0
  23. package/src/client/store/clientStore.ts +98 -0
  24. package/src/documentList/documentListStore.test.ts +575 -0
  25. package/src/documentList/documentListStore.ts +269 -0
  26. package/src/documents/.keep +0 -0
  27. package/src/instance/identity.test.ts +46 -0
  28. package/src/instance/identity.ts +28 -0
  29. package/src/instance/sanityInstance.test.ts +66 -0
  30. package/src/instance/sanityInstance.ts +64 -0
  31. package/src/instance/types.d.ts +29 -0
  32. package/src/schema/schemaStore.test.ts +30 -0
  33. package/src/schema/schemaStore.ts +32 -0
  34. package/src/store/createStore.test.ts +108 -0
  35. package/src/store/createStore.ts +106 -0
  36. package/src/tsdoc.json +39 -0
@@ -0,0 +1,519 @@
1
+ import {type ClientConfig, createClient, type SanityClient} from '@sanity/client'
2
+ import type {CurrentUser} from '@sanity/types'
3
+ import {distinctUntilChanged, EMPTY, filter, fromEvent, map, Observable} from 'rxjs'
4
+ import {devtools} from 'zustand/middleware'
5
+ import {createStore, type StoreApi} from 'zustand/vanilla'
6
+
7
+ import type {SanityInstance} from '../instance/types'
8
+
9
+ const AUTH_CODE_PARAM = 'sid'
10
+ const DEFAULT_BASE = 'http://localhost'
11
+ const DEFAULT_API_VERSION = '2021-06-07'
12
+ const REQUEST_TAG_PREFIX = 'sdk.auth'
13
+
14
+ /**
15
+ * Represents the various states the authentication store can be in.
16
+ *
17
+ * @public
18
+ */
19
+ export type AuthState =
20
+ | {type: 'logged-in'; token: string; currentUser: CurrentUser | null}
21
+ | {type: 'logging-in'; isExchangingToken: boolean}
22
+ | {type: 'error'; error: unknown}
23
+ | {type: 'logged-out'; isDestroyingSession: boolean}
24
+
25
+ /**
26
+ * Configuration for an authentication provider
27
+ * @public
28
+ */
29
+ export interface AuthProvider {
30
+ /**
31
+ * Unique identifier for the auth provider (e.g., 'google', 'github')
32
+ */
33
+ name: string
34
+
35
+ /**
36
+ * Display name for the auth provider in the UI
37
+ */
38
+ title: string
39
+
40
+ /**
41
+ * Complete authentication URL including callback and token parameters
42
+ */
43
+ url: string
44
+
45
+ /**
46
+ * Optional URL for direct sign-up flow
47
+ */
48
+ signUpUrl?: string
49
+ }
50
+
51
+ /**
52
+ * Configuration options for creating an auth store.
53
+ *
54
+ * @public
55
+ */
56
+ export interface AuthConfig {
57
+ /**
58
+ * The initial location href to use when handling auth callbacks.
59
+ * Defaults to the current window location if available.
60
+ */
61
+ initialLocationHref?: string
62
+
63
+ /**
64
+ * Factory function to create a SanityClient instance.
65
+ * Defaults to the standard Sanity client factory if not provided.
66
+ */
67
+ clientFactory?: (config: ClientConfig) => SanityClient
68
+
69
+ /**
70
+ * Custom authentication providers to use instead of or in addition to the default ones.
71
+ * Can be an array of providers or a function that takes the default providers and returns
72
+ * a modified array or a Promise resolving to one.
73
+ */
74
+ providers?: AuthProvider[] | ((prev: AuthProvider[]) => AuthProvider[] | Promise<AuthProvider[]>)
75
+
76
+ /**
77
+ * The API hostname for requests. Usually leave this undefined, but it can be set
78
+ * if using a custom domain or CNAME for the API endpoint.
79
+ */
80
+ apiHost?: string
81
+
82
+ /**
83
+ * Storage implementation to persist authentication state.
84
+ * Defaults to `localStorage` if available.
85
+ */
86
+ storageArea?: Storage
87
+
88
+ /**
89
+ * A callback URL for your application.
90
+ * If none is provided, the auth API will redirect back to the current location (`location.href`).
91
+ * When handling callbacks, this URL's pathname is checked to ensure it matches the callback.
92
+ */
93
+ callbackUrl?: string
94
+
95
+ /**
96
+ * A static authentication token to use instead of handling the OAuth flow.
97
+ * When provided, the auth store will remain in a logged-in state with this token,
98
+ * ignoring any storage or callback handling.
99
+ */
100
+ token?: string
101
+
102
+ /**
103
+ * The authentication scope.
104
+ * If set to 'project', requests are scoped to the project-level.
105
+ * If set to 'org', requests are scoped to the organization-level.
106
+ * Defaults to 'project'.
107
+ */
108
+ authScope?: 'project' | 'org'
109
+ }
110
+
111
+ /**
112
+ * Represents an authentication store that can handle login/logout flows, fetch providers,
113
+ * handle auth callbacks, subscribe to state changes, and more.
114
+ *
115
+ * @internal
116
+ */
117
+ export interface InternalAuthState {
118
+ authState: AuthState
119
+ setAuthState: (authState: AuthState) => void
120
+ providers: AuthProvider[] | undefined
121
+ setProviders: (providers: AuthProvider[] | undefined) => void
122
+ /**
123
+ * Handles an OAuth callback by reading the `sid` parameter from the given `locationHref`.
124
+ * If a token is successfully fetched, the state transitions to `logged-in`.
125
+ *
126
+ * @param locationHref - The location to parse for callback parameters. Defaults to current location.
127
+ * @returns A promise resolving to the updated URL (with callback params removed) or `false` if no callback was handled.
128
+ */
129
+ handleCallback(locationHref?: string): Promise<string | false>
130
+
131
+ /**
132
+ * Fetches authentication providers (OAuth endpoints) for logging in.
133
+ * Can optionally be customized with `providers` in {@link AuthConfig}.
134
+ * Results are cached after the first call. Subsequent calls return synchronously from cache.
135
+ *
136
+ * @returns Authentication providers as {@link AuthProvider} objects with pre-configured login URLs.
137
+ */
138
+ getLoginUrls(): AuthProvider[] | Promise<AuthProvider[]>
139
+
140
+ /**
141
+ * Logs out the current user. If a static token was provided, logout is a no-op.
142
+ * Otherwise, sends a request to invalidate the current token and updates state to `logged-out`.
143
+ */
144
+ logout(): Promise<void>
145
+
146
+ /**
147
+ * Disposes of any internal resources and subscriptions used by the store.
148
+ * After calling dispose, the store should no longer be used.
149
+ */
150
+ dispose(): void
151
+ }
152
+
153
+ /**
154
+ * Internal auth store type.
155
+ * @internal
156
+ */
157
+ export type InternalAuthStore = StoreApi<InternalAuthState>
158
+
159
+ /**
160
+ * Returns the default location to use.
161
+ * Tries accessing `location.href`, falls back to a default base if not available or on error.
162
+ */
163
+ function getDefaultLocation() {
164
+ try {
165
+ if (typeof location === 'undefined') return DEFAULT_BASE
166
+ if (typeof location.href === 'string') return location.href
167
+ return DEFAULT_BASE
168
+ } catch {
169
+ return DEFAULT_BASE
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Returns a default storage instance (localStorage) if available.
175
+ * If not available or an error occurs, returns undefined.
176
+ */
177
+ function getDefaultStorage() {
178
+ try {
179
+ if (typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function') {
180
+ return localStorage
181
+ }
182
+ return undefined
183
+ } catch {
184
+ return undefined
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Creates an observable stream of storage events. If not in a browser environment,
190
+ * returns an EMPTY observable.
191
+ */
192
+ function getStorageEvents(): Observable<StorageEvent> {
193
+ const isBrowser = typeof window !== 'undefined' && typeof window.addEventListener === 'function'
194
+
195
+ if (!isBrowser) {
196
+ return EMPTY
197
+ }
198
+
199
+ return fromEvent<StorageEvent>(window, 'storage')
200
+ }
201
+
202
+ /**
203
+ * Creates a new authentication store for managing OAuth flows, tokens, and related state.
204
+ *
205
+ * @param instance - The Sanity instance configuration.
206
+ * @param config - The auth configuration options.
207
+ * @returns An {@link InternalAuthStore} instance.
208
+ *
209
+ * @internal
210
+ */
211
+ export function createInternalAuthStore(
212
+ instance: SanityInstance,
213
+ config: AuthConfig = {},
214
+ ): InternalAuthStore {
215
+ const {
216
+ clientFactory = createClient,
217
+ initialLocationHref = getDefaultLocation(),
218
+ storageArea = getDefaultStorage(),
219
+ authScope = 'project',
220
+ apiHost,
221
+ callbackUrl,
222
+ providers: customProviders,
223
+ token: providedToken,
224
+ } = config
225
+
226
+ const {projectId, dataset} = instance.identity
227
+ const storageKey = `__sanity_auth_token_${projectId}_${dataset}`
228
+
229
+ /**
230
+ * Attempts to retrieve a token from the configured storage.
231
+ * If invalid or not present, returns null.
232
+ */
233
+ function getTokenFromStorage() {
234
+ if (!storageArea) return null
235
+ const item = storageArea.getItem(storageKey)
236
+ if (item === null) return null
237
+
238
+ try {
239
+ const parsed: unknown = JSON.parse(item)
240
+ if (
241
+ typeof parsed !== 'object' ||
242
+ parsed === null ||
243
+ !('token' in parsed) ||
244
+ typeof parsed.token !== 'string'
245
+ ) {
246
+ throw new Error('Invalid stored auth data structure')
247
+ }
248
+ return parsed.token
249
+ } catch {
250
+ storageArea.removeItem(storageKey)
251
+ return null
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Extracts the auth code (`sid`) from a location, if it matches the callback URL conditions.
257
+ * Returns null if no valid code is found.
258
+ */
259
+ function getAuthCode(locationHref: string) {
260
+ const loc = new URL(locationHref, DEFAULT_BASE)
261
+ const callbackLocation = callbackUrl ? new URL(callbackUrl, DEFAULT_BASE) : undefined
262
+ const callbackLocationMatches = callbackLocation
263
+ ? loc.pathname.toLowerCase().startsWith(callbackLocation.pathname.toLowerCase())
264
+ : true
265
+
266
+ const authCode = new URLSearchParams(loc.hash.slice(1)).get(AUTH_CODE_PARAM)
267
+ return authCode && callbackLocationMatches ? authCode : null
268
+ }
269
+
270
+ /**
271
+ * Determines the initial auth state based on provided token, callback params, or stored token.
272
+ */
273
+ function getInitialState(): AuthState {
274
+ if (providedToken) return {type: 'logged-in', token: providedToken, currentUser: null}
275
+ if (getAuthCode(initialLocationHref)) return {type: 'logging-in', isExchangingToken: false}
276
+ const token = getTokenFromStorage()
277
+ if (token) return {type: 'logged-in', token, currentUser: null}
278
+ return {type: 'logged-out', isDestroyingSession: false}
279
+ }
280
+
281
+ async function handleCallback(locationHref = getDefaultLocation()) {
282
+ // If a token is provided, no need to handle callback
283
+ if (providedToken) return false
284
+
285
+ // Don't handle the callback if already in flight.
286
+ const {authState} = store.getState()
287
+ if (authState.type === 'logging-in' && authState.isExchangingToken) return false
288
+
289
+ // If there is no matching `authCode` then we can't handle the callback
290
+ const authCode = getAuthCode(locationHref)
291
+ if (!authCode) return false
292
+
293
+ // Otherwise, start the exchange
294
+ store.setState(
295
+ {authState: {type: 'logging-in', isExchangingToken: true}},
296
+ undefined,
297
+ 'exchangeSessionForToken',
298
+ )
299
+
300
+ try {
301
+ const client = clientFactory({
302
+ projectId,
303
+ dataset,
304
+ apiVersion: DEFAULT_API_VERSION,
305
+ requestTagPrefix: REQUEST_TAG_PREFIX,
306
+ useProjectHostname: authScope === 'project',
307
+ ...(apiHost && {apiHost}),
308
+ })
309
+
310
+ const {token} = await client.request<{token: string; label: string}>({
311
+ method: 'GET',
312
+ uri: '/auth/fetch',
313
+ query: {sid: authCode},
314
+ tag: 'fetch-token',
315
+ })
316
+
317
+ storageArea?.setItem(storageKey, JSON.stringify({token}))
318
+ store.setState({authState: {type: 'logged-in', token, currentUser: null}})
319
+
320
+ const loc = new URL(locationHref)
321
+ loc.hash = ''
322
+ return loc.toString()
323
+ } catch (error) {
324
+ store.setState({authState: {type: 'error', error}}, undefined, 'exchangeSessionForTokenError')
325
+ return false
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Fetches the providers from `/auth/providers`, adds params to each url, and
331
+ * caches the result for synchronous usage.
332
+ */
333
+ async function fetchLoginUrls() {
334
+ const client = clientFactory({
335
+ projectId,
336
+ dataset,
337
+ apiVersion: DEFAULT_API_VERSION,
338
+ requestTagPrefix: REQUEST_TAG_PREFIX,
339
+ useProjectHostname: authScope === 'project',
340
+ ...(apiHost && {apiHost}),
341
+ })
342
+
343
+ const {providers: defaultProviders} = await client.request<{providers: AuthProvider[]}>({
344
+ uri: '/auth/providers',
345
+ tag: 'fetch-providers',
346
+ })
347
+
348
+ let providers: AuthProvider[]
349
+
350
+ if (typeof customProviders === 'function') {
351
+ providers = await customProviders(defaultProviders)
352
+ } else if (!customProviders?.length) {
353
+ providers = defaultProviders
354
+ } else {
355
+ const customProviderUrls = new Set(customProviders.map((p) => p.url))
356
+ providers = defaultProviders
357
+ .filter((official) => !customProviderUrls.has(official.url))
358
+ .concat(customProviders)
359
+ }
360
+
361
+ const configuredProviders = providers.map((provider) => {
362
+ const url = new URL(provider.url)
363
+ const origin = new URL(
364
+ callbackUrl
365
+ ? new URL(callbackUrl, new URL(getDefaultLocation()).origin).toString()
366
+ : getDefaultLocation(),
367
+ )
368
+
369
+ // `getDefaultLocation()` may be populated with an `sid` from a previous
370
+ // failed login attempt and should be omitted from the next login URL
371
+ const hashParams = new URLSearchParams(origin.hash.slice(1))
372
+ hashParams.delete('sid')
373
+ origin.hash = hashParams.toString()
374
+
375
+ // similarly, the origin may be populated with an `error` query param if
376
+ // the auth provider redirects back to the application. this should also
377
+ // be omitted from the origin sent
378
+ origin.searchParams.delete('error')
379
+
380
+ url.searchParams.set('origin', origin.toString())
381
+ url.searchParams.set('withSid', 'true')
382
+ if (authScope === 'project') {
383
+ url.searchParams.set('projectId', projectId)
384
+ }
385
+
386
+ return {...provider, url: url.toString()}
387
+ })
388
+
389
+ store.setState({providers: configuredProviders}, undefined, 'fetchedLoginUrls')
390
+
391
+ return configuredProviders
392
+ }
393
+
394
+ const store = createStore<InternalAuthState>()(
395
+ devtools(
396
+ (set, get) => ({
397
+ authState: getInitialState(),
398
+ setAuthState: (authState: AuthState) => {
399
+ set({authState}, undefined, 'setAuthState')
400
+ },
401
+ providers: undefined,
402
+ setProviders: (providers: AuthProvider[] | undefined) => {
403
+ set({providers}, undefined, 'setProviders')
404
+ },
405
+ logout: async () => {
406
+ // If a token is statically provided, logout does nothing
407
+ if (providedToken) return
408
+
409
+ const {authState} = store.getState()
410
+
411
+ // If we already have an inflight request, no-op
412
+ if (authState.type === 'logged-out' && authState.isDestroyingSession) return
413
+ const token = authState.type === 'logged-in' && authState.token
414
+
415
+ try {
416
+ if (token) {
417
+ set(
418
+ {authState: {type: 'logged-out', isDestroyingSession: true}},
419
+ undefined,
420
+ 'loggingOut',
421
+ )
422
+
423
+ const client = clientFactory({
424
+ token,
425
+ projectId,
426
+ dataset,
427
+ requestTagPrefix: REQUEST_TAG_PREFIX,
428
+ apiVersion: DEFAULT_API_VERSION,
429
+ useProjectHostname: authScope === 'project',
430
+ ...(apiHost && {apiHost}),
431
+ })
432
+
433
+ await client.request<void>({uri: '/auth/logout', method: 'POST'})
434
+ }
435
+ } finally {
436
+ set(
437
+ {authState: {type: 'logged-out', isDestroyingSession: false}},
438
+ undefined,
439
+ 'logoutSuccess',
440
+ )
441
+ storageArea?.removeItem(storageKey)
442
+ }
443
+ },
444
+ getCurrent: () => get().authState,
445
+ handleCallback: (locationHref = getDefaultLocation()) => handleCallback(locationHref),
446
+ getLoginUrls: () => get().providers ?? fetchLoginUrls(),
447
+ dispose: () => {
448
+ storageSubscription.unsubscribe()
449
+ },
450
+ }),
451
+ {
452
+ name: 'SanityInternalAuthStore',
453
+ enabled: true, // Should be process.env.NODE_ENV === 'development',
454
+ },
455
+ ),
456
+ )
457
+
458
+ const storageSubscription = getStorageEvents()
459
+ .pipe(
460
+ filter(
461
+ (e): e is StorageEvent & {newValue: string} =>
462
+ e.storageArea === storageArea && e.key === storageKey,
463
+ ),
464
+ map(() => getTokenFromStorage()),
465
+ distinctUntilChanged(),
466
+ )
467
+ .subscribe((token) =>
468
+ store
469
+ .getState()
470
+ .setAuthState(
471
+ token
472
+ ? {type: 'logged-in', token, currentUser: null}
473
+ : {type: 'logged-out', isDestroyingSession: false},
474
+ ),
475
+ )
476
+
477
+ const fetchCurrentUser = (authState: Extract<AuthState, {type: 'logged-in'}>) => {
478
+ const client = clientFactory({
479
+ token: authState.token,
480
+ projectId,
481
+ dataset,
482
+ requestTagPrefix: REQUEST_TAG_PREFIX,
483
+ apiVersion: DEFAULT_API_VERSION,
484
+ useProjectHostname: authScope === 'project',
485
+ ...(apiHost && {apiHost}),
486
+ })
487
+
488
+ client
489
+ .request<CurrentUser>({uri: '/users/me', method: 'GET'})
490
+ .then((currentUser) =>
491
+ store.getState().setAuthState({
492
+ ...authState,
493
+ currentUser,
494
+ } as Extract<AuthState, {type: 'logged-in'}>),
495
+ )
496
+ .catch((error) => {
497
+ return store.getState().setAuthState({
498
+ ...authState,
499
+ type: 'error',
500
+ error,
501
+ } as Extract<AuthState, {type: 'error'}>)
502
+ })
503
+ }
504
+
505
+ if (
506
+ store.getState().authState.type === 'logged-in' &&
507
+ !(store.getState().authState as Extract<AuthState, {type: 'logged-in'}>).currentUser?.id
508
+ ) {
509
+ fetchCurrentUser(store.getState().authState as Extract<AuthState, {type: 'logged-in'}>)
510
+ }
511
+
512
+ store.subscribe((state) => {
513
+ if (state.authState.type === 'logged-in' && !state.authState.currentUser) {
514
+ fetchCurrentUser(state.authState)
515
+ }
516
+ })
517
+
518
+ return store
519
+ }
@@ -0,0 +1,41 @@
1
+ import type {SanityClient} from '@sanity/client'
2
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
3
+
4
+ import type {SanityInstance} from '../instance/types'
5
+ import {getClient} from './getClient'
6
+ import {type ClientStore, getClientStore} from './store/clientStore'
7
+
8
+ // Mock the getClientStore module
9
+ vi.mock('./store/clientStore', () => ({
10
+ getClientStore: vi.fn(),
11
+ }))
12
+
13
+ describe('getClient', () => {
14
+ const mockClient = {} as SanityClient
15
+ const getOrCreateClient = vi.fn()
16
+ const mockInstance = {} as SanityInstance
17
+
18
+ beforeEach(() => {
19
+ // Reset all mocks before each test
20
+ vi.clearAllMocks()
21
+
22
+ // Setup mock implementation
23
+ getOrCreateClient.mockReturnValue(mockClient)
24
+
25
+ const mockGetClientStore = vi.fn().mockReturnValue({
26
+ getOrCreateClient,
27
+ } as unknown as ClientStore)
28
+
29
+ // Set the mock implementation for getClientStore
30
+ vi.mocked(getClientStore).mockImplementation(mockGetClientStore)
31
+ })
32
+
33
+ it('should get client from store with provided options', () => {
34
+ const options = {apiVersion: '2024-01-01'}
35
+ const result = getClient(options, mockInstance)
36
+
37
+ expect(getClientStore).toHaveBeenCalledWith(mockInstance)
38
+ expect(getOrCreateClient).toHaveBeenCalledWith(options)
39
+ expect(result).toBe(mockClient)
40
+ })
41
+ })
@@ -0,0 +1,13 @@
1
+ import type {SanityClient} from '@sanity/client'
2
+
3
+ import type {SanityInstance} from '../instance/types'
4
+ import {type ClientOptions, getClientStore} from './store/clientStore'
5
+
6
+ /**
7
+ * Retrieve a memoized client based on the apiVersion.
8
+ * @public
9
+ */
10
+ export const getClient = (options: ClientOptions, instance: SanityInstance): SanityClient => {
11
+ const clientStore = getClientStore(instance)
12
+ return clientStore.getOrCreateClient(options)
13
+ }
@@ -0,0 +1,71 @@
1
+ import {type SanityClient} from '@sanity/client'
2
+ import {describe, expect, it, vi} from 'vitest'
3
+
4
+ import {config} from '../../test/fixtures'
5
+ import {createSanityInstance} from '../instance/sanityInstance'
6
+ import {getSubscribableClient} from './getSubscribableClient'
7
+ import {getClientStore} from './store/clientStore'
8
+
9
+ describe('getSubscribableClient', () => {
10
+ const API_VERSION = '2024-12-05'
11
+ const instance = createSanityInstance(config)
12
+ const store = getClientStore(instance)
13
+
14
+ it('should create subscribable client and emit initial client', () => {
15
+ const options = {apiVersion: API_VERSION}
16
+
17
+ const storeSpy = vi.spyOn(getClientStore(instance), 'getClientEvents')
18
+
19
+ const client$ = getSubscribableClient(options, instance)
20
+
21
+ client$.subscribe({
22
+ next: (emittedClient) => {
23
+ expect(storeSpy).toHaveBeenCalledWith(options)
24
+ expect(emittedClient.config().apiVersion).toBe(API_VERSION)
25
+ },
26
+ })
27
+ })
28
+
29
+ it('should emit updated client when store changes', () => {
30
+ const options = {apiVersion: API_VERSION}
31
+ const client$ = getSubscribableClient(options, instance)
32
+
33
+ // Track emissions
34
+ const emittedClients: SanityClient[] = []
35
+
36
+ client$.subscribe({
37
+ next: (client) => {
38
+ emittedClients.push(client)
39
+
40
+ if (emittedClients.length === 2) {
41
+ expect(emittedClients[0].config().token).toBe(undefined)
42
+ expect(emittedClients[1].config().token).toBe('new-token')
43
+ }
44
+ },
45
+ complete: () => {},
46
+ })
47
+
48
+ // Simulate auth change using the curried action
49
+ store.receiveToken('new-token')
50
+ })
51
+
52
+ it('should use the same client for same API version', async () => {
53
+ const options = {apiVersion: API_VERSION}
54
+ const client1$ = getSubscribableClient(options, instance)
55
+ const client2$ = getSubscribableClient(options, instance)
56
+
57
+ let firstClient: SanityClient | undefined
58
+
59
+ client1$.subscribe({
60
+ next: (client) => {
61
+ firstClient = client
62
+
63
+ client2$.subscribe({
64
+ next: (secondClient) => {
65
+ expect(secondClient).toBe(firstClient)
66
+ },
67
+ })
68
+ },
69
+ })
70
+ })
71
+ })
@@ -0,0 +1,17 @@
1
+ import type {SanityClient} from '@sanity/client'
2
+ import type {Subscribable} from 'rxjs'
3
+
4
+ import type {SanityInstance} from '../instance/types'
5
+ import {type ClientOptions, getClientStore} from './store/clientStore'
6
+
7
+ /**
8
+ * Creates a subscribable client based on the apiVersion.
9
+ * The client will update when the underlying store changes (e.g., on user authentication changes).
10
+ * @public
11
+ */
12
+ export const getSubscribableClient = (
13
+ options: ClientOptions,
14
+ instance: SanityInstance,
15
+ ): Subscribable<SanityClient> => {
16
+ return getClientStore(instance).getClientEvents(options)
17
+ }