@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/client",
3
- "version": "6.24.2",
3
+ "version": "6.24.4",
4
4
  "description": "Client for retrieving, creating and patching data from Sanity.io",
5
5
  "keywords": [
6
6
  "sanity",
@@ -119,7 +119,7 @@
119
119
  },
120
120
  "dependencies": {
121
121
  "@sanity/eventsource": "^5.0.2",
122
- "get-it": "^8.6.5",
122
+ "get-it": "^8.6.6",
123
123
  "rxjs": "^7.0.0"
124
124
  },
125
125
  "devDependencies": {
@@ -128,7 +128,7 @@
128
128
  "@rollup/plugin-commonjs": "^28.0.2",
129
129
  "@rollup/plugin-node-resolve": "^16.0.0",
130
130
  "@sanity/client-latest": "npm:@sanity/client@latest",
131
- "@sanity/pkg-utils": "^6.13.2",
131
+ "@sanity/pkg-utils": "^7.0.1",
132
132
  "@types/json-diff": "^1.0.3",
133
133
  "@types/node": "^22.9.0",
134
134
  "@typescript-eslint/eslint-plugin": "^8.19.1",
@@ -152,7 +152,7 @@
152
152
  "rollup": "^4.30.1",
153
153
  "sse-channel": "^4.0.0",
154
154
  "terser": "^5.37.0",
155
- "typescript": "5.7.2",
155
+ "typescript": "5.7.3",
156
156
  "vitest": "2.1.8",
157
157
  "vitest-github-actions-reporter": "0.11.1"
158
158
  },
