@sanity/sdk 2.14.1 → 2.16.0
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.
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts +5 -4
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +1 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +152 -148
- package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
- package/dist/_chunks-es/version.js +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +32 -34
- package/src/auth/authStore.test.ts +1 -1
- package/src/auth/handleAuthCallback.test.ts +1 -1
- package/src/auth/refreshStampedToken.test.ts +199 -477
- package/src/auth/refreshStampedToken.ts +67 -195
- package/src/client/liveEvents.test.ts +151 -0
- package/src/client/liveEvents.ts +107 -0
- package/src/document/sharedListener.test.ts +1 -0
- package/src/document/sharedListener.ts +2 -0
- package/src/query/queryStore.test.ts +232 -38
- package/src/query/queryStore.ts +47 -47
- package/src/query/reducers.test.ts +0 -42
- package/src/query/reducers.ts +0 -16
- package/src/releases/getPerspectiveState.test.ts +3 -8
- package/src/releases/getPerspectiveState.ts +6 -19
- package/src/releases/observeReleases.test.ts +127 -0
- package/src/releases/observeReleases.ts +102 -0
- package/src/releases/releasesStore.test.ts +51 -53
- package/src/releases/releasesStore.ts +32 -32
- package/src/releases/utils/sortReleases.test.ts +36 -0
- package/src/releases/utils/sortReleases.ts +14 -9
|
@@ -2,8 +2,6 @@ import {
|
|
|
2
2
|
distinctUntilChanged,
|
|
3
3
|
exhaustMap,
|
|
4
4
|
filter,
|
|
5
|
-
firstValueFrom,
|
|
6
|
-
from,
|
|
7
5
|
map,
|
|
8
6
|
Observable,
|
|
9
7
|
type Subscription,
|
|
@@ -19,37 +17,6 @@ import {AuthStateType} from './authStateType'
|
|
|
19
17
|
import {type AuthState, type AuthStoreState} from './authStore'
|
|
20
18
|
|
|
21
19
|
const REFRESH_INTERVAL = 12 * 60 * 60 * 1000 // 12 hours in milliseconds
|
|
22
|
-
const LOCK_NAME = 'sanity-token-refresh-lock'
|
|
23
|
-
|
|
24
|
-
/** @internal */
|
|
25
|
-
export function getLastRefreshTime(storageArea: Storage | undefined, storageKey: string): number {
|
|
26
|
-
try {
|
|
27
|
-
const data = storageArea?.getItem(`${storageKey}_last_refresh`)
|
|
28
|
-
const parsed = data ? parseInt(data, 10) : 0
|
|
29
|
-
return isNaN(parsed) ? 0 : parsed
|
|
30
|
-
} catch {
|
|
31
|
-
return 0
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** @internal */
|
|
36
|
-
export function setLastRefreshTime(storageArea: Storage | undefined, storageKey: string): void {
|
|
37
|
-
try {
|
|
38
|
-
storageArea?.setItem(`${storageKey}_last_refresh`, Date.now().toString())
|
|
39
|
-
} catch {
|
|
40
|
-
// Ignore storage errors
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** @internal */
|
|
45
|
-
export function getNextRefreshDelay(storageArea: Storage | undefined, storageKey: string): number {
|
|
46
|
-
const lastRefresh = getLastRefreshTime(storageArea, storageKey)
|
|
47
|
-
if (!lastRefresh) return 0
|
|
48
|
-
|
|
49
|
-
const now = Date.now()
|
|
50
|
-
const nextRefreshTime = lastRefresh + REFRESH_INTERVAL
|
|
51
|
-
return Math.max(0, nextRefreshTime - now)
|
|
52
|
-
}
|
|
53
20
|
|
|
54
21
|
function createTokenRefreshStream(
|
|
55
22
|
token: string,
|
|
@@ -82,57 +49,6 @@ function createTokenRefreshStream(
|
|
|
82
49
|
})
|
|
83
50
|
}
|
|
84
51
|
|
|
85
|
-
async function acquireTokenRefreshLock(
|
|
86
|
-
refreshFn: () => Promise<void>,
|
|
87
|
-
storageArea: Storage | undefined,
|
|
88
|
-
storageKey: string,
|
|
89
|
-
): Promise<boolean> {
|
|
90
|
-
if (!navigator.locks) {
|
|
91
|
-
// If Web Locks API is not supported, perform an immediate, uncoordinated refresh.
|
|
92
|
-
// eslint-disable-next-line no-console
|
|
93
|
-
console.warn('Web Locks API not supported. Proceeding with uncoordinated refresh.')
|
|
94
|
-
await refreshFn()
|
|
95
|
-
setLastRefreshTime(storageArea, storageKey)
|
|
96
|
-
return true // Indicate success to allow stream processing, though it won't loop.
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
try {
|
|
100
|
-
// Attempt to acquire an exclusive lock for token refresh coordination.
|
|
101
|
-
// The callback handles the continuous refresh cycle while the lock is held.
|
|
102
|
-
const result = await navigator.locks.request(LOCK_NAME, {mode: 'exclusive'}, async (lock) => {
|
|
103
|
-
if (!lock) return false // Lock not granted
|
|
104
|
-
|
|
105
|
-
// Problematic infinite loop - needs redesign for graceful termination.
|
|
106
|
-
// This loop continuously refreshes the token at REFRESH_INTERVAL.
|
|
107
|
-
while (true) {
|
|
108
|
-
const delay = getNextRefreshDelay(storageArea, storageKey)
|
|
109
|
-
if (delay > 0) {
|
|
110
|
-
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
111
|
-
}
|
|
112
|
-
try {
|
|
113
|
-
await refreshFn()
|
|
114
|
-
setLastRefreshTime(storageArea, storageKey)
|
|
115
|
-
} catch (error) {
|
|
116
|
-
// eslint-disable-next-line no-console
|
|
117
|
-
console.error('Token refresh failed within lock:', error)
|
|
118
|
-
// Decide how to handle errors - break, retry, etc.? Currently logs and continues.
|
|
119
|
-
}
|
|
120
|
-
// Wait for the next interval
|
|
121
|
-
await new Promise((resolve) => setTimeout(resolve, REFRESH_INTERVAL))
|
|
122
|
-
}
|
|
123
|
-
// Unreachable due to while(true)
|
|
124
|
-
})
|
|
125
|
-
// The promise from navigator.locks.request resolves with the callback's return value,
|
|
126
|
-
// but only if the callback finishes. The infinite loop prevents this.
|
|
127
|
-
return result === true
|
|
128
|
-
} catch (error) {
|
|
129
|
-
// Handle potential errors during the initial lock request itself.
|
|
130
|
-
// eslint-disable-next-line no-console
|
|
131
|
-
console.error('Failed to request token refresh lock:', error)
|
|
132
|
-
return false // Indicate lock acquisition failure.
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
52
|
function shouldRefreshToken(lastRefresh: number | undefined): boolean {
|
|
137
53
|
if (!lastRefresh) return true
|
|
138
54
|
const timeSinceLastRefresh = Date.now() - lastRefresh
|
|
@@ -140,6 +56,14 @@ function shouldRefreshToken(lastRefresh: number | undefined): boolean {
|
|
|
140
56
|
}
|
|
141
57
|
|
|
142
58
|
/**
|
|
59
|
+
* Periodically refreshes stamped auth tokens (those containing `-st`).
|
|
60
|
+
*
|
|
61
|
+
* Two triggers cause a refresh while the user stays logged in:
|
|
62
|
+
* - a 12-hour interval timer (only fires while the tab is visible)
|
|
63
|
+
* - the tab becoming visible again after `lastTokenRefresh` has gone stale
|
|
64
|
+
*
|
|
65
|
+
* Non-stamped tokens (e.g. personal access tokens) are never refreshed.
|
|
66
|
+
*
|
|
143
67
|
* @internal
|
|
144
68
|
*/
|
|
145
69
|
export const refreshStampedToken = ({
|
|
@@ -151,96 +75,27 @@ export const refreshStampedToken = ({
|
|
|
151
75
|
const {clientFactory, apiHost, storageArea, storageKey} = state.get().options
|
|
152
76
|
|
|
153
77
|
const refreshToken$ = state.observable.pipe(
|
|
154
|
-
map((storeState) =>
|
|
155
|
-
authState: storeState.authState,
|
|
156
|
-
dashboardContext: storeState.dashboardContext,
|
|
157
|
-
})),
|
|
78
|
+
map((storeState) => storeState.authState),
|
|
158
79
|
filter(
|
|
159
|
-
(
|
|
160
|
-
|
|
161
|
-
): storeState is {
|
|
162
|
-
authState: Extract<AuthState, {type: AuthStateType.LOGGED_IN}>
|
|
163
|
-
dashboardContext: AuthStoreState['dashboardContext']
|
|
164
|
-
} => storeState.authState.type === AuthStateType.LOGGED_IN,
|
|
80
|
+
(authState): authState is Extract<AuthState, {type: AuthStateType.LOGGED_IN}> =>
|
|
81
|
+
authState.type === AuthStateType.LOGGED_IN,
|
|
165
82
|
),
|
|
166
|
-
distinctUntilChanged(
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
const currentToken = currentState.authState.token
|
|
184
|
-
|
|
185
|
-
logger.debug('Refreshing stamped token')
|
|
186
|
-
const response = await firstValueFrom(
|
|
187
|
-
createTokenRefreshStream(currentToken, clientFactory, apiHost),
|
|
188
|
-
)
|
|
189
|
-
|
|
190
|
-
logger.info('Token refreshed successfully')
|
|
191
|
-
state.set('setRefreshStampedToken', (prev) => ({
|
|
192
|
-
authState:
|
|
193
|
-
prev.authState.type === AuthStateType.LOGGED_IN
|
|
194
|
-
? {...prev.authState, token: response.token}
|
|
195
|
-
: prev.authState,
|
|
196
|
-
}))
|
|
197
|
-
storageArea?.setItem(storageKey, JSON.stringify({token: response.token}))
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (storeState.dashboardContext) {
|
|
201
|
-
return new Observable<{token: string}>((subscriber) => {
|
|
202
|
-
const visibilityHandler = () => {
|
|
203
|
-
const currentState = state.get()
|
|
204
|
-
if (
|
|
205
|
-
document.visibilityState === 'visible' &&
|
|
206
|
-
currentState.authState.type === AuthStateType.LOGGED_IN &&
|
|
207
|
-
shouldRefreshToken(currentState.authState.lastTokenRefresh)
|
|
208
|
-
) {
|
|
209
|
-
createTokenRefreshStream(
|
|
210
|
-
currentState.authState.token,
|
|
211
|
-
clientFactory,
|
|
212
|
-
apiHost,
|
|
213
|
-
).subscribe({
|
|
214
|
-
next: (response) => {
|
|
215
|
-
state.set('setRefreshStampedToken', (prev) => ({
|
|
216
|
-
authState:
|
|
217
|
-
prev.authState.type === AuthStateType.LOGGED_IN
|
|
218
|
-
? {
|
|
219
|
-
...prev.authState,
|
|
220
|
-
token: response.token,
|
|
221
|
-
lastTokenRefresh: Date.now(),
|
|
222
|
-
}
|
|
223
|
-
: prev.authState,
|
|
224
|
-
}))
|
|
225
|
-
subscriber.next(response)
|
|
226
|
-
},
|
|
227
|
-
error: (error) => subscriber.error(error),
|
|
228
|
-
})
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const timerSubscription = timer(REFRESH_INTERVAL, REFRESH_INTERVAL)
|
|
233
|
-
.pipe(
|
|
234
|
-
filter(() => document.visibilityState === 'visible'),
|
|
235
|
-
switchMap(() => {
|
|
236
|
-
const currentState = state.get().authState
|
|
237
|
-
if (currentState.type !== AuthStateType.LOGGED_IN) {
|
|
238
|
-
throw new Error('User logged out before refresh could complete')
|
|
239
|
-
}
|
|
240
|
-
return createTokenRefreshStream(currentState.token, clientFactory, apiHost)
|
|
241
|
-
}),
|
|
242
|
-
)
|
|
243
|
-
.subscribe({
|
|
83
|
+
distinctUntilChanged((prev, curr) => prev.token === curr.token),
|
|
84
|
+
filter((authState) => authState.token.includes('-st')), // Ensure we only try to refresh stamped tokens
|
|
85
|
+
exhaustMap(() =>
|
|
86
|
+
new Observable<{token: string}>((subscriber) => {
|
|
87
|
+
const visibilityHandler = () => {
|
|
88
|
+
const currentState = state.get()
|
|
89
|
+
if (
|
|
90
|
+
document.visibilityState === 'visible' &&
|
|
91
|
+
currentState.authState.type === AuthStateType.LOGGED_IN &&
|
|
92
|
+
shouldRefreshToken(currentState.authState.lastTokenRefresh)
|
|
93
|
+
) {
|
|
94
|
+
createTokenRefreshStream(
|
|
95
|
+
currentState.authState.token,
|
|
96
|
+
clientFactory,
|
|
97
|
+
apiHost,
|
|
98
|
+
).subscribe({
|
|
244
99
|
next: (response) => {
|
|
245
100
|
state.set('setRefreshStampedToken', (prev) => ({
|
|
246
101
|
authState:
|
|
@@ -256,31 +111,48 @@ export const refreshStampedToken = ({
|
|
|
256
111
|
},
|
|
257
112
|
error: (error) => subscriber.error(error),
|
|
258
113
|
})
|
|
259
|
-
|
|
260
|
-
document.addEventListener('visibilitychange', visibilityHandler)
|
|
261
|
-
|
|
262
|
-
return () => {
|
|
263
|
-
document.removeEventListener('visibilitychange', visibilityHandler)
|
|
264
|
-
timerSubscription.unsubscribe()
|
|
265
114
|
}
|
|
266
|
-
}
|
|
267
|
-
takeWhile(() => state.get().authState.type === AuthStateType.LOGGED_IN),
|
|
268
|
-
map((response: {token: string}) => ({token: response.token})),
|
|
269
|
-
)
|
|
270
|
-
}
|
|
115
|
+
}
|
|
271
116
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
117
|
+
const timerSubscription = timer(REFRESH_INTERVAL, REFRESH_INTERVAL)
|
|
118
|
+
.pipe(
|
|
119
|
+
filter(() => document.visibilityState === 'visible'),
|
|
120
|
+
switchMap(() => {
|
|
121
|
+
const currentState = state.get().authState
|
|
122
|
+
if (currentState.type !== AuthStateType.LOGGED_IN) {
|
|
123
|
+
throw new Error('User logged out before refresh could complete')
|
|
124
|
+
}
|
|
125
|
+
return createTokenRefreshStream(currentState.token, clientFactory, apiHost)
|
|
126
|
+
}),
|
|
127
|
+
)
|
|
128
|
+
.subscribe({
|
|
129
|
+
next: (response) => {
|
|
130
|
+
state.set('setRefreshStampedToken', (prev) => ({
|
|
131
|
+
authState:
|
|
132
|
+
prev.authState.type === AuthStateType.LOGGED_IN
|
|
133
|
+
? {
|
|
134
|
+
...prev.authState,
|
|
135
|
+
token: response.token,
|
|
136
|
+
lastTokenRefresh: Date.now(),
|
|
137
|
+
}
|
|
138
|
+
: prev.authState,
|
|
139
|
+
}))
|
|
140
|
+
subscriber.next(response)
|
|
141
|
+
},
|
|
142
|
+
error: (error) => subscriber.error(error),
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
document.addEventListener('visibilitychange', visibilityHandler)
|
|
146
|
+
|
|
147
|
+
return () => {
|
|
148
|
+
document.removeEventListener('visibilitychange', visibilityHandler)
|
|
149
|
+
timerSubscription.unsubscribe()
|
|
150
|
+
}
|
|
151
|
+
}).pipe(
|
|
152
|
+
takeWhile(() => state.get().authState.type === AuthStateType.LOGGED_IN),
|
|
153
|
+
map((response: {token: string}) => ({token: response.token})),
|
|
154
|
+
),
|
|
155
|
+
),
|
|
284
156
|
)
|
|
285
157
|
|
|
286
158
|
return refreshToken$.subscribe({
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConnectionFailedError,
|
|
3
|
+
CorsOriginError,
|
|
4
|
+
DisconnectError,
|
|
5
|
+
type LiveEvent,
|
|
6
|
+
type LiveEventMessage,
|
|
7
|
+
type SanityClient,
|
|
8
|
+
} from '@sanity/client'
|
|
9
|
+
import {of, Subject} from 'rxjs'
|
|
10
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
11
|
+
|
|
12
|
+
import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
|
|
13
|
+
import {type StateSource} from '../store/createStateSourceAction'
|
|
14
|
+
import {getClientState} from './clientStore'
|
|
15
|
+
import {LIVE_EVENTS_RETRY_DELAY, observeLiveEvents} from './liveEvents'
|
|
16
|
+
|
|
17
|
+
vi.mock('./clientStore', () => ({getClientState: vi.fn()}))
|
|
18
|
+
|
|
19
|
+
describe('observeLiveEvents', () => {
|
|
20
|
+
let instance: SanityInstance
|
|
21
|
+
let liveEventSubjects: Subject<LiveEvent>[]
|
|
22
|
+
let events: ReturnType<typeof vi.fn>
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.clearAllMocks()
|
|
26
|
+
instance = createSanityInstance({projectId: 'test', dataset: 'test'})
|
|
27
|
+
|
|
28
|
+
liveEventSubjects = []
|
|
29
|
+
events = vi.fn().mockImplementation(() => {
|
|
30
|
+
const subject = new Subject<LiveEvent>()
|
|
31
|
+
liveEventSubjects.push(subject)
|
|
32
|
+
return subject
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
vi.mocked(getClientState).mockReturnValue({
|
|
36
|
+
observable: of({
|
|
37
|
+
config: vi.fn().mockReturnValue({token: 'token'}),
|
|
38
|
+
live: {events},
|
|
39
|
+
} as unknown as SanityClient),
|
|
40
|
+
} as StateSource<SanityClient>)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
instance.dispose()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('emits only message events', () => {
|
|
48
|
+
const messages: LiveEventMessage[] = []
|
|
49
|
+
const subscription = observeLiveEvents(instance, {onCorsError: vi.fn()}).subscribe((message) =>
|
|
50
|
+
messages.push(message),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
liveEventSubjects[0].next({type: 'welcome'} as LiveEvent)
|
|
54
|
+
liveEventSubjects[0].next({type: 'message', id: 'e1', tags: ['s1:a']} as LiveEvent)
|
|
55
|
+
|
|
56
|
+
expect(messages).toEqual([{type: 'message', id: 'e1', tags: ['s1:a']}])
|
|
57
|
+
subscription.unsubscribe()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('reports CORS errors through onCorsError and completes without erroring', () => {
|
|
61
|
+
const onCorsError = vi.fn()
|
|
62
|
+
const error = vi.fn()
|
|
63
|
+
const complete = vi.fn()
|
|
64
|
+
observeLiveEvents(instance, {onCorsError}).subscribe({error, complete})
|
|
65
|
+
|
|
66
|
+
const corsError = new CorsOriginError({projectId: 'test'})
|
|
67
|
+
liveEventSubjects[0].error(corsError)
|
|
68
|
+
|
|
69
|
+
expect(onCorsError).toHaveBeenCalledWith(corsError)
|
|
70
|
+
expect(error).not.toHaveBeenCalled()
|
|
71
|
+
expect(complete).toHaveBeenCalled()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('completes without retrying on DisconnectError', async () => {
|
|
75
|
+
vi.useFakeTimers()
|
|
76
|
+
try {
|
|
77
|
+
const error = vi.fn()
|
|
78
|
+
const complete = vi.fn()
|
|
79
|
+
observeLiveEvents(instance, {onCorsError: vi.fn()}).subscribe({error, complete})
|
|
80
|
+
|
|
81
|
+
liveEventSubjects[0].error(new DisconnectError('Server disconnected client'))
|
|
82
|
+
await vi.advanceTimersByTimeAsync(LIVE_EVENTS_RETRY_DELAY * 5)
|
|
83
|
+
|
|
84
|
+
expect(error).not.toHaveBeenCalled()
|
|
85
|
+
expect(complete).toHaveBeenCalled()
|
|
86
|
+
expect(events).toHaveBeenCalledTimes(1)
|
|
87
|
+
} finally {
|
|
88
|
+
vi.useRealTimers()
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('completes without retrying on a 4xx connection rejection', async () => {
|
|
93
|
+
vi.useFakeTimers()
|
|
94
|
+
try {
|
|
95
|
+
const error = vi.fn()
|
|
96
|
+
const complete = vi.fn()
|
|
97
|
+
observeLiveEvents(instance, {onCorsError: vi.fn()}).subscribe({error, complete})
|
|
98
|
+
|
|
99
|
+
// The server rejected the connection with a 401 (e.g. expired token) —
|
|
100
|
+
// it will keep rejecting, so retrying would reconnect once per second
|
|
101
|
+
// forever
|
|
102
|
+
liveEventSubjects[0].error(
|
|
103
|
+
new ConnectionFailedError('EventSource connection failed', {status: 401}),
|
|
104
|
+
)
|
|
105
|
+
await vi.advanceTimersByTimeAsync(LIVE_EVENTS_RETRY_DELAY * 5)
|
|
106
|
+
|
|
107
|
+
expect(error).not.toHaveBeenCalled()
|
|
108
|
+
expect(complete).toHaveBeenCalled()
|
|
109
|
+
expect(events).toHaveBeenCalledTimes(1)
|
|
110
|
+
} finally {
|
|
111
|
+
vi.useRealTimers()
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('retries a connection failure without a status (transient network failure)', async () => {
|
|
116
|
+
vi.useFakeTimers()
|
|
117
|
+
try {
|
|
118
|
+
const error = vi.fn()
|
|
119
|
+
const subscription = observeLiveEvents(instance, {onCorsError: vi.fn()}).subscribe({error})
|
|
120
|
+
|
|
121
|
+
// No status means the failure could be transient (native EventSource
|
|
122
|
+
// exposes no status) — reconnecting is correct here
|
|
123
|
+
liveEventSubjects[0].error(new ConnectionFailedError('EventSource connection failed'))
|
|
124
|
+
await vi.advanceTimersByTimeAsync(LIVE_EVENTS_RETRY_DELAY)
|
|
125
|
+
|
|
126
|
+
expect(events).toHaveBeenCalledTimes(2)
|
|
127
|
+
expect(error).not.toHaveBeenCalled()
|
|
128
|
+
subscription.unsubscribe()
|
|
129
|
+
} finally {
|
|
130
|
+
vi.useRealTimers()
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('retries the connection after server-initiated errors', async () => {
|
|
135
|
+
vi.useFakeTimers()
|
|
136
|
+
try {
|
|
137
|
+
const error = vi.fn()
|
|
138
|
+
const subscription = observeLiveEvents(instance, {onCorsError: vi.fn()}).subscribe({error})
|
|
139
|
+
expect(events).toHaveBeenCalledTimes(1)
|
|
140
|
+
|
|
141
|
+
liveEventSubjects[0].error(new Error('ChannelError'))
|
|
142
|
+
await vi.advanceTimersByTimeAsync(LIVE_EVENTS_RETRY_DELAY)
|
|
143
|
+
|
|
144
|
+
expect(events).toHaveBeenCalledTimes(2)
|
|
145
|
+
expect(error).not.toHaveBeenCalled()
|
|
146
|
+
subscription.unsubscribe()
|
|
147
|
+
} finally {
|
|
148
|
+
vi.useRealTimers()
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConnectionFailedError,
|
|
3
|
+
CorsOriginError,
|
|
4
|
+
DisconnectError,
|
|
5
|
+
type LiveEventMessage,
|
|
6
|
+
} from '@sanity/client'
|
|
7
|
+
import {catchError, defer, EMPTY, filter, type Observable, retry, switchMap} from 'rxjs'
|
|
8
|
+
|
|
9
|
+
import {type DocumentResource} from '../config/sanityConfig'
|
|
10
|
+
import {type SanityInstance} from '../store/createSanityInstance'
|
|
11
|
+
import {getClientState} from './clientStore'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* All live-events subscriptions must use identical connection options
|
|
15
|
+
* (API version, request tag, `includeDrafts`): `@sanity/client` caches the
|
|
16
|
+
* underlying EventSource per URL + options, so divergent options would
|
|
17
|
+
* silently split what should be a single shared connection.
|
|
18
|
+
*/
|
|
19
|
+
const LIVE_EVENTS_API_VERSION = 'v2025-05-06'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Delay before re-establishing the live events connection after a
|
|
23
|
+
* server-initiated error (e.g. ChannelError, MessageError). Live errors are
|
|
24
|
+
* retried rather than surfaced because an errored live connection would
|
|
25
|
+
* otherwise poison the consuming store. Plain connection drops never reach
|
|
26
|
+
* this — they are reconnected inside `@sanity/client`.
|
|
27
|
+
*
|
|
28
|
+
* @internal
|
|
29
|
+
*/
|
|
30
|
+
export const LIVE_EVENTS_RETRY_DELAY = 1000
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Options for {@link observeLiveEvents}.
|
|
34
|
+
* @internal
|
|
35
|
+
*/
|
|
36
|
+
export interface ObserveLiveEventsOptions {
|
|
37
|
+
/** Resource to scope the underlying client and live connection to. */
|
|
38
|
+
resource?: DocumentResource
|
|
39
|
+
/**
|
|
40
|
+
* Called when the connection fails due to a CORS misconfiguration. The
|
|
41
|
+
* error is swallowed (the stream never errors; it idles until a new client
|
|
42
|
+
* is emitted) so consumers can surface it as store state instead of a
|
|
43
|
+
* stream error.
|
|
44
|
+
*/
|
|
45
|
+
onCorsError: (error: unknown) => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Emits Live Content API messages for a dataset so consumers can invalidate
|
|
50
|
+
* fetched data via sync tags.
|
|
51
|
+
*
|
|
52
|
+
* All consumers observe the same underlying EventSource connection:
|
|
53
|
+
* `@sanity/client` caches the live-events stream per URL and connection
|
|
54
|
+
* options, and every subscription made through this function uses the same
|
|
55
|
+
* API version and request tag.
|
|
56
|
+
*
|
|
57
|
+
* @internal
|
|
58
|
+
*/
|
|
59
|
+
export function observeLiveEvents(
|
|
60
|
+
instance: SanityInstance,
|
|
61
|
+
{resource, onCorsError}: ObserveLiveEventsOptions,
|
|
62
|
+
): Observable<LiveEventMessage> {
|
|
63
|
+
return getClientState(instance, {
|
|
64
|
+
apiVersion: LIVE_EVENTS_API_VERSION,
|
|
65
|
+
resource,
|
|
66
|
+
}).observable.pipe(
|
|
67
|
+
switchMap((client) =>
|
|
68
|
+
defer(() =>
|
|
69
|
+
client.live.events({includeDrafts: !!client.config().token, tag: 'live-events'}),
|
|
70
|
+
).pipe(
|
|
71
|
+
catchError((error) => {
|
|
72
|
+
if (error instanceof CorsOriginError) {
|
|
73
|
+
// Swallow CORS errors without bubbling up so that they can be
|
|
74
|
+
// handled by the consumer (e.g. shown via the Cors Error component)
|
|
75
|
+
onCorsError(error)
|
|
76
|
+
return EMPTY
|
|
77
|
+
}
|
|
78
|
+
if (error instanceof DisconnectError) {
|
|
79
|
+
// The server explicitly told this client to stop reconnecting —
|
|
80
|
+
// end live updates without erroring (consumers keep serving data,
|
|
81
|
+
// they just stop receiving live invalidation)
|
|
82
|
+
return EMPTY
|
|
83
|
+
}
|
|
84
|
+
if (
|
|
85
|
+
error instanceof ConnectionFailedError &&
|
|
86
|
+
typeof error.status === 'number' &&
|
|
87
|
+
error.status >= 400 &&
|
|
88
|
+
error.status < 500
|
|
89
|
+
) {
|
|
90
|
+
// The server rejected the connection with a 4xx (e.g. a 401 from
|
|
91
|
+
// an expired token) — it will keep rejecting, so retrying would
|
|
92
|
+
// reconnect once per second forever. End live updates like a
|
|
93
|
+
// DisconnectError. A ConnectionFailedError without a status stays
|
|
94
|
+
// retryable: it may be a transient network failure.
|
|
95
|
+
return EMPTY
|
|
96
|
+
}
|
|
97
|
+
throw error
|
|
98
|
+
}),
|
|
99
|
+
// Server-initiated live errors (e.g. ChannelError, MessageError) are
|
|
100
|
+
// retried, not surfaced. Dropped connections are already reconnected
|
|
101
|
+
// inside @sanity/client.
|
|
102
|
+
retry({delay: LIVE_EVENTS_RETRY_DELAY}),
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
filter((e): e is LiveEventMessage => e.type === 'message'),
|
|
106
|
+
)
|
|
107
|
+
}
|
|
@@ -43,6 +43,8 @@ export function createSharedListener(
|
|
|
43
43
|
{
|
|
44
44
|
events: ['mutation', 'welcome', 'reconnect'],
|
|
45
45
|
includeResult: false,
|
|
46
|
+
// the document store handles version documents, so we need to include all versions
|
|
47
|
+
includeAllVersions: true,
|
|
46
48
|
tag: 'document-listener',
|
|
47
49
|
// // from manual testing, it seems like mendoza patches may be
|
|
48
50
|
// // causing some ambiguity/wonkiness
|