@directus/api
Version:
Directus is a real-time API and App dashboard for managing SQL database content
184 lines (183 loc) • 7.35 kB
JavaScript
import { InvalidPayloadError } from '@directus/errors';
import {} from '@directus/memory';
import { useBus } from '../../bus/index.js';
import emitter from '../../emitter.js';
import { getSchema } from '../../utils/get-schema.js';
import { sanitizeQuery } from '../../utils/sanitize-query.js';
import { WebSocketError, handleWebSocketError } from '../errors.js';
import { WebSocketSubscribeMessage } from '../messages.js';
import { getPayload } from '../utils/items.js';
import { fmtMessage, getMessageType } from '../utils/message.js';
/**
* Handler responsible for subscriptions
*/
export class SubscribeHandler {
// storage of subscriptions per collection
subscriptions;
// internal message bus
messenger;
/**
* Initialize the handler
*/
constructor() {
this.subscriptions = {};
this.messenger = useBus();
this.bindWebSocket();
// listen to the Redis pub/sub and dispatch
this.messenger.subscribe('websocket.event', (message) => {
try {
this.dispatch(message);
}
catch {
// don't error on an invalid event from the messenger
}
});
}
/**
* Hook into websocket client lifecycle events
*/
bindWebSocket() {
// listen to incoming messages on the connected websockets
emitter.onAction('websocket.message', ({ client, message }) => {
if (!['subscribe', 'unsubscribe'].includes(getMessageType(message)))
return;
try {
this.onMessage(client, WebSocketSubscribeMessage.parse(message));
}
catch (error) {
handleWebSocketError(client, error, 'subscribe');
}
});
// unsubscribe when a connection drops
emitter.onAction('websocket.error', ({ client }) => this.unsubscribe(client));
emitter.onAction('websocket.close', ({ client }) => this.unsubscribe(client));
}
/**
* Register a subscription
* @param subscription
*/
subscribe(subscription) {
const { collection } = subscription;
if ('item' in subscription && ['directus_fields', 'directus_relations'].includes(collection)) {
throw new InvalidPayloadError({ reason: `Cannot subscribe to a specific item in the ${collection} collection.` });
}
if (!this.subscriptions[collection]) {
this.subscriptions[collection] = new Set();
}
this.subscriptions[collection]?.add(subscription);
}
/**
* Remove a subscription
* @param subscription
*/
unsubscribe(client, uid) {
if (uid !== undefined) {
const subscription = this.getSubscription(client, String(uid));
if (subscription) {
this.subscriptions[subscription.collection]?.delete(subscription);
}
}
else {
for (const key of Object.keys(this.subscriptions)) {
const subscriptions = Array.from(this.subscriptions[key] || []);
for (let i = subscriptions.length - 1; i >= 0; i--) {
const subscription = subscriptions[i];
if (!subscription)
continue;
if (subscription.client === client && (!uid || subscription.uid === uid)) {
this.subscriptions[key]?.delete(subscription);
}
}
}
}
}
/**
* Dispatch event to subscriptions
*/
async dispatch(event) {
const subscriptions = this.subscriptions[event.collection];
if (!subscriptions || subscriptions.size === 0)
return;
const schema = await getSchema();
for (const subscription of subscriptions) {
const { client } = subscription;
if (subscription.event !== undefined && event.action !== subscription.event) {
continue; // skip filtered events
}
if ('item' in subscription) {
if ('keys' in event && !event.keys.includes(subscription.item))
continue;
if ('key' in event && event.key !== subscription.item)
continue;
}
try {
const result = await getPayload(subscription, client.accountability, schema, event);
if (Array.isArray(result?.['data']) && result?.['data']?.length === 0)
continue;
client.send(fmtMessage('subscription', result, subscription.uid));
}
catch (err) {
handleWebSocketError(client, err, 'subscribe');
}
}
}
/**
* Handle incoming (un)subscribe requests
*/
async onMessage(client, message) {
if (getMessageType(message) === 'subscribe') {
try {
const collection = String(message.collection);
const accountability = client.accountability;
const schema = await getSchema();
if (!accountability?.admin && !schema.collections[collection]) {
throw new WebSocketError('subscribe', 'INVALID_COLLECTION', 'The provided collection does not exists or is not accessible.', message.uid);
}
const subscription = {
client,
collection,
};
if ('event' in message) {
subscription.event = message.event;
}
if (message.query) {
subscription.query = await sanitizeQuery(message.query, schema, accountability);
}
if ('item' in message)
subscription.item = String(message.item);
if ('uid' in message) {
subscription.uid = String(message.uid);
// remove the subscription if it already exists
this.unsubscribe(client, subscription.uid);
}
const data = subscription.event === undefined ? await getPayload(subscription, accountability, schema) : { event: 'init' };
// if no errors were thrown register the subscription
this.subscribe(subscription);
// send an initial response
client.send(fmtMessage('subscription', data, subscription.uid));
}
catch (err) {
handleWebSocketError(client, err, 'subscribe');
}
}
if (getMessageType(message) === 'unsubscribe') {
try {
this.unsubscribe(client, message.uid);
client.send(fmtMessage('subscription', { event: 'unsubscribe' }, message.uid));
}
catch (err) {
handleWebSocketError(client, err, 'unsubscribe');
}
}
}
getSubscription(client, uid) {
for (const userSubscriptions of Object.values(this.subscriptions)) {
for (const subscription of userSubscriptions) {
if (subscription.client === client && subscription.uid === uid) {
return subscription;
}
}
}
return undefined;
}
}