@@ -0,0 +1,255 @@
1
+ import {defer, isObservable, mergeMap, Observable, of} from 'rxjs'
2
+
3
+ import {type Any} from '../types'
4
+
5
+ /**
6
+ * @public
7
+ * Thrown if the EventSource connection could not be established.
8
+ * Note that ConnectionFailedErrors are rare, and disconnects will normally be handled by the EventSource instance itself and emitted as `reconnect` events.
9
+ */
10
+ export class ConnectionFailedError extends Error {
11
+ readonly name = 'ConnectionFailedError'
12
+ }
13
+
14
+ /**
15
+ * The listener has been told to explicitly disconnect.
16
+ * This is a rare situation, but may occur if the API knows reconnect attempts will fail,
17
+ * eg in the case of a deleted dataset, a blocked project or similar events.
18
+ * @public
19
+ */
20
+ export class DisconnectError extends Error {
21
+ readonly name = 'DisconnectError'
22
+ readonly reason?: string
23
+ constructor(message: string, reason?: string, options: ErrorOptions = {}) {
24
+ super(message, options)
25
+ this.reason = reason
26
+ }
27
+ }
28
+
29
+ /**
30
+ * @public
31
+ * The server sent a `channelError` message. Usually indicative of a bad or malformed request
32
+ */
33
+ export class ChannelError extends Error {
34
+ readonly name = 'ChannelError'
35
+ readonly data?: unknown
36
+ constructor(message: string, data: unknown) {
37
+ super(message)
38
+ this.data = data
39
+ }
40
+ }
41
+
42
+ /**
43
+ * @public
44
+ * The server sent an `error`-event to tell the client that an unexpected error has happened.
45
+ */
46
+ export class MessageError extends Error {
47
+ readonly name = 'MessageError'
48
+ readonly data?: unknown
49
+ constructor(message: string, data: unknown, options: ErrorOptions = {}) {
50
+ super(message, options)
51
+ this.data = data
52
+ }
53
+ }
54
+
55
+ /**
56
+ * @public
57
+ * An error occurred while parsing the message sent by the server as JSON. Should normally not happen.
58
+ */
59
+ export class MessageParseError extends Error {
60
+ readonly name = 'MessageParseError'
61
+ }
62
+
63
+ /**
64
+ * @public
65
+ */
66
+ export interface ServerSentEvent<Name extends string> {
67
+ type: Name
68
+ id?: string
69
+ data?: unknown
70
+ }
71
+
72
+ // Always listen for these events, no matter what
73
+ const REQUIRED_EVENTS = ['channelError', 'disconnect']
74
+
75
+ /**
76
+ * @internal
77
+ */
78
+ export type EventSourceEvent<Name extends string> = ServerSentEvent<Name>
79
+
80
+ /**
81
+ * @internal
82
+ */
83
+ export type EventSourceInstance = InstanceType<typeof globalThis.EventSource>
84
+
85
+ /**
86
+ * Sanity API specific EventSource handler shared between the listen and live APIs
87
+ *
88
+ * Since the `EventSource` API is not provided by all environments, this function enables custom initialization of the EventSource instance
89
+ * for runtimes that requires polyfilling or custom setup logic (e.g. custom HTTP headers)
90
+ * via the passed `initEventSource` function which must return an EventSource instance.
91
+ *
92
+ * Possible errors to be thrown on the returned observable are:
93
+ * - {@link MessageError}
94
+ * - {@link MessageParseError}
95
+ * - {@link ChannelError}
96
+ * - {@link DisconnectError}
97
+ * - {@link ConnectionFailedError}
98
+ *
99
+ * @param initEventSource - A function that returns an EventSource instance or an Observable that resolves to an EventSource instance
100
+ * @param events - an array of named events from the API to listen for.
101
+ *
102
+ * @internal
103
+ */
104
+ export function connectEventSource<EventName extends string>(
105
+ initEventSource: () => EventSourceInstance | Observable<EventSourceInstance>,
106
+ events: EventName[],
107
+ ) {
108
+ return defer(() => {
109
+ const es = initEventSource()
110
+ return isObservable(es) ? es : of(es)
111
+ }).pipe(mergeMap((es) => connectWithESInstance(es, events))) as Observable<
112
+ ServerSentEvent<EventName>
113
+ >
114
+ }
115
+
116
+ /**
117
+ * Provides an observable from the passed EventSource instance, subscribing to the passed list of names of events types to listen for
118
+ * Handles connection logic, adding/removing event listeners, payload parsing, error propagation, etc.
119
+ *
120
+ * @param es - The EventSource instance
121
+ * @param events - List of event names to listen for
122
+ */
123
+ function connectWithESInstance<EventTypeName extends string>(
124
+ es: EventSourceInstance,
125
+ events: EventTypeName[],
126
+ ) {
127
+ return new Observable<EventSourceEvent<EventTypeName>>((observer) => {
128
+ const emitOpen = (events as string[]).includes('open')
129
+ const emitReconnect = (events as string[]).includes('reconnect')
130
+
131
+ // EventSource will emit a regular Event if it fails to connect, however the API may also emit an `error` MessageEvent
132
+ // So we need to handle both cases
133
+ function onError(evt: MessageEvent | Event) {
134
+ // If the event has a `data` property, then it`s a MessageEvent emitted by the API and we should forward the error
135
+ if ('data' in evt) {
136
+ const [parseError, event] = parseEvent(evt as MessageEvent)
137
+ observer.error(
138
+ parseError
139
+ ? new MessageParseError('Unable to parse EventSource error message', {cause: event})
140
+ : new MessageError((event?.data as {message: string}).message, event),
141
+ )
142
+ return
143
+ }
144
+
145
+ // We should never be in a disconnected state. By default, EventSource will reconnect
146
+ // automatically, but in some cases (like when a laptop lid is closed), it will trigger onError
147
+ // if it can't reconnect.
148
+ // see https://html.spec.whatwg.org/multipage/server-sent-events.html#sse-processing-model
149
+ if (es.readyState === es.CLOSED) {
150
+ // In these cases we'll signal to consumers (via the error path) that a retry/reconnect is needed.
151
+ observer.error(new ConnectionFailedError('EventSource connection failed'))
152
+ } else if (emitReconnect) {
153
+ observer.next({type: 'reconnect' as EventTypeName})
154
+ }
155
+ }
156
+
157
+ function onOpen() {
158
+ // The open event of the EventSource API is fired when a connection with an event source is opened.
159
+ observer.next({type: 'open' as EventTypeName})
160
+ }
161
+
162
+ function onMessage(message: MessageEvent) {
163
+ const [parseError, event] = parseEvent(message)
164
+ if (parseError) {
165
+ observer.error(
166
+ new MessageParseError('Unable to parse EventSource message', {cause: parseError}),
167
+ )
168
+ return
169
+ }
170
+ if (message.type === 'channelError') {
171
+ // An error occurred. This is different from a network-level error (which will be emitted as 'error').
172
+ // Possible causes are things such as malformed filters, non-existant datasets or similar.
173
+ observer.error(new ChannelError(extractErrorMessage(event?.data), event.data))
174
+ return
175
+ }
176
+ if (message.type === 'disconnect') {
177
+ // The listener has been told to explicitly disconnect and not reconnect.
178
+ // This is a rare situation, but may occur if the API knows reconnect attempts will fail,
179
+ // eg in the case of a deleted dataset, a blocked project or similar events.
180
+ observer.error(
181
+ new DisconnectError(
182
+ `Server disconnected client: ${
183
+ (event.data as {reason?: string})?.reason || 'unknown error'
184
+ }`,
185
+ ),
186
+ )
187
+ return
188
+ }
189
+ observer.next({
190
+ type: message.type as EventTypeName,
191
+ id: message.lastEventId,
192
+ ...(event.data ? {data: event.data} : {}),
193
+ })
194
+ }
195
+
196
+ es.addEventListener('error', onError)
197
+
198
+ if (emitOpen) {
199
+ es.addEventListener('open', onOpen)
200
+ }
201
+
202
+ // Make sure we have a unique list of events types to avoid listening multiple times,
203
+ const cleanedEvents = [...new Set([...REQUIRED_EVENTS, ...events])]
204
+ // filter out events that are handled separately
205
+ .filter((type) => type !== 'error' && type !== 'open' && type !== 'reconnect')
206
+
207
+ cleanedEvents.forEach((type: string) => es.addEventListener(type, onMessage))
208
+
209
+ return () => {
210
+ es.removeEventListener('error', onError)
211
+ if (emitOpen) {
212
+ es.removeEventListener('open', onOpen)
213
+ }
214
+ cleanedEvents.forEach((type: string) => es.removeEventListener(type, onMessage))
215
+ es.close()
216
+ }
217
+ })
218
+ }
219
+
220
+ function parseEvent(
221
+ message: MessageEvent,
222
+ ): [null, {type: string; id: string; data?: unknown}] | [Error, null] {
223
+ try {
224
+ const data = typeof message.data === 'string' && JSON.parse(message.data)
225
+ return [
226
+ null,
227
+ {
228
+ type: message.type,
229
+ id: message.lastEventId,
230
+ ...(isEmptyObject(data) ? {} : {data}),
231
+ },
232
+ ]
233
+ } catch (err) {
234
+ return [err as Error, null]
235
+ }
236
+ }
237
+
238
+ function extractErrorMessage(err: Any) {
239
+ if (!err.error) {
240
+ return err.message || 'Unknown listener error'
241
+ }
242
+
243
+ if (err.error.description) {
244
+ return err.error.description
245
+ }
246
+
247
+ return typeof err.error === 'string' ? err.error : JSON.stringify(err.error, null, 2)
248
+ }
249
+
250
+ function isEmptyObject(data: object) {
251
+ for (const _ in data) {
252
+ return false
253
+ }
254
+ return true
255
+ }
@@ -0,0 +1,7 @@
1
+ import {defer, shareReplay} from 'rxjs'
2
+ import {map} from 'rxjs/operators'
3
+
4
+ export const eventSourcePolyfill = defer(() => import('@sanity/eventsource')).pipe(
5
+ map(({default: EventSource}) => EventSource as unknown as typeof globalThis.EventSource),
6
+ shareReplay(1),
7
+ )
@@ -1,11 +1,21 @@
1
- import {Observable} from 'rxjs'
1
+ import {Observable, of, throwError} from 'rxjs'
2
+ import {filter, map} from 'rxjs/operators'
2
3
 
