Skip to content

Commit de0cec7

Browse files
authored
feat: add experimental new live events API (#797)
* feat: CLDX-2062 * revert: fix in `listen`, will open dedicated PR
1 parent 53552df commit de0cec7

File tree

10 files changed

+430
-4
lines changed

10 files changed

+430
-4
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@sanity/client",
3-
"version": "6.17.3",
3+
"version": "6.18.0-canary.0",
44
"description": "Client for retrieving, creating and patching data from Sanity.io",
55
"keywords": [
66
"sanity",

src/SanityClient.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {AssetsClient, ObservableAssetsClient} from './assets/AssetsClient'
44
import {defaultConfig, initConfig} from './config'
55
import * as dataMethods from './data/dataMethods'
66
import {_listen} from './data/listen'
7+
import {LiveClient} from './data/live'
78
import {ObservablePatch, Patch} from './data/patch'
89
import {ObservableTransaction, Transaction} from './data/transaction'
910
import {DatasetsClient, ObservableDatasetsClient} from './datasets/DatasetsClient'
@@ -43,6 +44,7 @@ export type {
4344
_listen,
4445
AssetsClient,
4546
DatasetsClient,
47+
LiveClient,
4648
ObservableAssetsClient,
4749
ObservableDatasetsClient,
4850
ObservableProjectsClient,
@@ -55,6 +57,7 @@ export type {
5557
export class ObservableSanityClient {
5658
assets: ObservableAssetsClient
5759
datasets: ObservableDatasetsClient
60+
live: LiveClient
5861
projects: ObservableProjectsClient
5962
users: ObservableUsersClient
6063

@@ -76,6 +79,7 @@ export class ObservableSanityClient {
7679

7780
this.assets = new ObservableAssetsClient(this, this.#httpRequest)
7881
this.datasets = new ObservableDatasetsClient(this, this.#httpRequest)
82+
this.live = new LiveClient(this)
7983
this.projects = new ObservableProjectsClient(this, this.#httpRequest)
8084
this.users = new ObservableUsersClient(this, this.#httpRequest)
8185
}
@@ -695,6 +699,7 @@ export class ObservableSanityClient {
695699
export class SanityClient {
696700
assets: AssetsClient
697701
datasets: DatasetsClient
702+
live: LiveClient
698703
projects: ProjectsClient
699704
users: UsersClient
700705

@@ -721,6 +726,7 @@ export class SanityClient {
721726

722727
this.assets = new AssetsClient(this, this.#httpRequest)
723728
this.datasets = new DatasetsClient(this, this.#httpRequest)
729+
this.live = new LiveClient(this)
724730
this.projects = new ProjectsClient(this, this.#httpRequest)
725731
this.users = new UsersClient(this, this.#httpRequest)
726732

src/data/dataMethods.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ export function _dataRequest(
257257
const useGet = !isMutation && strQuery.length < getQuerySizeLimit
258258
const stringQuery = useGet ? strQuery : ''
259259
const returnFirst = options.returnFirst
260-
const {timeout, token, tag, headers, returnQuery} = options
260+
const {timeout, token, tag, headers, returnQuery, lastLiveEventId} = options
261261

262262
const uri = _getDataUrl(client, endpoint, stringQuery)
263263

@@ -274,6 +274,7 @@ export function _dataRequest(
274274
returnQuery,
275275
perspective: options.perspective,
276276
resultSourceMap: options.resultSourceMap,
277+
lastLiveEventId: Array.isArray(lastLiveEventId) ? lastLiveEventId[0] : lastLiveEventId,
277278
canUseCdn: isQuery,
278279
signal: options.signal,
279280
fetch: options.fetch,
@@ -375,6 +376,10 @@ export function _requestObservable<R>(
375376
}
376377
}
377378

379+
if (options.lastLiveEventId) {
380+
options.query = {...options.query, lastLiveEventId: options.lastLiveEventId}
381+
}
382+
378383
if (options.returnQuery === false) {
379384
options.query = {returnQuery: 'false', ...options.query}
380385
}

src/data/live.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import {Observable} from 'rxjs'
2+
3+
import type {ObservableSanityClient, SanityClient} from '../SanityClient'
4+
import type {Any, LiveEventMessage, LiveEventRestart} from '../types'
5+
import {_getDataUrl} from './dataMethods'
6+
7+
/**
8+
* @alpha this API is experimental and may change or even be removed
9+
*/
10+
export class LiveClient {
11+
#client: SanityClient | ObservableSanityClient
12+
constructor(client: SanityClient | ObservableSanityClient) {
13+
this.#client = client
14+
}
15+
16+
events(): Observable<LiveEventMessage | LiveEventRestart> {
17+
const path = _getDataUrl(this.#client, 'live/events')
18+
const url = new URL(this.#client.getUrl(path, false))
19+
20+
const listenFor = ['restart', 'message'] as const
21+
22+
return new Observable((observer) => {
23+
let es: InstanceType<typeof EventSource> | undefined
24+
let reconnectTimer: NodeJS.Timeout
25+
let stopped = false
26+
// Unsubscribe differs from stopped in that we will never reopen.
27+
// Once it is`true`, it will never be `false` again.
28+
let unsubscribed = false
29+
30+
open()
31+
32+
// EventSource will emit a regular event if it fails to connect, however the API will emit an `error` MessageEvent if the server goes down
33+
// So we need to handle both cases
34+
function onError(evt: MessageEvent | Event) {
35+
if (stopped) {
36+
return
37+
}
38+
39+
// 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
40+
if ('data' in evt) {
41+
const event = parseEvent(evt)
42+
observer.error(new Error(event.message, {cause: event}))
43+
}
44+
45+
// Unless we've explicitly stopped the ES (in which case `stopped` should be true),
46+
// we should never be in a disconnected state. By default, EventSource will reconnect
47+
// automatically, in which case it sets readyState to `CONNECTING`, but in some cases
48+
// (like when a laptop lid is closed), it closes the connection. In these cases we need
49+
// to explicitly reconnect.
50+
if (es!.readyState === es!.CLOSED) {
51+
unsubscribe()
52+
clearTimeout(reconnectTimer)
53+
reconnectTimer = setTimeout(open, 100)
54+
}
55+
}
56+
57+
function onMessage(evt: Any) {
58+
const event = parseEvent(evt)
59+
return event instanceof Error ? observer.error(event) : observer.next(event)
60+
}
61+
62+
function unsubscribe() {
63+
if (!es) return
64+
es.removeEventListener('error', onError)
65+
for (const type of listenFor) {
66+
es.removeEventListener(type, onMessage)
67+
}
68+
es.close()
69+
}
70+
71+
async function getEventSource() {
72+
const EventSourceImplementation: typeof EventSource =
73+
typeof EventSource === 'undefined'
74+
? ((await import('@sanity/eventsource')).default as typeof EventSource)
75+
: EventSource
76+
77+
// If the listener has been unsubscribed from before we managed to load the module,
78+
// do not set up the EventSource.
79+
if (unsubscribed) {
80+
return
81+
}
82+
83+
const evs = new EventSourceImplementation(url.toString())
84+
evs.addEventListener('error', onError)
85+
for (const type of listenFor) {
86+
evs.addEventListener(type, onMessage)
87+
}
88+
return evs
89+
}
90+
91+
function open() {
92+
getEventSource()
93+
.then((eventSource) => {
94+
if (eventSource) {
95+
es = eventSource
96+
// Handle race condition where the observer is unsubscribed before the EventSource is set up
97+
if (unsubscribed) {
98+
unsubscribe()
99+
}
100+
}
101+
})
102+
.catch((reason) => {
103+
observer.error(reason)
104+
stop()
105+
})
106+
}
107+
108+
function stop() {
109+
stopped = true
110+
unsubscribe()
111+
unsubscribed = true
112+
}
113+
114+
return stop
115+
})
116+
}
117+
}
118+
119+
function parseEvent(event: MessageEvent) {
120+
try {
121+
const data = (event.data && JSON.parse(event.data)) || {}
122+
return {type: event.type, id: event.lastEventId, ...data}
123+
} catch (err) {
124+
return err
125+
}
126+
}

src/types.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,8 @@ export interface RequestObservableOptions extends Omit<RequestOptions, 'url'> {
306306
returnQuery?: boolean
307307
resultSourceMap?: boolean | 'withKeyArraySelector'
308308
perspective?: ClientPerspective
309+
/** @alpha this API is experimental and may change or even be removed */
310+
lastLiveEventId?: string
309311
}
310312

311313
/** @public */
@@ -479,6 +481,8 @@ export interface QueryParams {
479481
token?: never
480482
/** @deprecated you're using a fetch option as a GROQ parameter, this is likely a mistake */
481483
useCdn?: never
484+
/** @deprecated you're using a fetch option as a GROQ parameter, this is likely a mistake */
485+
lastLiveEventId?: never
482486
/* eslint-enable @typescript-eslint/no-explicit-any */
483487
}
484488

@@ -743,6 +747,8 @@ export interface ResponseQueryOptions extends RequestOptions {
743747
// The `cache` and `next` options are specific to the Next.js App Router integration
744748
cache?: 'next' extends keyof RequestInit ? RequestInit['cache'] : never
745749
next?: ('next' extends keyof RequestInit ? RequestInit : never)['next']
750+
/** @alpha this API is experimental and may change or even be removed */
751+
lastLiveEventId?: string | string[] | null
746752
}
747753

748754
/** @public */
@@ -785,6 +791,8 @@ export interface RawQueryResponse<R> {
785791
ms: number
786792
result: R
787793
resultSourceMap?: ContentSourceMap
794+
/** @alpha this API is experimental and may change or even be removed */
795+
syncTags?: SyncTag[]
788796
}
789797

790798
/** @public */
@@ -999,6 +1007,19 @@ export interface ContentSourceMap {
9991007
paths: ContentSourceMapPaths
10001008
}
10011009

1010+
/** @alpha this API is experimental and may change or even be removed */
1011+
export type SyncTag = `s1:${string}`
1012+
/** @alpha this API is experimental and may change or even be removed */
1013+
export interface LiveEventRestart {
1014+
type: 'restart'
1015+
}
1016+
/** @alpha this API is experimental and may change or even be removed */
1017+
export interface LiveEventMessage {
1018+
type: 'message'
1019+
id: string
1020+
tags: SyncTag[]
1021+
}
1022+
10021023
export type {
10031024
ContentSourceMapParsedPath,
10041025
ContentSourceMapParsedPathKeyedSegment,

test/client.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,90 @@ describe('client', async () => {
569569
expect(res[0].rating, 'data should match').toBe(5)
570570
})
571571

572+
test.skipIf(isEdge)('can query for documents with last live event ID', async () => {
573+
nock(projectHost())
574+
.get(
575+
`/vX/data/query/foo?query=*&returnQuery=false&lastLiveEventId=MTA0MDM1Nnx2a2lQY200bnRHQQ`,
576+
)
577+
.reply(200, {
578+
ms: 123,
579+
result,
580+
})
581+
582+
const res = await getClient({apiVersion: 'X'}).fetch(
583+
'*',
584+
{},
585+
{lastLiveEventId: 'MTA0MDM1Nnx2a2lQY200bnRHQQ'},
586+
)
587+
expect(res.length, 'length should match').toBe(1)
588+
expect(res[0].rating, 'data should match').toBe(5)
589+
})
590+
591+
test.skipIf(isEdge)(
592+
'allows passing last live event ID from Next.js style searchParams',
593+
async () => {
594+
nock(projectHost())
595+
.get(
596+
`/vX/data/query/foo?query=*&returnQuery=false&lastLiveEventId=MTA0MDM1Nnx2a2lQY200bnRHQQ`,
597+
)
598+
.reply(200, {
599+
ms: 123,
600+
result,
601+
})
602+
603+
const res = await getClient({apiVersion: 'X'}).fetch(
604+
'*',
605+
{},
606+
// searchParams in Next.js will return an arry of strings in some cases,
607+
// as an convenience we allow it, and behave the same way as URLSearchParams.get() when that happens:
608+
// we pick the first value in the array
609+
{lastLiveEventId: ['MTA0MDM1Nnx2a2lQY200bnRHQQ', 'some-other-value']},
610+
)
611+
expect(res.length, 'length should match').toBe(1)
612+
expect(res[0].rating, 'data should match').toBe(5)
613+
},
614+
)
615+
616+
test.skipIf(isEdge)(
617+
'allows passing last live event ID from URLSearchParams that might be null',
618+
async () => {
619+
nock(projectHost()).get(`/vX/data/query/foo?query=*&returnQuery=false`).reply(200, {
620+
ms: 123,
621+
result,
622+
})
623+
const searchParams = new URLSearchParams('')
624+
625+
const res = await getClient({apiVersion: 'X'}).fetch(
626+
'*',
627+
{},
628+
// URLSearchParams.get() will return null if the key is not found, we should handle that
629+
{lastLiveEventId: searchParams.get('lastLiveEventId')},
630+
)
631+
expect(res.length, 'length should match').toBe(1)
632+
expect(res[0].rating, 'data should match').toBe(5)
633+
},
634+
)
635+
636+
test.skipIf(isEdge)(
637+
'allows passing last live event ID from URLSearchParams that might be an empty string',
638+
async () => {
639+
nock(projectHost()).get(`/vX/data/query/foo?query=*&returnQuery=false`).reply(200, {
640+
ms: 123,
641+
result,
642+
})
643+
const searchParams = new URLSearchParams('lastLiveEventId=')
644+
645+
const res = await getClient({apiVersion: 'X'}).fetch(
646+
'*',
647+
{},
648+
// URLSearchParams.get() will return null if the key is not found, we should handle that
649+
{lastLiveEventId: searchParams.get('lastLiveEventId')},
650+
)
651+
expect(res.length, 'length should match').toBe(1)
652+
expect(res[0].rating, 'data should match').toBe(5)
653+
},
654+
)
655+
572656
test.skipIf(isEdge)(
573657
'can query for documents with resultSourceMap and perspective',
574658
async () => {

test/helpers/sseServer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const createSseServer = (onRequest: OnRequest): Promise<http.Server> =>
1414
let channel
1515
if (
1616
request?.url?.indexOf('/v1/data/listen/') === 0 ||
17+
request?.url?.indexOf('/vX/data/live/events/') === 0 ||
1718
request?.url?.indexOf('/listen/beerns?query=') === 0
1819
) {
1920
channel = new SseChannel({jsonEncode: true})

0 commit comments

Comments
 (0)