diff --git a/src/declarations.ts b/src/declarations.ts index 9c138be7..16e620ed 100644 --- a/src/declarations.ts +++ b/src/declarations.ts @@ -19,6 +19,7 @@ import { PublicItemTaskManager, S3FileConfiguration, } from './services'; +import { WebsocketService } from './services/websockets/index'; declare module 'fastify' { interface FastifyRequest { @@ -135,5 +136,10 @@ declare module 'fastify' { h5p?: { taskManager: H5PTaskManager; }; + + /** + * Websockets service + */ + websockets?: WebsocketService; } } diff --git a/src/services/index.ts b/src/services/index.ts index b02d7c28..528aeae8 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,14 +1,15 @@ export * from './app'; +export * from './categories'; +export * from './chat'; export * from './database'; +export * from './etherpad'; export * from './file'; export * from './flag'; export * from './h5p'; -export * from './etherpad'; +export * from './invitation'; export * from './item-memberships'; export * from './item-tags'; export * from './items'; -export * from './invitation'; export * from './members'; export * from './public'; -export * from './chat'; -export * from './categories'; +export * from './websockets'; diff --git a/src/services/websockets/api/client.ts b/src/services/websockets/api/client.ts new file mode 100644 index 00000000..68233a01 --- /dev/null +++ b/src/services/websockets/api/client.ts @@ -0,0 +1,65 @@ +/** + * Client message types for the graasp websockets network API + * {@link https://github.com/graasp/graasp-plugin-websockets/blob/main/API.md} + */ +import { NotifMessage } from './message'; + +/** + * Client actions: describe what the client requests from the server + */ +export enum ClientActions { + /** Subscribe to a channel */ + Subscribe = 'subscribe', + /** Unsubscribe from a channel */ + Unsubscribe = 'unsubscribe', + /** Subscribe only to a channel and unsubscribe from all others */ + SubscribeOnly = 'subscribeOnly', + /** Disconnect from the websocket system */ + Disconnect = 'disconnect', +} + +export type ClientAction = ClientActions | `${ClientActions}`; + +/** + * Message sent by client to disconnect + */ +export interface ClientDisconnect extends NotifMessage { + action: `${ClientActions.Disconnect}`; +} + +/** + * Message sent by client to subscribe to some channel + */ +export interface ClientSubscribe extends NotifMessage { + action: `${ClientActions.Subscribe}`; + topic: string; + channel: string; +} + +/** + * Message sent by client to unsubscribe from some channel + */ +export interface ClientUnsubscribe extends NotifMessage { + action: `${ClientActions.Unsubscribe}`; + topic: string; + channel: string; +} + +/** + * Message sent by client to subscribe to a single channel + * (i.e. it also unsubscribes it from any other channel) + */ +export interface ClientSubscribeOnly extends NotifMessage { + action: `${ClientActions.SubscribeOnly}`; + topic: string; + channel: string; +} + +/** + * Client message type is union type of all client message subtypes + */ +export type ClientMessage = + | ClientDisconnect + | ClientSubscribe + | ClientUnsubscribe + | ClientSubscribeOnly; diff --git a/src/services/websockets/api/index.ts b/src/services/websockets/api/index.ts new file mode 100644 index 00000000..8dd0a7bd --- /dev/null +++ b/src/services/websockets/api/index.ts @@ -0,0 +1,3 @@ +export * from './client'; +export * from './message'; +export * from './server'; diff --git a/src/services/websockets/api/message.ts b/src/services/websockets/api/message.ts new file mode 100644 index 00000000..398aae03 --- /dev/null +++ b/src/services/websockets/api/message.ts @@ -0,0 +1,28 @@ +/** + * Message types for the graasp websockets network API + * {@link https://github.com/graasp/graasp-plugin-websockets/blob/main/API.md} + */ + +/** + * Message realms: describe the universe in which the message belongs to + */ +export enum Realms { + /** Notifications realm for real-time updates in Graasp */ + Notif = 'notif', +} + +export type Realm = Realms | `${Realms}`; + +/** + * Base message shape + */ +export interface Message { + realm: Realm; +} + +/** + * Base message for real-time updates and notifications in Graasp + */ +export interface NotifMessage extends Message { + realm: `${Realms.Notif}`; +} diff --git a/src/services/websockets/api/server.ts b/src/services/websockets/api/server.ts new file mode 100644 index 00000000..6f36aa2c --- /dev/null +++ b/src/services/websockets/api/server.ts @@ -0,0 +1,76 @@ +/** + * Server message types for the graasp websockets network API + * {@link https://github.com/graasp/graasp-plugin-websockets/blob/main/API.md} + */ +import { WebsocketError } from '../errors'; +import { ClientMessage } from './client'; +import { NotifMessage } from './message'; + +/** + * Server message types: describe what the server can send to the clients + */ +export enum ServerMessageTypes { + /** Response to a previous client request */ + Response = 'response', + /** Update with new data on a given channel */ + Update = 'update', + /** Global broadcasts unrelated to any specific channel */ + Info = 'info', +} + +export type ServerMessageType = ServerMessageTypes | `${ServerMessageTypes}`; + +/** + * Server response status + */ +export enum ResponseStatuses { + Success = 'success', + Error = 'error', +} + +export type ResponseStatus = ResponseStatuses | `${ResponseStatuses}`; + +/** + * Message sent by server as a response to a {@link ClientMessage} on success + */ +export interface SuccessServerResponse extends NotifMessage { + type: `${ServerMessageTypes.Response}`; + status: `${ResponseStatuses.Success}`; + request: ClientMessage; +} + +/** + * Error message sent by server as a response to a {@link ClientMessage} + */ +export interface ErrorServerResponse extends NotifMessage { + type: `${ServerMessageTypes.Response}`; + status: `${ResponseStatuses.Error}`; + request?: ClientMessage; + error: WebsocketError; +} + +export type ServerResponse = ErrorServerResponse | SuccessServerResponse; + +/** + * Message sent by server for misc broadcasts unrelated to a channel + */ +export interface ServerInfo extends NotifMessage { + type: `${ServerMessageTypes.Info}`; + message: string; + extra?: T; +} + +/** + * Message sent by server for update notifications sent over a channel + */ +export interface ServerUpdate extends NotifMessage { + type: `${ServerMessageTypes.Update}`; + topic: string; + channel: string; + body: T; +} + +/** + * Server message type is union type of all server message subtypes + */ +export type ServerMessage = ServerResponse | ServerInfo | ServerUpdate; diff --git a/src/services/websockets/errors.ts b/src/services/websockets/errors.ts new file mode 100644 index 00000000..d5fcb6a2 --- /dev/null +++ b/src/services/websockets/errors.ts @@ -0,0 +1,47 @@ +/** + * Top-level error class for websockets + * (useful e.g. to check instanceof) + */ +export abstract class WebsocketError extends Error {} + +/** + * Available websocket error names + * + * @example + * ``` + * if (err.name === Websocket.ErrorNames.AccessDenied) { + * // handle access denied + * } + * ``` + */ +export enum ErrorNames { + AccessDenied = 'ACCESS_DENIED', + BadRequest = 'BAD_REQUEST', + NotFound = 'NOT_FOUND', + ServerError = 'SERVER_ERROR', +} + +export type ErrorName = ErrorNames | `${ErrorNames}`; + +export class AccessDeniedError extends WebsocketError { + name: `${ErrorNames.AccessDenied}` = ErrorNames.AccessDenied; + message = 'Websocket: Access denied for the requested resource'; +} + +export class BadRequestError extends WebsocketError { + name: `${ErrorNames.BadRequest}` = ErrorNames.BadRequest; + message = + 'Websocket: Request message format was not understood by the server'; +} + +export class NotFoundError extends WebsocketError { + name: `${ErrorNames.NotFound}` = ErrorNames.NotFound; + message = 'Websocket: Requested resource not found'; +} + +export class ServerError extends WebsocketError { + name: `${ErrorNames.ServerError}` = ErrorNames.ServerError; + constructor(message: string) { + super((message = `Websocket: Internal server error: ${message}`)); + } +} diff --git a/src/services/websockets/index.ts b/src/services/websockets/index.ts new file mode 100644 index 00000000..18645f6d --- /dev/null +++ b/src/services/websockets/index.ts @@ -0,0 +1,45 @@ +import * as API from './api'; +import * as Errors from './errors'; +import * as Requests from './interfaces/request'; + +export * from './interfaces/service'; + +/** + * Re-export types and utility classes within the Websocket namespace + */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-namespace */ +export namespace Websocket { + /* Errors */ + export import Error = Errors.WebsocketError; + export import ErrorNames = Errors.ErrorNames; + export type ErrorName = Errors.ErrorName; + export import AccessDeniedError = Errors.AccessDeniedError; + export import BadRequestError = Errors.BadRequestError; + export import NotFoundError = Errors.NotFoundError; + export import ServerError = Errors.ServerError; + /* Requests */ + export type SubscriptionRequest = Requests.SubscriptionRequest; + /* API */ + export import Realms = API.Realms; + export type Realm = API.Realm; + export type Message = API.Message; + export type NotifMessage = API.NotifMessage; + export import ClientActions = API.ClientActions; + export type ClientAction = API.ClientAction; + export type ClientDisconnect = API.ClientDisconnect; + export type ClientSubscribe = API.ClientSubscribe; + export type ClientUnsubscribe = API.ClientUnsubscribe; + export type ClientSubscribeOnly = API.ClientSubscribeOnly; + export type ClientMessage = API.ClientMessage; + export import ServerMessageTypes = API.ServerMessageTypes; + export type ServerMessageType = API.ServerMessageType; + export import ResponseStatuses = API.ResponseStatuses; + export type ResponseStatus = API.ResponseStatus; + export type SuccessServerResponse = API.SuccessServerResponse; + export type ErrorServerResponse = API.ErrorServerResponse; + export type ServerResponse = API.ServerResponse; + export type ServerInfo = API.ServerInfo; + export type ServerUpdate = API.ServerUpdate; + export type ServerMessage = API.ServerMessage; +} diff --git a/src/services/websockets/interfaces/request.ts b/src/services/websockets/interfaces/request.ts new file mode 100644 index 00000000..5d287fe6 --- /dev/null +++ b/src/services/websockets/interfaces/request.ts @@ -0,0 +1,20 @@ +import { Member, UnknownExtra } from '@/index'; + +/** + * Internal request type when clients attempt to subscribe to some channel + * Allows consumers to operate on the subscription attempt in the server + */ +export interface SubscriptionRequest { + /** + * Subscription target channel name + */ + channel: string; + /** + * Member requesting a subscription + */ + member: Member; + /** + * Rejects the subscription request with a specified error + */ + reject(error: Error): void; +} diff --git a/src/services/websockets/interfaces/service.ts b/src/services/websockets/interfaces/service.ts new file mode 100644 index 00000000..b9b09b3d --- /dev/null +++ b/src/services/websockets/interfaces/service.ts @@ -0,0 +1,35 @@ +import { SubscriptionRequest } from './request'; + +/** + * Public WebSocket service exposed to other consumers on the server + * (e.g. other services that want to publish messages using websockets) + */ +export interface WebsocketService { + /** + * Registers a topic (a group of related channels) dedicated to the caller + * @param topic topic name, must be unique across server + * @param validateClient async function called when a client attempts to + * subscribe to a channel from this topic + */ + register( + topic: string, + validateClient: (request: SubscriptionRequest) => Promise, + ): this; + + /** + * Publishes a message on a channel globally (incl. across server instances) + * @param topic topic name + * @param channel channel name + * @param message message to publish + */ + publish(topic: string, channel: string, message: Message): void; + + /** + * Publishes a message on a channel locally (i.e. on this specific server + * instance only) + * @param topic topic name + * @param channel channel name + * @param message message to publish + */ + publishLocal(topic: string, channel: string, message: Message): void; +}