Skip to content
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

feat(useEventSource): add autoReconnect and immediate to options, update typings #3793

Merged
merged 2 commits into from
Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
52 changes: 43 additions & 9 deletions packages/core/useEventSource/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,46 @@ import { useEventSource } from '@vueuse/core'
const { status, data, error, close } = useEventSource('https://event-source-url')
```

| State | Type | Description |
| ----------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| status | `Ref<string>` | A read-only value representing the state of the connection. Possible values are CONNECTING (0), OPEN (1), or CLOSED (2) |
| data | `Ref<string \| null>` | Reference to the latest data received via the EventSource, can be watched to respond to incoming messages |
| eventSource | `Ref<EventSource \| null>` | Reference to the current EventSource instance |

| Method | Signature | Description |
| ------ | ------------ | --------------------------------------------- |
| close | `() => void` | Closes the EventSource connection gracefully. |
See the [Type Declarations](#type-declarations) for more options.

### Named Events

You can define named events with the second parameter

```js
import { useEventSource } from '@vueuse/core'

const { event, data } = useEventSource('https://event-source-url', ['notice', 'update'] as const)
```

### Immediate

Auto-connect (enabled by default).

This will call `open()` automatically for you and you don't need to call it by yourself.

If url is provided as a ref, this also controls whether a connection is re-established when its value is changed (or whether you need to call open() again for the change to take effect).

### Auto-reconnection

Reconnect on errors automatically (disabled by default).

```js
const { status, data, close } = useEventSource('https://event-source-url', [], {
autoReconnect: true,
})
```

Or with more controls over its behavior:

```js
const { status, data, close } = useEventSource('https://event-source-url', [], {
autoReconnect: {
retries: 3,
delay: 1000,
onFailed() {
alert('Failed to connect EventSource after 3 retries')
},
},
})
```
189 changes: 159 additions & 30 deletions packages/core/useEventSource/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,93 @@
import type { Ref } from 'vue-demi'
import { ref, shallowRef } from 'vue-demi'
import { tryOnScopeDispose } from '@vueuse/shared'
import type { MaybeRefOrGetter, Ref } from 'vue-demi'
import { ref, shallowRef, watch } from 'vue-demi'
import { isClient, toRef, tryOnScopeDispose } from '@vueuse/shared'
import type { Fn } from '@vueuse/shared'
import { useEventListener } from '../useEventListener'

export type UseEventSourceOptions = EventSourceInit
export type EventSourceStatus = 'CONNECTING' | 'OPEN' | 'CLOSED'

export interface UseEventSourceOptions extends EventSourceInit {
/**
* Enabled auto reconnect
*
* @default false
*/
autoReconnect?: boolean | {
/**
* Maximum retry times.
*
* Or you can pass a predicate function (which returns true if you want to retry).
*
* @default -1
*/
retries?: number | (() => boolean)

/**
* Delay for reconnect, in milliseconds
*
* @default 1000
*/
delay?: number

/**
* On maximum retry times reached.
*/
onFailed?: Fn
}

/**
* Automatically open a connection
*
* @default true
*/
immediate?: boolean
}

export interface UseEventSourceReturn<Events extends string[]> {
/**
* Reference to the latest data received via the EventSource,
* can be watched to respond to incoming messages
*/
data: Ref<string | null>

/**
* The current state of the connection, can be only one of:
* 'CONNECTING', 'OPEN' 'CLOSED'
*/
status: Ref<EventSourceStatus>

/**
* The latest named event
*/
event: Ref<Events[number] | null>

/**
* The current error
*/
error: Ref<Event | null>

/**
* Closes the EventSource connection gracefully.
*/
close: EventSource['close']

/**
* Reopen the EventSource connection.
* If there the current one is active, will close it before opening a new one.
*/
open: Fn

/**
* Reference to the current EventSource instance.
*/
eventSource: Ref<EventSource | null>
}

function resolveNestedOptions<T>(options: T | true): T {
if (options === true)
return {} as T
return options
}

/**
* Reactive wrapper for EventSource.
Expand All @@ -14,63 +98,108 @@ export type UseEventSourceOptions = EventSourceInit
* @param events
* @param options
*/
export function useEventSource(url: string | URL, events: Array<string> = [], options: UseEventSourceOptions = {}) {
export function useEventSource<Events extends string[]>(
url: MaybeRefOrGetter<string | URL | undefined>,
events: Events = [] as unknown as Events,
options: UseEventSourceOptions = {},
): UseEventSourceReturn<Events> {
const event: Ref<string | null> = ref(null)
const data: Ref<string | null> = ref(null)
const status = ref('CONNECTING') as Ref<'OPEN' | 'CONNECTING' | 'CLOSED'>
const status = ref('CONNECTING') as Ref<EventSourceStatus>
const eventSource = ref(null) as Ref<EventSource | null>
const error = shallowRef(null) as Ref<Event | null>
const urlRef = toRef(url)

let explicitlyClosed = false
let retried = 0

const {
withCredentials = false,
immediate = true,
} = options

const close = () => {
if (eventSource.value) {
if (isClient && eventSource.value) {
eventSource.value.close()
eventSource.value = null
status.value = 'CLOSED'
explicitlyClosed = true
}
}

const es = new EventSource(url, { withCredentials })
const _init = () => {
if (explicitlyClosed || typeof urlRef.value === 'undefined')
return

eventSource.value = es
const es = new EventSource(urlRef.value, { withCredentials })

es.onopen = () => {
status.value = 'OPEN'
error.value = null
}
status.value = 'CONNECTING'

es.onerror = (e) => {
status.value = 'CLOSED'
error.value = e
}
eventSource.value = es

es.onmessage = (e: MessageEvent) => {
event.value = null
data.value = e.data
}
es.onopen = () => {
status.value = 'OPEN'
error.value = null
}

es.onerror = (e) => {
status.value = 'CLOSED'
error.value = e

// only reconnect if EventSource isn't reconnecting by itself
// this is the case when the connection is closed (readyState is 2)
if (es.readyState === 2 && !explicitlyClosed && options.autoReconnect) {
es.close()
const {
retries = -1,
delay = 1000,
onFailed,
} = resolveNestedOptions(options.autoReconnect)
retried += 1

if (typeof retries === 'number' && (retries < 0 || retried < retries))
setTimeout(_init, delay)
else if (typeof retries === 'function' && retries())
setTimeout(_init, delay)
else
onFailed?.()
}
}

es.onmessage = (e: MessageEvent) => {
event.value = null
data.value = e.data
}

for (const event_name of events) {
useEventListener(es, event_name, (e: Event & { data?: string }) => {
event.value = event_name
data.value = e.data || null
})
for (const event_name of events) {
useEventListener(es, event_name, (e: Event & { data?: string }) => {
event.value = event_name
data.value = e.data || null
})
}
}

tryOnScopeDispose(() => {
const open = () => {
if (!isClient)
return
close()
})
explicitlyClosed = false
retried = 0
_init()
}

if (immediate)
watch(urlRef, open, { immediate: true })

tryOnScopeDispose(close)

return {
eventSource,
event,
data,
status,
error,
open,
close,
}
}

export type UseEventSourceReturn = ReturnType<typeof useEventSource>