3
4
  import type {ObservableSanityClient, SanityClient} from '../SanityClient'
4
- import type {Any, ListenEvent, ListenOptions, ListenParams, MutationEvent} from '../types'
5
+ import {
6
+ type Any,
7
+ type ListenEvent,
8
+ type ListenOptions,
9
+ type ListenParams,
10
+ type MutationEvent,
11
+ } from '../types'
5
12
  import defaults from '../util/defaults'
6
13
  import {pick} from '../util/pick'
7
14
  import {_getDataUrl} from './dataMethods'
8
15
  import {encodeQueryString} from './encodeQueryString'
16
+ import {connectEventSource} from './eventsource'
17
+ import {eventSourcePolyfill} from './eventsourcePolyfill'
18
+ import {reconnectOnConnectionFailure} from './reconnectOnConnectionFailure'
9
19
 
10
20
  // Limit is 16K for a _request_, eg including headers. Have to account for an
11
21
  // unknown range of headers, but an average EventSource request from Chrome seems
@@ -67,11 +77,10 @@ export function _listen<R extends Record<string, Any> = Record<string, Any>>(
67
77
 
68
78
  const uri = `${url}${_getDataUrl(this, 'listen', qs)}`
69
79
  if (uri.length > MAX_URL_LENGTH) {
70
- return new Observable((observer) => observer.error(new Error('Query too large for listener')))
80
+ return throwError(() => new Error('Query too large for listener'))
71
81
  }
72
82
 
73
83
  const listenFor = options.events ? options.events : ['mutation']
74
- const shouldEmitReconnect = listenFor.indexOf('reconnect') !== -1
75
84
 
76
85
  const esOptions: EventSourceInit & {headers?: Record<string, string>} = {}
77
86
  if (token || withCredentials) {
@@ -84,142 +93,22 @@ export function _listen<R extends Record<string, Any> = Record<string, Any>>(
84
93
  }
85
94
  }
86
95
 
87
- return new Observable((observer) => {
88
- let es: InstanceType<typeof import('@sanity/eventsource')>
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
- function onError() {
98
- if (stopped) {
99
- return
100
- }
101
-
102
- emitReconnect()
103
-
104
- // Allow event handlers of `emitReconnect` to cancel/close the reconnect attempt
105
- if (stopped) {
106
- return
107
- }
108
-
109
- // Unless we've explicitly stopped the ES (in which case `stopped` should be true),
110
- // we should never be in a disconnected state. By default, EventSource will reconnect
111
- // automatically, in which case it sets readyState to `CONNECTING`, but in some cases
112
- // (like when a laptop lid is closed), it closes the connection. In these cases we need
113
- // to explicitly reconnect.
114
- if (es.readyState === es.CLOSED) {
115
- unsubscribe()
116
- clearTimeout(reconnectTimer)
117
- reconnectTimer = setTimeout(open, 100)
118
- }
119
- }
120
-
121
- function onChannelError(err: Any) {
122
- observer.error(cooerceError(err))
123
- }
124
-
125
- function onMessage(evt: Any) {
126
- const event = parseEvent(evt)
127
- return event instanceof Error ? observer.error(event) : observer.next(event)
128
- }
129
-
130
- function onDisconnect() {
131
- stopped = true
132
- unsubscribe()
133
- observer.complete()
134
- }
135
-
136
- function unsubscribe() {
137
- if (!es) return
138
- es.removeEventListener('error', onError)
139
- es.removeEventListener('channelError', onChannelError)
140
- es.removeEventListener('disconnect', onDisconnect)
141
- listenFor.forEach((type: string) => es.removeEventListener(type, onMessage))
142
- es.close()
143
- }
144
-
145
- function emitReconnect() {
146
- if (shouldEmitReconnect) {
147
- observer.next({type: 'reconnect'})
148
- }
149
- }
150
-
151
- async function getEventSource(): Promise<InstanceType<
152
- typeof import('@sanity/eventsource')
153
- > | void> {
154
- const {default: EventSource} = await import('@sanity/eventsource')
155
-
156
- // If the listener has been unsubscribed from before we managed to load the module,
157
- // do not set up the EventSource.
158
- if (unsubscribed) {
159
- return
160
- }
161
-
162
- const evs = new EventSource(uri, esOptions)
163
- evs.addEventListener('error', onError)
164
- evs.addEventListener('channelError', onChannelError)
165
- evs.addEventListener('disconnect', onDisconnect)
166
- listenFor.forEach((type: string) => evs.addEventListener(type, onMessage))
167
- return evs
168
- }
169
-
170
- function open() {
171
- getEventSource()
172
- .then((eventSource) => {
173
- if (eventSource) {
174
- es = eventSource
175
- // Handle race condition where the observer is unsubscribed before the EventSource is set up
176
- if (unsubscribed) {
177
- unsubscribe()
178
- }
179
- }
180
- })
181
- .catch((reason) => {
182
- observer.error(reason)
183
- stop()
184
- })
185
- }
186
-
187
- function stop() {
188
- stopped = true
189
- unsubscribe()
190
- unsubscribed = true
191
- }
192
-
193
- return stop
194
- })
195
- }
196
-
197
- function parseEvent(event: Any) {
198
- try {
199
- const data = (event.data && JSON.parse(event.data)) || {}
200
- return Object.assign({type: event.type}, data)
201
- } catch (err) {
202
- return err
203
- }
204
- }
205
-
206
- function cooerceError(err: Any) {
207
- if (err instanceof Error) {
208
- return err
209
- }
210
-
211
- const evt = parseEvent(err)
212
- return evt instanceof Error ? evt : new Error(extractErrorMessage(evt))
213
- }
214
-
215
- function extractErrorMessage(err: Any) {
216
- if (!err.error) {
217
- return err.message || 'Unknown listener error'
218
- }
219
-
220
- if (err.error.description) {
221
- return err.error.description
222
- }
223
-
224
- return typeof err.error === 'string' ? err.error : JSON.stringify(err.error, null, 2)
96
+ const initEventSource = () =>
97
+ // use polyfill if there is no global EventSource or if we need to set headers
98
+ (typeof EventSource === 'undefined' || esOptions.headers
99
+ ? eventSourcePolyfill
100
+ : of(EventSource)
101
+ ).pipe(map((EventSource) => new EventSource(uri, esOptions)))
102
+
103
+ return connectEventSource(initEventSource, listenFor).pipe(
104
+ reconnectOnConnectionFailure(),
105
+ filter((event) => listenFor.includes(event.type)),
106
+ map(
107
+ (event) =>
108
+ ({
109
+ type: event.type,
110
+ ...('data' in event ? (event.data as object) : {}),
111
+ }) as MutationEvent<R> | ListenEvent<R>,
112
+ ),
113
+ )
225
114
  }