@sanity/client 6.24.2 → 6.24.4

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/src/data/live.ts CHANGED
@@ -1,15 +1,19 @@
1
- import {Observable} from 'rxjs'
1
+ import {catchError, concat, EMPTY, mergeMap, Observable, of} from 'rxjs'
2
+ import {map} from 'rxjs/operators'
2
3
 
3
4
  import {CorsOriginError} from '../http/errors'
4
5
  import type {ObservableSanityClient, SanityClient} from '../SanityClient'
5
6
  import type {
6
- Any,
7
7
  LiveEventMessage,
8
8
  LiveEventReconnect,
9
9
  LiveEventRestart,
10
10
  LiveEventWelcome,
11
+ SyncTag,
11
12
  } from '../types'
12
13
  import {_getDataUrl} from './dataMethods'
14
+ import {connectEventSource} from './eventsource'
15
+ import {eventSourcePolyfill} from './eventsourcePolyfill'
16
+ import {reconnectOnConnectionFailure} from './reconnectOnConnectionFailure'
13
17
 
14
18
  const requiredApiVersion = '2021-03-26'
15
19
 
@@ -72,8 +76,6 @@ export class LiveClient {
72
76
  if (includeDrafts) {
73
77
  url.searchParams.set('includeDrafts', 'true')
74
78
  }
75
-
76
- const listenFor = ['restart', 'message', 'welcome', 'reconnect'] as const
77
79
  const esOptions: EventSourceInit & {headers?: Record<string, string>} = {}
78
80
  if (includeDrafts && token) {
79
81
  esOptions.headers = {
@@ -84,124 +86,62 @@ export class LiveClient {
84
86
  esOptions.withCredentials = true
85
87
  }
86
88
 
87
- return new Observable((observer) => {
88
- let es: InstanceType<typeof EventSource> | undefined
89
- let reconnectTimer: NodeJS.Timeout
90
- let stopped = false
91
- // Unsubscribe differs from stopped in that we will never reopen.
92
- // Once it is`true`, it will never be `false` again.
93
- let unsubscribed = false
94
-
95
- open()
96
-
97
- // EventSource will emit a regular event if it fails to connect, however the API will emit an `error` MessageEvent if the server goes down
98
- // So we need to handle both cases
99
- function onError(evt: MessageEvent | Event) {
100
- if (stopped) {
101
- return
102
- }
103
-
104
- // If the event has a `data` property, then it`s a MessageEvent emitted by the API and we should forward the error and close the connection
105
- if ('data' in evt) {
106
- const event = parseEvent(evt)
107
- observer.error(new Error(event.message, {cause: event}))
108
- }
109
-
110
- // Unless we've explicitly stopped the ES (in which case `stopped` should be true),
111
- // we should never be in a disconnected state. By default, EventSource will reconnect
112
- // automatically, in which case it sets readyState to `CONNECTING`, but in some cases
113
- // (like when a laptop lid is closed), it closes the connection. In these cases we need
114
- // to explicitly reconnect.
115
- if (es!.readyState === es!.CLOSED) {
116
- unsubscribe()
117
- clearTimeout(reconnectTimer)
118
- reconnectTimer = setTimeout(open, 100)
119
- }
120
- }
121
-
122
- function onMessage(evt: Any) {
123
- const event = parseEvent(evt)
124
- return event instanceof Error ? observer.error(event) : observer.next(event)
125
- }
126
-
127
- function unsubscribe() {
128
- if (!es) return
129
- es.removeEventListener('error', onError)
130
- for (const type of listenFor) {
131
- es.removeEventListener(type, onMessage)
132
- }
133
- es.close()
134
- }
135
-
136
- async function getEventSource() {
137
- const EventSourceImplementation: typeof EventSource =
138
- typeof EventSource === 'undefined' || esOptions.headers || esOptions.withCredentials
139
- ? ((await import('@sanity/eventsource')).default as unknown as typeof EventSource)
140
- : EventSource
141
-
142
- // If the listener has been unsubscribed from before we managed to load the module,
143
- // do not set up the EventSource.
144
- if (unsubscribed) {
145
- return
146
- }
147
-
148
- // Detect if CORS is allowed, the way the CORS is checked supports preflight caching, so when the EventSource boots up it knows it sees the preflight was already made and we're good to go
149
- try {
150
- await fetch(url, {
151
- method: 'OPTIONS',
152
- mode: 'cors',
153
- credentials: esOptions.withCredentials ? 'include' : 'omit',
154
- headers: esOptions.headers,
155
- })
156
- if (unsubscribed) {
157
- return
158
- }
159
- } catch {
160
- // If the request fails, then we assume it was due to CORS, and we rethrow a special error that allows special handling in userland
161
- throw new CorsOriginError({projectId: projectId!})
89
+ const initEventSource = () =>
90
+ // use polyfill if there is no global EventSource or if we need to set headers
91
+ (typeof EventSource === 'undefined' || esOptions.headers
92
+ ? eventSourcePolyfill
93
+ : of(EventSource)
94
+ ).pipe(map((EventSource) => new EventSource(url.href, esOptions)))
95
+
96
+ const events = connectEventSource(initEventSource, [
97
+ 'message',
98
+ 'restart',
99
+ 'welcome',
100
+ 'reconnect',
101
+ ]).pipe(
102
+ reconnectOnConnectionFailure(),
103
+ map((event) => {
104
+ if (event.type === 'message') {
105
+ const {data, ...rest} = event
106
+ // Splat data properties from the eventsource message onto the returned event
107
+ return {...rest, tags: (data as {tags: SyncTag[]}).tags} as LiveEventMessage
162
108
  }
163
-
164
- const evs = new EventSourceImplementation(url.toString(), esOptions)
165
- evs.addEventListener('error', onError)
166
- for (const type of listenFor) {
167
- evs.addEventListener(type, onMessage)
168
- }
169
- return evs
170
- }
171
-
172
- function open() {
173
- getEventSource()
174
- .then((eventSource) => {
175
- if (eventSource) {
176
- es = eventSource
177
- // Handle race condition where the observer is unsubscribed before the EventSource is set up
178
- if (unsubscribed) {
179
- unsubscribe()
180
- }
181
- }
182
- })
183
- .catch((reason) => {
184
- observer.error(reason)
185
- stop()
186
- })
187
- }
188
-
189
- function stop() {
190
- stopped = true
191
- unsubscribe()
192
- unsubscribed = true
193
- }
194
-
195
- return stop
196
- })
109
+ return event as LiveEventRestart | LiveEventReconnect | LiveEventWelcome
110
+ }),
111
+ )
112
+
113
+ // Detect if CORS is allowed, the way the CORS is checked supports preflight caching, so when the EventSource boots up it knows it sees the preflight was already made and we're good to go
114
+ const checkCors = fetchObservable(url, {
115
+ method: 'OPTIONS',
116
+ mode: 'cors',
117
+ credentials: esOptions.withCredentials ? 'include' : 'omit',
118
+ headers: esOptions.headers,
119
+ }).pipe(
120
+ mergeMap(() => EMPTY),
121
+ catchError(() => {
122
+ // If the request fails, then we assume it was due to CORS, and we rethrow a special error that allows special handling in userland
123
+ throw new CorsOriginError({projectId: projectId!})
124
+ }),
125
+ )
126
+ return concat(checkCors, events)
197
127
  }
198
128
  }
199
129
 
200
- function parseEvent(event: MessageEvent) {
201
- try {
202
- const data = (event.data && JSON.parse(event.data)) || {}
203
- return {type: event.type, id: event.lastEventId, ...data}
204
- } catch (err) {
205
- return err
206
- }
130
+ function fetchObservable(url: URL, init: RequestInit) {
131
+ return new Observable((observer) => {
132
+ const controller = new AbortController()
133
+ const signal = controller.signal
134
+ fetch(url, {...init, signal: controller.signal}).then(
135
+ (response) => {
136
+ observer.next(response)
137
+ observer.complete()
138
+ },
139
+ (err) => {
140
+ if (!signal.aborted) {
141
+ observer.error(err)
142
+ }
143
+ },
144
+ )
145
+ return () => controller.abort()
146
+ })
207
147
  }
@@ -0,0 +1,30 @@
1
+ import {
2
+ catchError,
3
+ concat,
4
+ mergeMap,
5
+ Observable,
6
+ of,
7
+ type OperatorFunction,
8
+ throwError,
9
+ timer,
10
+ } from 'rxjs'
11
+
12
+ import {ConnectionFailedError} from './eventsource'
13
+
14
+ /**
15
+ * Note: connection failure is not the same as network disconnect which may happen more frequent.
16
+ * The EventSource instance will automatically reconnect in case of a network disconnect, however,
17
+ * in some rare cases a ConnectionFailed Error will be thrown and this operator explicitly retries these
18
+ */
19
+ export function reconnectOnConnectionFailure<T>(): OperatorFunction<T, T | {type: 'reconnect'}> {
20
+ return function (source: Observable<T>) {
21
+ return source.pipe(
22
+ catchError((err, caught) => {
23
+ if (err instanceof ConnectionFailedError) {
24
+ return concat(of({type: 'reconnect' as const}), timer(1000).pipe(mergeMap(() => caught)))
25
+ }
26
+ return throwError(() => err)
27
+ }),
28
+ )
29
+ }
30
+ }
@@ -7,6 +7,7 @@ import type {
7
7
  IdentifiedSanityDocumentStub,
8
8
  MultipleMutationResult,
9
9
  Mutation,
10
+ MutationSelection,
10
11
  PatchOperations,
11
12
  SanityDocument,
12
13
  SanityDocumentStub,
@@ -213,6 +214,13 @@ export class Transaction extends BaseTransaction {
213
214
  * @param patchOps - Operations to perform, or a builder function
214
215
  */
215
216
  patch(documentId: string, patchOps?: PatchBuilder | PatchOperations): this
217
+ /**
218
+ * Performs a patch on the given selection. Can either be a builder function or an object of patch operations.
219
+ *
220
+ * @param selection - An object with `query` and optional `params`, defining which document(s) to patch
221
+ * @param patchOps - Operations to perform, or a builder function
222
+ */
223
+ patch(patch: MutationSelection, patchOps?: PatchBuilder | PatchOperations): this
216
224
  /**
217
225
  * Adds the given patch instance to the transaction.
218
226
  * The operation is added to the current transaction, ready to be commited by `commit()`
@@ -220,9 +228,15 @@ export class Transaction extends BaseTransaction {
220
228
  * @param patch - Patch to execute
221
229
  */
222
230
  patch(patch: Patch): this
223
- patch(patchOrDocumentId: Patch | string, patchOps?: PatchBuilder | PatchOperations): this {
231
+ patch(
232
+ patchOrDocumentId: Patch | MutationSelection | string,
233
+ patchOps?: PatchBuilder | PatchOperations,
234
+ ): this {
224
235
  const isBuilder = typeof patchOps === 'function'
225
236
  const isPatch = typeof patchOrDocumentId !== 'string' && patchOrDocumentId instanceof Patch
237
+ const isMutationSelection =
238
+ typeof patchOrDocumentId === 'object' &&
239
+ ('query' in patchOrDocumentId || 'id' in patchOrDocumentId)
226
240
 
227
241
  // transaction.patch(client.patch('documentId').inc({visits: 1}))
228
242
  if (isPatch) {
@@ -239,6 +253,17 @@ export class Transaction extends BaseTransaction {
239
253
  return this._add({patch: patch.serialize()})
240
254
  }
241
255
 
256
+ /**
257
+ * transaction.patch(
258
+ * {query: "*[_type == 'person' && points >= $threshold]", params: { threshold: 100 }},
259
+ * {dec: { points: 100 }, inc: { bonuses: 1 }}
260
+ * )
261
+ */
262
+ if (isMutationSelection) {
263
+ const patch = new Patch(patchOrDocumentId, patchOps || {}, this.#client)
264
+ return this._add({patch: patch.serialize()})
265
+ }
266
+
242
267
  return this._add({patch: {id: patchOrDocumentId, ...patchOps}})
243
268
  }
244
269
  }
@@ -4,6 +4,17 @@ import {defineHttpRequest} from './http/request'
4
4
  import type {Any, ClientConfig, HttpRequest} from './types'
5
5
 
6
6
  export {validateApiPerspective} from './config'
7
+ export {
8
+ ChannelError,
9
+ connectEventSource,
10
+ ConnectionFailedError,
11
+ DisconnectError,
12
+ type EventSourceEvent,
13
+ type EventSourceInstance,
14
+ MessageError,
15
+ MessageParseError,
16
+ type ServerSentEvent,
17
+ } from './data/eventsource'
7
18
  export * from './data/patch'
8
19
  export * from './data/transaction'
9
20
  export {ClientError, CorsOriginError, ServerError} from './http/errors'
package/src/types.ts CHANGED
@@ -854,6 +854,15 @@ export type ReconnectEvent = {
854
854
  type: 'reconnect'
855
855
  }
856
856
 
857
+ /**
858
+ * The listener connection has been established
859
+ * note: it's usually a better option to use the 'welcome' event
860
+ * @public
861
+ */
862
+ export type OpenEvent = {
863
+ type: 'open'
864
+ }
865
+
857
866
  /**
858
867
  * The listener has been established, and will start receiving events.
859
868
  * Note that this is also emitted upon _reconnection_.
@@ -872,6 +881,7 @@ export type ListenEvent<R extends Record<string, Any>> =
872
881
  | DisconnectEvent
873
882
  | ReconnectEvent
874
883
  | WelcomeEvent
884
+ | OpenEvent
875
885
 
876
886
  /** @public */
877
887
  export type ListenEventName =