Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const app = createApp(App)

app.use(GirderPlugin, {
girder: {apiRoot: import.meta.env.VITE_API_ROOT},
notification: {useEventSource: true},
notifications: {useEventSource: true}, // Girder 3; use useWebSocket: true for Girder 5
components: true // register all components
})
app.mount('#app')
Expand Down Expand Up @@ -87,6 +87,46 @@ See [the demo app](demo/App.vue) for a more comprehensive example.

## Advanced Usage

### Real-time notifications (Girder 3 vs Girder 5)

Girder web components can receive server push notifications through the
[`NotificationBus`](./src/utils/notificationBus.js). The transport depends on
which Girder version your API server runs:

| Girder version | Transport | Plugin option |
| --- | --- | --- |
| Girder 3 | Server-Sent Events (`/notification/stream`) | `notifications: { useEventSource: true }` |
| Girder 5 | WebSocket (`/notifications/me?token=…`) | `notifications: { useWebSocket: true }` |

**Girder 3 (EventSource)** uses long-polling over SSE. If the stream fails, the
bus falls back to HTTP polling automatically.

**Girder 5 (WebSocket)** connects to the ASGI notification channel documented
in the [Girder 5 migration guide](https://girder.readthedocs.io/en/stable/migration-guide.html#overhaul-of-notifications-system-switch-from-wsgi-to-asgi).
The user must be authenticated (a Girder token is required). On unexpected
disconnects the bus retries with exponential backoff controlled by
`reconnectInterval` and `maxReconnectAttempts`.

```javascript
/* Girder 3 */
app.use(GirderPlugin, {
girder: { apiRoot: import.meta.env.VITE_API_ROOT },
notifications: { useEventSource: true },
})

/* Girder 5 */
app.use(GirderPlugin, {
girder: { apiRoot: import.meta.env.VITE_API_ROOT },
notifications: { useWebSocket: true },
})
```

When using WebSockets, ensure your reverse proxy forwards the `Upgrade` header
(see [Girder deployment docs](https://girder.readthedocs.io/en/stable/deployment.html)).

If neither `useEventSource` nor `useWebSocket` is set, the bus uses standard
HTTP polling against `/notification`.

### Customizing Vuetify Configuration
Custom additional vuetify configuration can be passed to the plugin through the `vuetifyConfig` option.
If your downstream application is also using Vuetify and needs to pass additional configuration
Expand All @@ -103,7 +143,7 @@ const app = createApp(App)

app.use(GirderPlugin, {
girder: {apiRoot: import.meta.env.VITE_API_ROOT},
notification: {useEventSource: true},
notifications: {useEventSource: true},
vuetifyConfig: {icons: {aliases: {login: 'mdi-circle' }}}
})
app.mount('#app')
Expand Down
3 changes: 2 additions & 1 deletion demo/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ const app = createApp(App)

app.use(GirderPlugin, {
girder: {apiRoot: import.meta.env.VITE_API_ROOT},
notification: {useEventSource: true},
// useEventSource for Girder 3; useWebSocket for Girder 5
notifications: {useEventSource: true},
components: true,
})
app.mount('#app')
4 changes: 4 additions & 0 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,15 @@ export class NotificationBus {
gr: RestClient,
args?: {
EventSource?: EventSource
WebSocket?: WebSocket
listenToRestClient?: boolean
pollingInterval?: number[]
since?: Date
useEventSource?: boolean
useWebSocket?: boolean
withCredentials?: boolean
reconnectInterval?: number
maxReconnectAttempts?: number
}
)

Expand Down
4 changes: 3 additions & 1 deletion src/plugins/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import vuetify from './vuetify';
/**
* @typedef {Object} GwcPluginOptions
* @property {Object} [girder] - Configuration for the Girder Rest Client
* @property {Object} [notifications] - Configuration for the Girder Notification Bus
* @property {Object} [notifications] - Configuration for the Girder Notification Bus.
* Set `useEventSource: true` for Girder 3 Server-Sent Events, or `useWebSocket: true`
* for Girder 5 WebSocket notifications.
* @property {Boolean} [components] - Register all components
* @property {Object} [vuetifyConfig] - Custom vuetify config
*/
Expand Down
108 changes: 101 additions & 7 deletions src/utils/notificationBus.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,46 @@
import mitt from 'mitt';

function getWebSocketBaseUrl(apiRoot) {
if (/^https?:\/\//.test(apiRoot)) {
const url = new URL(apiRoot);
const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
const pathname = url.pathname.replace(/\/api\/v1\/?$/, '');
return `${wsProtocol}//${url.host}${pathname.replace(/\/$/, '')}`;
}

const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsPath = apiRoot.replace(/\/api\/v1\/?$/, '') || '';
return `${wsProtocol}//${window.location.host}${wsPath.replace(/\/$/, '')}`;
}

export default class NotificationBus {
constructor($rest, {
EventSource = window.EventSource,
WebSocket = window.WebSocket,
listenToRestClient = true,
pollingInterval = [500, 5000, 1000],
since = new Date(),
useEventSource = false,
useWebSocket = false,
withCredentials = true,
reconnectInterval = 5000,
maxReconnectAttempts = Infinity,
} = {}) {
this.emitter = mitt(); // Event bus replacement for $emit/$on
this.emitter = mitt();
this.$rest = $rest;
this.EventSource = EventSource;
this.WebSocket = WebSocket;
this.pollingInterval = pollingInterval;
this.since = since;
this.useEventSource = useEventSource;
this.useWebSocket = useWebSocket;
this.withCredentials = withCredentials;
this.reconnectInterval = reconnectInterval;
this.maxReconnectAttempts = maxReconnectAttempts;
this._eventSource = null;
this._poller = null;
this._websocket = null;
this._reconnectAttempts = 0;

if (listenToRestClient) {
$rest.on?.('userLoggedIn', () => { this.connect(); });
Expand Down Expand Up @@ -49,6 +72,16 @@ export default class NotificationBus {
this.emit('message', notification);
}

_getWebSocketUrl() {
const token = this.$rest.token;
if (!token) {
throw new Error('No authentication token available');
}

const baseUrl = getWebSocketBaseUrl(this.$rest.apiRoot);
return `${baseUrl}/notifications/me?token=${encodeURIComponent(token)}`;
}

_onSseMessage({ data }) {
this._emitNotification(JSON.parse(data));
}
Expand All @@ -60,14 +93,49 @@ export default class NotificationBus {
this.connect();
}

_onWebSocketMessage(event) {
try {
const notification = JSON.parse(event.data);
this._emitNotification(notification);
this._reconnectAttempts = 0;
} catch (e) {
this.emit('error', new Error(`Failed to parse notification: ${e.message}`));
}
}

_onWebSocketError(e) {
this.emit('error', e);
}

_onWebSocketClose(event) {
this._websocket = null;
this.emit('stop', this);

if (event.code !== 1000 && this._reconnectAttempts < this.maxReconnectAttempts) {
this._reconnectAttempts += 1;
setTimeout(() => {
if (this.$rest.token) {
this.connect();
}
}, this.reconnectInterval);
} else if (this._reconnectAttempts >= this.maxReconnectAttempts) {
this.emit('error', new Error('Maximum reconnection attempts reached'));
}
}

get connected() {
if (this._websocket) {
return this._websocket.readyState === this.WebSocket.OPEN;
}
return !!(this._eventSource || this._poller);
}

connect() {
if (this.connected) {return;}
if (this.connected) { return; }

if (this.useEventSource && this.EventSource) {
if (this.useWebSocket) {
this._connectWebSocket();
} else if (this.useEventSource && this.EventSource) {
const since = Math.ceil(+this.since / 1000);
const url = `${this.$rest.apiRoot}/notification/stream?since=${since}`;
this._eventSource = new this.EventSource(url, { withCredentials: this.withCredentials });
Expand All @@ -79,13 +147,39 @@ export default class NotificationBus {
}
}

_connectWebSocket() {
if (!this.$rest.token) {
this.emit('error', new Error('Cannot connect: no authentication token'));
return;
}

try {
const url = this._getWebSocketUrl();
this._websocket = new this.WebSocket(url);
this._websocket.onmessage = this._onWebSocketMessage.bind(this);
this._websocket.onerror = this._onWebSocketError.bind(this);
this._websocket.onclose = this._onWebSocketClose.bind(this);
this._websocket.onopen = () => {
this._reconnectAttempts = 0;
this.emit('start', this);
};
} catch (e) {
this.emit('error', e);
}
}

disconnect() {
this._stopPolling();
if (this._eventSource) {
this._eventSource.close();
this._eventSource = null;
this.emit('stop', this);
}
if (this._websocket) {
this._websocket.close(1000);
this._websocket = null;
this._reconnectAttempts = 0;
}
}

_poll(interval = 0) {
Expand All @@ -96,9 +190,9 @@ export default class NotificationBus {
try {
const { data } = await this.$rest.get(`/notification?since=${this.since.toISOString()}`);
data.forEach(this._emitNotification.bind(this));
if (data.length) {nextInterval = min;}
else if (interval === 0) {nextInterval = max;}
else {nextInterval = Math.min(interval + step, max);}
if (data.length) { nextInterval = min; }
else if (interval === 0) { nextInterval = max; }
else { nextInterval = Math.min(interval + step, max); }
} catch (_err) {
nextInterval = max;
} finally {
Expand All @@ -111,4 +205,4 @@ export default class NotificationBus {
clearTimeout(this._poller);
this._poller = null;
}
}
}
Loading