@sanity/client 7.26.2 → 8.1.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.
Files changed (133) hide show
  1. package/README.md +63 -341
  2. package/dist/browserUpload-2tz6Sdqp.js +87 -0
  3. package/dist/browserUpload-2tz6Sdqp.js.map +1 -0
  4. package/dist/browserUpload-CwpNx7Vl.js +87 -0
  5. package/dist/browserUpload-CwpNx7Vl.js.map +1 -0
  6. package/dist/config-3wiPP-sZ.js +126 -0
  7. package/dist/config-3wiPP-sZ.js.map +1 -0
  8. package/dist/csm.d.ts +122 -335
  9. package/dist/csm.js +79 -91
  10. package/dist/csm.js.map +1 -1
  11. package/dist/dist-C9ExSk2R.js +73 -0
  12. package/dist/dist-C9ExSk2R.js.map +1 -0
  13. package/dist/dist-Z8cIRxoB.js +69 -0
  14. package/dist/dist-Z8cIRxoB.js.map +1 -0
  15. package/dist/index.d.ts +112 -6722
  16. package/dist/index.js +2950 -2848
  17. package/dist/index.js.map +1 -1
  18. package/dist/{index.d.cts → index.node.d.ts} +4529 -5322
  19. package/dist/index.node.js +3264 -0
  20. package/dist/index.node.js.map +1 -0
  21. package/dist/isRecord-Kfmt-nk-.js +7 -0
  22. package/dist/isRecord-Kfmt-nk-.js.map +1 -0
  23. package/dist/media-library.d.ts +9 -100
  24. package/dist/media-library.js +36 -14
  25. package/dist/media-library.js.map +1 -1
  26. package/dist/request-BhMuKj0D.js +373 -0
  27. package/dist/request-BhMuKj0D.js.map +1 -0
  28. package/dist/request-SnMg7nUX.js +355 -0
  29. package/dist/request-SnMg7nUX.js.map +1 -0
  30. package/dist/resolveEditInfo-Cz-smq3a.js +339 -0
  31. package/dist/resolveEditInfo-Cz-smq3a.js.map +1 -0
  32. package/dist/rolldown-runtime-vyAXikos.js +9 -0
  33. package/dist/stega.d.ts +78 -7137
  34. package/dist/stega.js +27 -20
  35. package/dist/stega.js.map +1 -1
  36. package/dist/stegaClean-C18wLWau.js +21 -0
  37. package/dist/stegaClean-C18wLWau.js.map +1 -0
  38. package/dist/stegaEncodeSourceMap-DbM2fTN4.js +373 -0
  39. package/dist/stegaEncodeSourceMap-DbM2fTN4.js.map +1 -0
  40. package/dist/stegaEncodeSourceMap-YR3NQ3iz.js +163 -0
  41. package/dist/stegaEncodeSourceMap-YR3NQ3iz.js.map +1 -0
  42. package/dist/{index.browser.d.cts → types-BODIEY7F.d.ts} +4196 -5258
  43. package/dist/types-CfGzbXrl.d.ts +108 -0
  44. package/package.json +80 -142
  45. package/src/SanityClient.ts +101 -137
  46. package/src/agent/actions/AgentActionsClient.ts +12 -12
  47. package/src/agent/actions/commonTypes.ts +2 -1
  48. package/src/agent/actions/generate.ts +26 -6
  49. package/src/agent/actions/patch.ts +27 -7
  50. package/src/agent/actions/prompt.ts +19 -5
  51. package/src/agent/actions/transform.ts +23 -5
  52. package/src/agent/actions/translate.ts +23 -5
  53. package/src/assets/AssetsClient.ts +121 -24
  54. package/src/csm/applySourceDocuments.ts +5 -5
  55. package/src/csm/createEditUrl.ts +1 -1
  56. package/src/csm/draftUtils.ts +23 -5
  57. package/src/csm/index.ts +2 -2
  58. package/src/csm/studioPath.ts +2 -1
  59. package/src/csm/types.ts +8 -2
  60. package/src/data/dataMethods.ts +784 -349
  61. package/src/data/eventsource.ts +82 -49
  62. package/src/data/listen.ts +18 -23
  63. package/src/data/live.ts +60 -35
  64. package/src/data/resolveEventSourceFetch.ts +104 -0
  65. package/src/datasets/DatasetsClient.ts +58 -41
  66. package/src/defineCreateClient.ts +38 -22
  67. package/src/functions/FunctionsClient.ts +66 -0
  68. package/src/functions/invoke.ts +176 -0
  69. package/src/http/browserMiddleware.ts +7 -1
  70. package/src/http/browserUpload.ts +163 -0
  71. package/src/http/errors.ts +61 -6
  72. package/src/http/nodeMiddleware.ts +94 -30
  73. package/src/http/request.ts +304 -87
  74. package/src/http/requestOptions.ts +131 -15
  75. package/src/{index.browser.ts → index.node.ts} +9 -4
  76. package/src/index.ts +4 -3
  77. package/src/mediaLibrary/MediaLibraryVideoClient.ts +27 -12
  78. package/src/projects/ProjectsClient.ts +18 -16
  79. package/src/releases/ReleasesClient.ts +38 -33
  80. package/src/stega/index.ts +1 -2
  81. package/src/stega/stegaEncodeSourceMap.ts +5 -0
  82. package/src/stega/types.ts +2 -2
  83. package/src/types.ts +328 -103
  84. package/src/users/UsersClient.ts +10 -14
  85. package/src/util/shareReplayLatest.ts +1 -1
  86. package/src/validators.ts +1 -1
  87. package/src/warnings.ts +13 -2
  88. package/dist/_chunks-cjs/config.cjs +0 -206
  89. package/dist/_chunks-cjs/config.cjs.map +0 -1
  90. package/dist/_chunks-cjs/isRecord.cjs +0 -6
  91. package/dist/_chunks-cjs/isRecord.cjs.map +0 -1
  92. package/dist/_chunks-cjs/resolveEditInfo.cjs +0 -334
  93. package/dist/_chunks-cjs/resolveEditInfo.cjs.map +0 -1
  94. package/dist/_chunks-cjs/stegaClean.cjs +0 -46
  95. package/dist/_chunks-cjs/stegaClean.cjs.map +0 -1
  96. package/dist/_chunks-cjs/stegaEncodeSourceMap.cjs +0 -388
  97. package/dist/_chunks-cjs/stegaEncodeSourceMap.cjs.map +0 -1
  98. package/dist/_chunks-es/config.js +0 -207
  99. package/dist/_chunks-es/config.js.map +0 -1
  100. package/dist/_chunks-es/isRecord.js +0 -7
  101. package/dist/_chunks-es/isRecord.js.map +0 -1
  102. package/dist/_chunks-es/resolveEditInfo.js +0 -335
  103. package/dist/_chunks-es/resolveEditInfo.js.map +0 -1
  104. package/dist/_chunks-es/stegaClean.js +0 -47
  105. package/dist/_chunks-es/stegaClean.js.map +0 -1
  106. package/dist/_chunks-es/stegaEncodeSourceMap.js +0 -389
  107. package/dist/_chunks-es/stegaEncodeSourceMap.js.map +0 -1
  108. package/dist/csm.cjs +0 -99
  109. package/dist/csm.cjs.map +0 -1
  110. package/dist/csm.d.cts +0 -391
  111. package/dist/index.browser.cjs +0 -3106
  112. package/dist/index.browser.cjs.map +0 -1
  113. package/dist/index.browser.d.ts +0 -6802
  114. package/dist/index.browser.js +0 -3082
  115. package/dist/index.browser.js.map +0 -1
  116. package/dist/index.cjs +0 -2946
  117. package/dist/index.cjs.map +0 -1
  118. package/dist/media-library.cjs +0 -20
  119. package/dist/media-library.cjs.map +0 -1
  120. package/dist/media-library.d.cts +0 -117
  121. package/dist/stega.browser.cjs +0 -29
  122. package/dist/stega.browser.cjs.map +0 -1
  123. package/dist/stega.browser.d.cts +0 -7200
  124. package/dist/stega.browser.d.ts +0 -7200
  125. package/dist/stega.browser.js +0 -24
  126. package/dist/stega.browser.js.map +0 -1
  127. package/dist/stega.cjs +0 -29
  128. package/dist/stega.cjs.map +0 -1
  129. package/dist/stega.d.cts +0 -7200
  130. package/src/data/eventsourcePolyfill.ts +0 -7
  131. package/umd/.gitkeep +0 -1
  132. package/umd/sanityClient.js +0 -6543
  133. package/umd/sanityClient.min.js +0 -6
