-
Notifications
You must be signed in to change notification settings - Fork 33
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
refactor: unify sse endpoints handling #950
Conversation
events: EventTypeName[], | ||
) { | ||
return new Observable<EventSourceEvent<EventTypeName>>((observer) => { | ||
const emitOpen = (events as string[]).includes('open') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this now makes it possible to subscribe to open
events as well (but opt-in)
import {defer, shareReplay} from 'rxjs' | ||
import {map} from 'rxjs/operators' | ||
|
||
export const eventSourcePolyfill = defer(() => import('@sanity/eventsource')).pipe( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this will lazy load the polyfill, share it between consumers and cache it forever
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Really cool! And so much more elegant than the old new Promise
song and dance 🤯
0b42fa5
to
f0185c1
Compare
this refactors our current EventSource/SSE handling and unifies it under a common implementation shared between the live content api and the listener api.
f0185c1
to
47ca499
Compare
@@ -74,16 +74,12 @@ describe.skipIf(typeof EdgeRuntime === 'string' || typeof document !== 'undefine | |||
({request, channel}) => { | |||
expect(request.headers.authorization, 'should send token').toBe('Bearer foobar') | |||
|
|||
channel!.send({event: 'disconnect'}) | |||
process.nextTick(() => { | |||
channel!.close() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
closing here will trigger an error/reconnecting state, so not needed since we only take the welcome event from the observable
channel!.close() | ||
process.nextTick(() => channel!.close()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
also not needed since an error thrown on the observable will call the observable cleanup, which will do the closing for us
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
* note: it's usually a better option to use the 'welcome' event | ||
* @public | ||
*/ | ||
export type OpenEvent = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this be part of ListenEvent on L878?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch! Will add it
* | ||
* @internal | ||
*/ | ||
export function connectEventSource<EventName extends string>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
export function connectEventSource<EventName extends string>( | |
const EventTypeName = ListenEvent['type'] | |
export function connectEventSource<EventName extends EventTypeName[]>( |
should make this type safe? 🤔 and same on L123?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is type safe… kind of. Here we can only really know for sure that the event matches any of the event names given, which is why this returns {type: Name, id:? string, data?: unknown}
. I left it up to the endpoints themselves (listen()
and live()
– for now) to take care of typing it correctly, since they have more specific knowledge of the payloads. I also made a choice not to splat data
into the events at this level, because in theory it could contain type
and id
which would lead to very surprising behavior.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Absolute brilliant 💖 I always learn so much about rxjs
when reviewing your PRs, and your improvements are so elegant :chefs-kiss:
src/data/live.ts
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you think about using a shareReplay cache trick like the polyfill here?
The cache key can be: JSON.stringify({url: url.href, esOptions})
and it would allow interesting improvements to Sanity Live. Unlike client.listen()
, the resulting URL doesn't have filters or anything else that lead to many unique URLs. If includeDrafts
or a token
isn't set, then the same URL is used for an entire dataset and apiVersion.
For example in Next.js apps it would let you render client components that might call client.live.events()
multiple times on the same page, and still end up with a single Event Source connection. Free from the burden of implementing this dedupe logic in userland ❤
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not opposed to it, but it's also something we could provide as an operator or small wrapper util. It feels like request deduping is something that belongs more on the application/SDK side though
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it's more like instance deduping. the client is already scoped to the project and dataset, so the SSE resource feels like something that should be scoped as well.
Both the Listen API and the Live API have limits to how concurrent things are (starts at 1k): https://arc.net/l/quote/edfhjttg
Listen has been difficult to use, but even then we've run into issues where you reach too many editors working in a studio.
With the Live API being so easy, this will grow. Both for production use cases, but for plugins it's become my preferred way of building things where I want to write to a document and subscribe to changes in a very lightweight way that lives outside of a form builder state.
If you install 10 plugins and they all use this technique, but we don't dedupe, you'll have easily 10 connections per user.
And it feels odd to add a wrapper like this in useClient
in sanity
, and then in next-sanity
. hydrogen-sanity
will also need it. And probably @sanity/astro
. And what about @nuxtjs/sanity
. 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fair and convincing points. For debuggability it might be worth considering having an option/env var to turn the caching behavior off.
2b4c7c7
to
ee047bb
Compare
This needs a bit of a close look, but tldr; it refactors our current EventSource/SSE handling and unifies it under a common
connectEventsource()
function, now shared between the Live Content API and the Listener API. This function handles Sanity API SSE endpoint specific behaviors like dealing withchannelError
,disconnect
event etc, but should be flexible enough to adapt to potential new future SSE endpoints.The
connectEventsource()
function takes in ainitEventSource
function that allows us to abstract out the actual eventsource instantiation, which tends to vary depending on request options (headers) and environments.Here's an example of how live query api uses it to determine which eventsource implementation to use:
client/src/data/listen.ts
Line 96 in b07b8d6
This will lazy load the polyfill if either 1) no global EventSource is defined or 2) if headers are passed. Otherwise it will use the native EventSource class provided by the runtime.
I've also formalized the error classes and documented what errors can be thrown on the observable and under what circumstances they will be thrown. This should enable downstream consumers to better reason about and recover from the different types of errors.
I feel fairly confident that this patch doesn't introduce any breaking changes (beyond the bugfix mention below). Please tell me if I've overlooked something else.
This also exports the standalone
connectEventsource()
and the different error classes/helper types changes, which enables internal usage (tagged as@internal
for now).Backwards incompatible change with regards to
disconnect
eventsCurrently, if the server sends a
disconnect
event, the listener stream will complete. This is frommain
:client/src/data/listen.ts
Line 133 in 5f0a991
This seems like a bug with a potential for unfortunate effects. Consider the following:
disconnect
event, which causes the observable to complete, and never reconnect.No error is thrown and the application is not likely to pick up on the error and continue as if nothing happened, leaving the document out of sync.
I'd argue that this is a bug, and that the observable here should be kept "open" forever until the subscriber unsubscribes, and that a
disconnect
event should be treated as an error condition. Now, it looks like the only cases where adisconnect
is actually sent are cases where the dataset has been deleted or similar exceptional cases, but I'd argue that throwing an error on the observable in these cases still would be preferable, and doing so is not a breaking change, but should be considered a bugfix with the reasoning that if it happens, we actually want it to raise an error instead of silently fail.