@@ -1,21 +1,23 @@
1
+ import type {ErrorEvent, EventSourceConstructor} from 'eventsource'
1
2
  import {defer, isObservable, mergeMap, Observable, of} from 'rxjs'
2
3
 
3
4
  import {formatQueryParseError, isQueryParseError} from '../http/errors'
4
- import {type Any} from '../types'
5
+ import {isRecord} from '../util/isRecord'
5
6
 
6
7
  /**
7
- * @public
8
8
  * Thrown when the EventSource connection could not be established, or was rejected by the server.
9
9
  * Transient failures (network drops, 5xx, 408, 429) are reconnected internally and emitted as
10
10
  * `reconnect` events; a permanent rejection (any other 4xx, eg an expired token) errors the
11
11
  * stream with this class so consumers can react — check `status` for the rejection code.
12
+ *
13
+ * @public
12
14
  */
13
15
  export class ConnectionFailedError extends Error {
14
16
  readonly name = 'ConnectionFailedError'
15
17
  /**
16
18
  * HTTP status code of the rejected connection attempt, if known.
17
- * Only set when the EventSource implementation exposes it — the polyfill used in
18
- * Node.js and when custom headers (eg authorization) are required does, while
19
+ * Only set when the EventSource implementation exposes it — the `eventsource`
20
+ * package used by the client does (as `code` on its error events), while
19
21
  * native EventSource implementations (browser and Node.js) do not.
20
22
  */
21
23
  readonly status?: number
@@ -28,8 +30,9 @@ export class ConnectionFailedError extends Error {
28
30
 
29
31
  /**
30
32
  * The listener has been told to explicitly disconnect.
31
- * This is a rare situation, but may occur if the API knows reconnect attempts will fail,
32
- * eg in the case of a deleted dataset, a blocked project or similar events.
33
+ * This is a rare situation, but may occur if the API knows reconnect attempts will fail,
34
+ * eg in the case of a deleted dataset, a blocked project or similar events.
35
+ *
33
36
  * @public
34
37
  */
35
38
  export class DisconnectError extends Error {
@@ -42,8 +45,9 @@ export class DisconnectError extends Error {
42
45
  }
43
46
 
44
47
  /**
45
- * @public
46
48
  * The server sent a `channelError` message. Usually indicative of a bad or malformed request
49
+ *
50
+ * @public
47
51
  */
48
52
  export class ChannelError extends Error {
49
53
  readonly name = 'ChannelError'
@@ -55,8 +59,9 @@ export class ChannelError extends Error {
55
59
  }
56
60
 
57
61
  /**
58
- * @public
59
62
  * The server sent an `error`-event to tell the client that an unexpected error has happened.
63
+ *
64
+ * @public
60
65
  */
61
66
  export class MessageError extends Error {
62
67
  readonly name = 'MessageError'
@@ -68,8 +73,9 @@ export class MessageError extends Error {
68
73
  }
69
74
 
70
75
  /**
71
- * @public
72
76
  * An error occurred while parsing the message sent by the server as JSON. Should normally not happen.
77
+ *
78
+ * @public
73
79
  */
74
80
  export class MessageParseError extends Error {
75
81
  readonly name = 'MessageParseError'
@@ -95,7 +101,7 @@ export type EventSourceEvent<Name extends string> = ServerSentEvent<Name>
95
101
  /**
96
102
  * @internal
97
103
  */
98
- export type EventSourceInstance = InstanceType<typeof globalThis.EventSource>
104
+ export type EventSourceInstance = InstanceType<EventSourceConstructor>
99
105
 
100
106
  /**
101
107
  * Sanity API specific EventSource handler shared between the listen and live APIs
@@ -123,9 +129,7 @@ export function connectEventSource<EventName extends string>(
123
129
  return defer(() => {
124
130
  const es = initEventSource()
125
131
  return isObservable(es) ? es : of(es)
126
- }).pipe(mergeMap((es) => connectWithESInstance(es, events))) as Observable<
127
- ServerSentEvent<EventName>
128
- >
132
+ }).pipe(mergeMap((es) => connectWithESInstance(es, events)))
129
133
  }
130
134
 
131
135
  /**
@@ -140,19 +144,30 @@ function connectWithESInstance<EventTypeName extends string>(
140
144
  events: EventTypeName[],
141
145
  ) {
142
146
  return new Observable<EventSourceEvent<EventTypeName>>((observer) => {
143
- const emitOpen = (events as string[]).includes('open')
144
- const emitReconnect = (events as string[]).includes('reconnect')
147
+ // Events actually requested by the caller. Backs `isRequestedEvent`, the type
148
+ // guard used below to narrow plain strings (eg `message.type`) to `EventTypeName`
149
+ // without a cast.
150
+ const requestedEvents = new Set<string>(events)
151
+ const isRequestedEvent = (type: string): type is EventTypeName => requestedEvents.has(type)
152
+ const emitOpen = isRequestedEvent('open')
145
153
 
146
154
  // EventSource will emit a regular Event if it fails to connect, however the API may also emit an `error` MessageEvent
147
155
  // So we need to handle both cases
148
- function onError(evt: MessageEvent | Event) {
156
+ function onError(evt: ErrorEvent | MessageEvent) {
149
157
  // If the event has a `data` property, then it`s a MessageEvent emitted by the API and we should forward the error
150
158
  if ('data' in evt) {
151
- const [parseError, event] = parseEvent(evt as MessageEvent)
159
+ const [parseError, event] = parseEvent(evt)
152
160
  observer.error(
153
- parseError
154
- ? new MessageParseError('Unable to parse EventSource error message', {cause: event})
155
- : new MessageError((event?.data as {message: string}).message, event),
161
+ parseError || !event
162
+ ? new MessageParseError('Unable to parse EventSource error message', {
163
+ cause: parseError,
164
+ })
165
+ : new MessageError(
166
+ isRecord(event.data) && typeof event.data.message === 'string'
167
+ ? event.data.message
168
+ : '',
169
+ event,
170
+ ),
156
171
  )
157
172
  return
158
173
  }
@@ -161,34 +176,41 @@ function connectWithESInstance<EventTypeName extends string>(
161
176
  // automatically, but in some cases (like when a laptop lid is closed), it will trigger onError
162
177
  // if it can't reconnect.
163
178
  // see https://html.spec.whatwg.org/multipage/server-sent-events.html#sse-processing-model
164
- // The polyfills expose the HTTP status of a rejected connection on the error event
165
- // (native EventSource implementations do not). A status means the server rejected the
166
- // connection attempt, so error out regardless of readyState the polyfills disagree
167
- // on whether the connection closes before or after the error event is dispatched —
168
- // and let `reconnectOnConnectionFailure` classify it (4xx fatal, otherwise retried).
169
- const rawStatus = (evt as {status?: unknown}).status
170
- const status = typeof rawStatus === 'number' ? rawStatus : undefined
171
- if (status !== undefined) {
172
- observer.error(new ConnectionFailedError('EventSource connection failed', {status}))
179
+ // The `eventsource` package exposes the HTTP status of a rejected connection as
180
+ // `code` on the error event (native EventSource implementations expose nothing).
181
+ // A status means the server rejected the connection attempt, so error out
182
+ // regardless of readyState implementations disagree on whether the connection
183
+ // closes before or after the error event is dispatched — and let
184
+ // `reconnectOnConnectionFailure` classify it (4xx fatal, otherwise retried).
185
+ if (evt.code !== undefined) {
186
+ observer.error(
187
+ new ConnectionFailedError('EventSource connection failed', {status: evt.code}),
188
+ )
173
189
  return
174
190
  }
175
191
 
176
192
  if (es.readyState === es.CLOSED) {
177
193
  // In these cases we'll signal to consumers (via the error path) that a retry/reconnect is needed.
178
194
  observer.error(new ConnectionFailedError('EventSource connection failed'))
179
- } else if (emitReconnect) {
180
- observer.next({type: 'reconnect' as EventTypeName})
195
+ } else {
196
+ const type = 'reconnect'
197
+ if (isRequestedEvent(type)) {
198
+ observer.next({type})
199
+ }
181
200
  }
182
201
  }
183
202
 
184
203
  function onOpen() {
185
204
  // The open event of the EventSource API is fired when a connection with an event source is opened.
186
- observer.next({type: 'open' as EventTypeName})
205
+ const type = 'open'
206
+ if (isRequestedEvent(type)) {
207
+ observer.next({type})
208
+ }
187
209
  }
188
210
 
189
211
  function onMessage(message: MessageEvent) {
190
212
  const [parseError, event] = parseEvent(message)
191
- if (parseError) {
213
+ if (parseError || !event) {
192
214
  observer.error(
193
215
  new MessageParseError('Unable to parse EventSource message', {cause: parseError}),
194
216
  )
@@ -209,17 +231,25 @@ function connectWithESInstance<EventTypeName extends string>(
209
231
  observer.error(
210
232
  new DisconnectError(
211
233
  `Server disconnected client: ${
212
- (event.data as {reason?: string})?.reason || 'unknown error'
234
+ (isRecord(event.data) &&
235
+ typeof event.data.reason === 'string' &&
236
+ event.data.reason) ||
237
+ 'unknown error'
213
238
  }`,
214
239
  ),
215
240
  )
216
241
  return
217
242
  }
218
- observer.next({
219
- type: message.type as EventTypeName,
220
- id: message.lastEventId,
221
- ...(event.data ? {data: event.data} : {}),
222
- })
243
+ // `onMessage` is only ever registered for `REQUIRED_EVENTS` (handled above, and always
244
+ // returned from before reaching here) and the caller-requested `events` (see
245
+ // `cleanedEvents` below), so `message.type` is guaranteed to be a requested event here.
246
+ if (isRequestedEvent(message.type)) {
247
+ observer.next({
248
+ type: message.type,
249
+ id: message.lastEventId,
250
+ ...(event.data ? {data: event.data} : {}),
251
+ })
252
+ }
223
253
  }
224
254
 
225
255
  es.addEventListener('error', onError)
@@ -248,7 +278,7 @@ function connectWithESInstance<EventTypeName extends string>(
248
278
 
249
279
  function parseEvent(
250
280
  message: MessageEvent,
251
- ): [null, {type: string; id: string; data?: unknown}] | [Error, null] {
281
+ ): [null, {type: string; id: string; data?: unknown}] | [unknown, null] {
252
282
  try {
253
283
  const data = typeof message.data === 'string' && JSON.parse(message.data)
254
284
  return [
@@ -260,23 +290,26 @@ function parseEvent(
260
290
  },
261
291
  ]
262
292
  } catch (err) {
263
- return [err as Error, null]
293
+ return [err, null]
264
294
  }
265
295
  }
266
296
 
267
- function extractErrorMessage(err: Any, tag?: string | null) {
268
- const error = err.error
297
+ function extractErrorMessage(err: unknown, tag?: string | null): string {
298
+ const error = isRecord(err) ? err.error : undefined
269
299
 
270
300
  if (!error) {
271
- return err.message || 'Unknown listener error'
301
+ const message = isRecord(err) ? err.message : undefined
302
+ return (typeof message === 'string' && message) || 'Unknown listener error'
272
303
  }
273
304
 
274
- if (isQueryParseError(error)) {
275
- return formatQueryParseError(error, tag)
276
- }
305
+ if (isRecord(error)) {
306
+ if (isQueryParseError(error)) {
307
+ return formatQueryParseError(error, tag)
308
+ }
277
309
 
278
- if (error.description) {
279
- return error.description
310
+ if (typeof error.description === 'string') {
311
+ return error.description
312
+ }
280
313
  }
281
314
 
282
315
  return typeof error === 'string' ? error : JSON.stringify(error, null, 2)
@@ -1,4 +1,5 @@
1
- import {Observable, of, throwError} from 'rxjs'
1
+ import {EventSource} from 'eventsource'
2
+ import {Observable, throwError} from 'rxjs'
2
3
  import {filter, map} from 'rxjs/operators'
3
4
 
4
5
  import type {ObservableSanityClient, SanityClient} from '../SanityClient'
@@ -22,8 +23,8 @@ import {pick} from '../util/pick'
22
23
  import {_getDataUrl} from './dataMethods'
23
24
  import {encodeQueryString} from './encodeQueryString'
24
25
  import {connectEventSource} from './eventsource'
25
- import {eventSourcePolyfill} from './eventsourcePolyfill'
26
26
  import {reconnectOnConnectionFailure} from './reconnectOnConnectionFailure'
27
+ import {resolveEventSourceFetch} from './resolveEventSourceFetch'
27
28
 
28
29
  // Limit is 16K for a _request_, eg including headers. Have to account for an
29
30
  // unknown range of headers, but an average EventSource request from Chrome seems
@@ -57,7 +58,8 @@ const defaultOptions = {
57
58
  export type MapListenEventNamesToListenEvents<
58
59
  R extends Record<string, Any> = Record<string, Any>,
59
60
  Events extends (ResumableListenEventNames | ListenEventName)[] = (
60
- ResumableListenEventNames | ListenEventName
61
+ | ResumableListenEventNames
62
+ | ListenEventName
61
63
  )[],
62
64
  > = Events extends (infer E)[]
63
65
  ? E extends 'welcome'
@@ -134,7 +136,8 @@ export function _listen<
134
136
  params?: ListenParams,
135
137
  opts: Opts = {} as Opts,
136
138
  ): Observable<ListenEventFromOptions<R, Opts>> {
137
- const {url, token, withCredentials, requestTagPrefix, headers: configHeaders} = this.config()
139
+ const config = this.config()
140
+ const {url, token, withCredentials, requestTagPrefix, headers: configHeaders} = config
138
141
  const tag = opts.tag && requestTagPrefix ? [requestTagPrefix, opts.tag].join('.') : opts.tag
139
142
  const options = {...defaults(opts, defaultOptions), tag}
140
143
  const listenOpts = pick(options, possibleOptions)
@@ -147,29 +150,21 @@ export function _listen<
147
150
 
148
151
  const listenFor = (options.events ? options.events : ['mutation']) satisfies Opts['events']
149
152
 
150
- const esOptions: EventSourceInit & {headers?: Record<string, string>} = {}
151
- if (withCredentials) {
152
- esOptions.withCredentials = true
153
+ const headers: Record<string, string> = {}
154
+ if (token) {
155
+ headers.Authorization = `Bearer ${token}`
153
156
  }
154
-
155
- if (token || configHeaders) {
156
- esOptions.headers = {}
157
-
158
- if (token) {
159
- esOptions.headers.Authorization = `Bearer ${token}`
160
- }
161
-
162
- if (configHeaders) {
163
- Object.assign(esOptions.headers, configHeaders)
164
- }
157
+ if (configHeaders) {
158
+ Object.assign(headers, configHeaders)
165
159
  }
166
160
 
167
161
  const initEventSource = () =>
168
- // use polyfill if there is no global EventSource or if we need to set headers
169
- (typeof EventSource === 'undefined' || esOptions.headers
170
- ? eventSourcePolyfill
171
- : of(EventSource)
172
- ).pipe(map((EventSource) => new EventSource(uri, esOptions)))
162
+ new EventSource(uri, {
163
+ fetch: resolveEventSourceFetch(config, {
164
+ headers: Object.keys(headers).length ? headers : undefined,
165
+ withCredentials,
166
+ }),
167
+ })
173
168
 
174
169
  return connectEventSource(initEventSource, listenFor).pipe(
175
170
  reconnectOnConnectionFailure(),
package/src/data/live.ts CHANGED
@@ -1,9 +1,12 @@
1
+ import {EventSource} from 'eventsource'
2
+ import type {FetchFunction} from 'get-it'
1
3
  import {catchError, mergeMap, Observable, of, throwError} from 'rxjs'
2
4
  import {finalize, map} from 'rxjs/operators'
3
5
 
4
6
  import {CorsOriginError} from '../http/errors'
5
7
  import type {ObservableSanityClient, SanityClient} from '../SanityClient'
6
8
  import type {
9
+ InitializedClientConfig,
7
10
  LiveEvent,
8
11
  LiveEventGoAway,
9
12
  LiveEventMessage,
@@ -12,11 +15,12 @@ import type {
12
15
  LiveEventWelcome,
13
16
  SyncTag,
14
17
  } from '../types'
18
+ import {isRecord} from '../util/isRecord'
15
19
  import {shareReplayLatest} from '../util/shareReplayLatest'
16
20
  import {_getDataUrl} from './dataMethods'
17
21
  import {connectEventSource} from './eventsource'
18
- import {eventSourcePolyfill} from './eventsourcePolyfill'
19
22
  import {reconnectOnConnectionFailure} from './reconnectOnConnectionFailure'
23
+ import {pickBaseFetch, resolveEventSourceFetch} from './resolveEventSourceFetch'
20
24
 
21
25
  const requiredApiVersion = '2021-03-25'
22
26
 
@@ -50,6 +54,7 @@ export class LiveClient {
50
54
  */
51
55
  waitFor?: 'function'
52
56
  } = {}): Observable<LiveEvent> {
57
+ const config = this.#client.config()
53
58
  const {
54
59
  projectId,
55
60
  apiVersion: _apiVersion,
@@ -57,7 +62,7 @@ export class LiveClient {
57
62
  withCredentials,
58
63
  requestTagPrefix,
59
64
  headers: configHeaders,
60
- } = this.#client.config()
65
+ } = config
61
66
  const apiVersion = _apiVersion.replace(/^v/, '')
62
67
  if (apiVersion !== 'X' && apiVersion < requiredApiVersion) {
63
68
  throw new Error(
@@ -83,36 +88,39 @@ export class LiveClient {
83
88
  if (waitFor) {
84
89
  url.searchParams.set('waitFor', waitFor)
85
90
  }
86
- const esOptions: EventSourceInit & {headers?: Record<string, string>} = {}
87
- if (includeDrafts && withCredentials) {
88
- esOptions.withCredentials = true
91
+ const eventSourceHeaders: Record<string, string> = {}
92
+ if (includeDrafts && token) {
93
+ eventSourceHeaders.Authorization = `Bearer ${token}`
89
94
  }
90
-
91
- if ((includeDrafts && token) || configHeaders) {
92
- esOptions.headers = {}
93
-
94
- if (includeDrafts && token) {
95
- esOptions.headers.Authorization = `Bearer ${token}`
96
- }
97
-
98
- if (configHeaders) {
99
- Object.assign(esOptions.headers, configHeaders)
100
- }
95
+ if (configHeaders) {
96
+ Object.assign(eventSourceHeaders, configHeaders)
101
97
  }
98
+ const eventSourceWithCredentials = Boolean(includeDrafts && withCredentials)
102
99
 
103
- const key = `${url.href}::${JSON.stringify(esOptions)}`
104
- const existing = eventsCache.get(key)
100
+ let transportCache = eventsCache.get(config.resolveFetch)
101
+ if (!transportCache) {
102
+ transportCache = new Map()
103
+ eventsCache.set(config.resolveFetch, transportCache)
104
+ }
105
+ const cacheKey = JSON.stringify([
106
+ url.href,
107
+ typeof config.proxy === 'string' ? config.proxy : null,
108
+ eventSourceHeaders,
109
+ eventSourceWithCredentials,
110
+ ])
111
+ const existing = transportCache.get(cacheKey)
105
112
 
106
113
  if (existing) {
107
114
  return existing
108
115
  }
109
116
 
110
117
  const initEventSource = () =>
111
- // use polyfill if there is no global EventSource or if we need to set headers
112
- (typeof EventSource === 'undefined' || esOptions.headers
113
- ? eventSourcePolyfill
114
- : of(EventSource)
115
- ).pipe(map((EventSource) => new EventSource(url.href, esOptions)))
118
+ new EventSource(url.href, {
119
+ fetch: resolveEventSourceFetch(config, {
120
+ headers: Object.keys(eventSourceHeaders).length ? eventSourceHeaders : undefined,
121
+ withCredentials: eventSourceWithCredentials,
122
+ }),
123
+ })
116
124
 
117
125
  const events = connectEventSource(initEventSource, [
118
126
  'message',
@@ -125,7 +133,8 @@ export class LiveClient {
125
133
  const checkCors = checkCorsObservable(
126
134
  new URL(this.#client.getUrl('/check/cors', false)),
127
135
  projectId,
128
- esOptions.withCredentials === true,
136
+ eventSourceWithCredentials,
137
+ pickBaseFetch(config),
129
138
  )
130
139
 
131
140
  const observable = events
@@ -162,12 +171,15 @@ export class LiveClient {
162
171
  }),
163
172
  )
164
173
  .pipe(
165
- finalize(() => eventsCache.delete(key)),
174
+ finalize(() => {
175
+ transportCache.delete(cacheKey)
176
+ if (transportCache.size === 0) eventsCache.delete(config.resolveFetch)
177
+ }),
166
178
  shareReplayLatest({
167
179
  predicate: (event) => event.type === 'welcome',
168
180
  }),
169
181
  )
170
- eventsCache.set(key, observable)
182
+ transportCache.set(cacheKey, observable)
171
183
  return observable
172
184
  }
173
185
  }
@@ -212,35 +224,40 @@ function checkCorsObservable(
212
224
  url: URL,
213
225
  projectId: string | undefined,
214
226
  requireCredentials: boolean,
227
+ fetcher: FetchFunction,
215
228
  ): Observable<void> {
216
229
  return new Observable<void>((observer) => {
217
230
  const controller = new AbortController()
218
231
  const {signal} = controller
219
- fetch(url, {method: 'GET', mode: 'cors', credentials: 'omit', signal})
232
+ fetcher(url.href, {method: 'GET', credentials: 'omit', signal})
220
233
  .then((response) => {
221
234
  // Aborted or non-2xx: not a confirmed CORS rejection. Fall through with
222
235
  // an undefined body so the next step takes the silent-completion path.
223
- if (signal.aborted || !response.ok) return
224
- return response.json() as Promise<{
225
- result?: {allowed?: boolean; withCredentials?: boolean}
226
- }>
236
+ if (signal.aborted || !response.ok) return undefined
237
+ return response.text()
227
238
  })
228
- .then((body) => {
239
+ .then((text) => {
229
240
  if (signal.aborted) return
241
+ // `get-it`'s `FetchResponse` only guarantees `.text()`/`.arrayBuffer()`,
242
+ // not `.json()`, so the body is parsed by hand here. An empty/aborted
243
+ // fall-through (`text === undefined`) and malformed JSON both leave
244
+ // `result` unresolved, taking the same "no signal either way" path.
245
+ const parsed: unknown = text === undefined ? undefined : JSON.parse(text)
246
+ const result = isRecord(parsed) ? parsed.result : undefined
230
247
  // Check the credentialed case first: if the EventSource was about to
231
248
  // send credentials but the project's CORS config doesn't permit them,
232
249
  // the credentialed request would fail with a missing
233
250
  // `access-control-allow-credentials` header. Surface this as a CORS
234
251
  // rejection with `credentials: true` so the deep-link pre-selects the
235
252
  // "Allow credentials" toggle.
236
- if (requireCredentials && body?.result?.withCredentials === false) {
253
+ if (requireCredentials && isRecord(result) && result.withCredentials === false) {
237
254
  observer.error(new CorsOriginError({projectId, credentials: true}))
238
255
  return
239
256
  }
240
257
  // Generic case: the server actively rejected this origin. Propagate
241
258
  // `credentials: requireCredentials` so the deep-link still pre-selects
242
259
  // credentials when the caller needed them.
243
- if (body?.result?.allowed === false) {
260
+ if (isRecord(result) && result.allowed === false) {
244
261
  observer.error(new CorsOriginError({projectId, credentials: requireCredentials}))
245
262
  return
246
263
  }
@@ -262,4 +279,12 @@ function checkCorsObservable(
262
279
  })
263
280
  }
264
281
 
265
- const eventsCache = new Map<string, Observable<LiveEvent>>()
282
+ /**
283
+ * Cached observables capture their transport (`initEventSource` closes over
284
+ * `config.resolveFetch` and `config.proxy`), so the cache is scoped per
285
+ * resolver — `undefined` covers the `globalThis.fetch` fallback.
286
+ */
287
+ const eventsCache = new Map<
288
+ InitializedClientConfig['resolveFetch'],
289
+ Map<string, Observable<LiveEvent>>
290
+ >()
@@ -0,0 +1,104 @@
1
+ import type {EventSourceFetchInit, FetchLikeResponse} from 'eventsource'
2
+ import type {FetchFunction, FetchInit} from 'get-it'
3
+
4
+ import type {InitializedClientConfig} from '../types'
5
+
6
+ /** @internal */
7
+ export interface EventSourceFetchOptions {
8
+ /**
9
+ * Headers that must be merged into every EventSource request. Used for
10
+ * the `Authorization` token, custom `headers` from the client config,
11
+ * etc. — things the native EventSource API has no equivalent for.
12
+ */
13
+ headers?: Record<string, string>
14
+ /**
15
+ * If the client was configured with `withCredentials: true`, the
16
+ * resolved fetch forwards `credentials: 'include'` so the browser
17
+ * attaches cookies to the SSE request.
18
+ */
19
+ withCredentials?: boolean
20
+ }
21
+
22
+ /**
23
+ * Build a `fetch` implementation suitable for the `eventsource` package's
24
+ * `fetch` option. Routes the EventSource connection through the same
25
+ * transport layer the rest of the client uses, so things like the
26
+ * test-fetch override, the per-request `proxy` config, and `HTTPS_PROXY`
27
+ * env-var support apply to SSE too.
28
+ *
29
+ * Resolution order on each request:
30
+ *
31
+ * 1. `config.resolveFetch(config.proxy)` if set — the client's fetch
32
+ * resolver, so SSE uses the same transport as regular requests:
33
+ * custom fetch variants (incl. the test suite's injected mock),
34
+ * undici configuration, an explicit `proxy` config, and env-proxy
35
+ * support all apply to SSE too. The Node entry supplies get-it's
36
+ * undici-backed fetch (threading the resolver through the env
37
+ * instead of importing `get-it/node` directly keeps `undici` out of
38
+ * the browser bundle); the browser entry leaves it unset.
39
+ * 2. `globalThis.fetch`. Note that Node's global fetch does NOT read
40
+ * proxy env vars (that is opt-in via `NODE_USE_ENV_PROXY`), which
41
+ * is one of the reasons step 1 exists.
42
+ *
43
+ * The returned fetch always merges `options.headers` into the outgoing
44
+ * request, regardless of which underlying fetch was picked.
45
+ *
46
+ * @internal
47
+ */
48
+ export function resolveEventSourceFetch(
49
+ config: InitializedClientConfig,
50
+ options: EventSourceFetchOptions = {},
51
+ ): EventSourceFetch {
52
+ const extraHeaders = options.headers
53
+ const credentials: FetchInit['credentials'] = options.withCredentials ? 'include' : undefined
54
+
55
+ return function eventSourceFetch(url, init) {
56
+ const baseFetch = pickBaseFetch(config)
57
+
58
+ // Extra `EventSourceFetchInit` fields get-it's `FetchInit` doesn't
59
+ // declare (`mode`, `cache`) survive the spread and reach whichever
60
+ // fetch implementation is effective.
61
+ const mergedInit: FetchInit = {...init}
62
+ if (extraHeaders) {
63
+ const headers = new Headers(init?.headers)
64
+ for (const [key, value] of Object.entries(extraHeaders)) {
65
+ headers.set(key, value)
66
+ }
67
+ mergedInit.headers = headers
68
+ }
69
+ if (credentials !== undefined) {
70
+ mergedInit.credentials = credentials
71
+ }
72
+ // get-it's `FetchResponse` is a structural superset of the package's
73
+ // `FetchLikeResponse`, so it can be handed over as-is.
74
+ return baseFetch(typeof url === 'string' ? url : url.href, mergedInit)
75
+ }
76
+ }
77
+
78
+ /**
79
+ * The fetch shape handed to the `eventsource` package: accepts what its
80
+ * `FetchLike` passes in, requires only what get-it's `FetchFunction`
81
+ * guarantees back — the full `typeof fetch` contract is not needed anywhere
82
+ * in this chain.
83
+ *
84
+ * @internal
85
+ */
86
+ export type EventSourceFetch = (
87
+ url: string | URL,
88
+ init?: EventSourceFetchInit,
89
+ ) => Promise<FetchLikeResponse>
90
+
91
+ /**
92
+ * The fetch the client's own transport resolves for this config: the
93
+ * configured `resolveFetch` (honouring an explicit `proxy`) if present,
94
+ * otherwise the global fetch. Shared by the EventSource connection and the
95
+ * `/check/cors` probe so both resolve identically.
96
+ *
97
+ * @internal
98
+ */
99
+ export function pickBaseFetch(config: InitializedClientConfig): FetchFunction {
100
+ if (config.resolveFetch) {
101
+ return config.resolveFetch(typeof config.proxy === 'string' ? config.proxy : undefined)
102
+ }
103
+ return globalThis.fetch.bind(globalThis)
104
+ }