mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Integrating Websockets in Directus 🕸️🧦 (#14737)
* added emitter context * partial items tests * updated items handler tests * fixed test after merge * forgot the event context * fixed auth message parsing for graphql subscriptions * fixed type strictness * fixed graphql subscription bug * bumped websocket dependencies * touched up some dangling code * updated itemsservice usage * disabled overkill logs * double checked environment type processing * fixed missed capitalization * fixed subscription payloads * Added explicit string type casting * removed obsolete "trimUpper" utility * using the parseJSON utility consistently * pinned dependencies * parse environment variables * fixed pnpm-lock * GraphQL Subscriptions for all events * fixed typo * added event data to the graphql definition * fix payload for delete events * Added optional chaining for type to prevent fatal crashes on invalid messages * fix failing on getting type from undefined * Update api/src/websocket/exceptions.ts Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com> * Add proper ZodError handling * added the zod-validation-error parser * allow disabling the rate limiter * Update api/src/websocket/controllers/base.ts Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com> * updated starting logs * fixed email/password expiration logic * added tests for getMessageType * simplified message parsing and dropped capitalization * updated authenticate test * switched to lower cased message.type to prevent spreading "toUpperCase" around * cleaned up debug logs * cast enabled config to boolean * Update api/src/websocket/controllers/rest.ts Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com> * Update api/src/websocket/handlers/subscribe.ts Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com> * Update api/src/websocket/handlers/subscribe.ts Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com> * Update api/src/websocket/handlers/items.ts Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com> * Update api/src/websocket/controllers/base.ts Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com> * Update api/src/websocket/handlers/heartbeat.ts Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com> * Suggested fixes by Azri * removed redundant try-catch * fixed authentication timeout added returning the refresh token when authenticating * updated pnpm lock after merge * Fixed authentication modes for GraphQL according to best practices * implement useFakeTimers in heartbeat unit test * implement useFakeTimers in items unit test * Update api/src/services/server.ts Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com> * removed obsolete authentication.verbose toggle * added email flag to message validation * switched to ternary for consistency * moved getSchema out of for loop * added singleton logic to items handler * close the socket after failed auth for non-public connections * disabled system collections for rest subscriptions * re-ran pnpm i * allow for multiple subscripitions in the memory messenger * - fixed system collection subscriptions - abstracted hook message bus - fixed graphql horizontal scaling * remove logic from root context for tests * fix reading created item * fix linter * typo and extra safe guard suggested by azri * prevent setting long timeouts in favor of a shared interval * prevent unsubscribing all existing subscriptions when omitting "uid" * - extracted getService utility - block system collections mutation in the items handler - implemented the correct services for system collections * allow numeric uid's to be used * fixed the types for numeric uid's to be used * added missing await's * fixed type imports after merge * removed unused imports * Update api/src/websocket/controllers/hooks.ts Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com> * Update api/src/websocket/controllers/hooks.ts Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com> * Update api/src/messenger.ts Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com> * improved error for graphql subscriptions * fixed TS Modernization conflicts * fixed TS Modernization conflicts * fixed conflicts after merge * removed unused name property * abstracxted environment configuration * respond to ping messages when heartbeat disabled * something something merge * moved toBoolean to it's own util file * replaced old socket naming * removed old exception * fixed typo * Update api/src/env.ts Co-authored-by: ian <licitdev@gmail.com> * Update api/src/websocket/handlers/heartbeat.test.ts Co-authored-by: ian <licitdev@gmail.com> * Update api/src/websocket/handlers/heartbeat.ts Co-authored-by: ian <licitdev@gmail.com> * Update api/src/services/server.ts Co-authored-by: ian <licitdev@gmail.com> * fixed for linter * add server_info_websocket in graphql * Add base REST websocket tests * do merge things * fixing things * fixed failing unit test * Update dependencies * Move tests * Update lockfile * Use new paths when spawning * return websockets to opt-in * Enable websockets for tests * Test with ephemeral access token * no camelcasing gql subscriptions * use underscore for gql event * Remove unused import * Add base GraphQL subscription tests * Fix accidental comment * Add some relational tests * Organize imports Using VS Code's default organize import * Run ESlint formatting * One more opinionated formatting change * Formatting * Fix message sequence not in order * Remove relational batch update tests * Test horizontal scaling * using toboolean util for server_info * removed unneeded type cast * found the gql request type * extra usage of the toBoolean util * merge the authentication middleware and get-account-for-token util * updated utility test * fixed middleware unit test * Add return * Remove user filtering and close conns * Fix reused accountability * fixed failing util test * added subscription unit tests * added missing mock * trigger workflow * Revert "trigger workflow" This reverts commit4f544b0c1b. * Trigger testing for all vendors * add unsubscription confirmation * Wait for unsubscription confirmation * Fix incorrect sending of unsubscription confirmation * updated ubsubscribe logic * Update count for unsubscription message * Fix sequence for UUID pktype in MSSQL * Increase auth timeout * Add start index when getting messages * Fix subscription retrieval and cast uid to string * Remove nested ternary * Revert "Increase auth timeout" This reverts commit10707409c4. * Terminate connection instead of close * fixed merge * re-added missing packages after merge resolve * fixed type imports * Create lazy-cows-happen.md Added changeset * Minor bump for "directus" package as well * fixed "strict" auth mode for graphql subscriptions * removed nested ternary * Add websocket tests to sequential flow * Disable pressure limiter for blackbox tests * fix merge * WebSockets Documentation (#18254) * Small repsonsive styling fix on Card * REST getting started guide * Authentication guide * REST subscription guides * JS Chat guide * Sidebar websocket guides section * Added config options * Respoinding to brainslug's review * Fixed incorrect header on guides/rt/subs * Fixed spellchecker * Correct full code example on guides/rt/chat/js * Fixed JS chat tut * Order of steps in js chat guide updated for easier following-along * Realtime chat Vue Guide * feat: create react.js file * feat: add set up for directus project * docs: create react boilder plate * docs: initialize connection * docs: set up submission methods * docs: establish websocket connection * docs: subscribe to messages * docs: create new messages * docs: display new messages * docs: display historical messages * docs: next steps * docs: full code sample * docs: clean up * docs: add name to contributors * docs: add react card * docs: updates to react chat * Added live poll result guide * docs: intro * docs: before you begin * docs: install packages * docs: authenticate connection * docs: query and mutation * docs: utilize hooks * docs: subscribe to changes * docs: create helper functions * docs: display messages * docs: summary * docs: full sample code * chore: add card for webscockets with graphql * docs: intro * docs: subscribe to changes * docs: handling changes * docs: crud operations * docs: unsubscribing from changes * docs: updates * chore: add card * chore: updates to graphql docs * chore: updates to getting started * chore: updates to subscription * chore: updates to real chat guide * Added WebSockets Operations Guide * Consistent titles * Contributors component for docs * Triggering Netlify * Add operations to sidebar * Fix operations link * Small formatting changes * Clarity around property values * Removed unused values in Contributors component * Prompt for default choice * Tabs & lowercase doctypes * Semicolons * Event overwerites -> event listeners * Spacing * Flipped order of websockets guide to match GQL --------- Co-authored-by: Esther Agbaje <folasadeagbaje@gmail.com> Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com> * fixed typo * removed unused import * added tests for "to-boolean" and "exceptions" * added websocket service tests * quote environment variable to satisfy dictionary * GraphQL Subscriptions update (#18804) * updated graphql subscription structure * updated graphql examples * Create hungry-points-rescue.md * using `key` instead of `ID` on the toplevel * removed changeset * fixed the graphql type after the rename to `key` * retrun data null for delete events to prevent non-nullable gql error * updated missed ID reference in the docs * updated missed ID reference in the docs * renamed "payload" to "data" in the REST Subscription response * fixed missed reference to payload * added optional event filter for REST subscriptions * updated docs for event filter * Update docs/guides/real-time/subscriptions/websockets.md Co-authored-by: ian <licitdev@gmail.com> --------- Co-authored-by: ian <licitdev@gmail.com> * added messenger unit test * always send subscription confirmation * Add event to subscription options * Update tests * Add tests for event filtering * Revert testing for all vendors * Remove obsolete console comment * Update comment * Correct event in JS WS guide * Fix collection name to match name used in subscription * Fix collection name in other guides * Fix diffs in doc & enhance chart example * Complete sentence in GraphQL guide * Small update to config description --------- Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com> Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com> Co-authored-by: ian <licitdev@gmail.com> Co-authored-by: Nitwel <mail@nitwel.de> Co-authored-by: Kevin Lewis <kvn@lws.io> Co-authored-by: Pascal Jufer <pascal-jufer@bluewin.ch> Co-authored-by: Esther Agbaje <folasadeagbaje@gmail.com>
This commit is contained in:
16
.changeset/lazy-cows-happen.md
Normal file
16
.changeset/lazy-cows-happen.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
"@directus/api": minor
|
||||
"directus": minor
|
||||
---
|
||||
|
||||
Integrating Websockets Subscriptions for REST and GraphQL in Directus 🕸️🧦
|
||||
- A CRUD implementation over WebSockets
|
||||
- A REST Subscriptions implementation
|
||||
- GraphQL Subscriptions over WebSockets
|
||||
- Three authentication modes: `public`, `handshake`, `strict`
|
||||
- Authentication refresh with an open socket
|
||||
- Heartbeat signal to keep the connection alive
|
||||
- Follows the Directus permission model
|
||||
- Message rate limiting and connection limiting
|
||||
- Horizontal scaling with the Messenger
|
||||
- Extensible event driven design
|
||||
@@ -112,6 +112,7 @@
|
||||
"fs-extra": "11.1.1",
|
||||
"graphql": "16.6.0",
|
||||
"graphql-compose": "9.0.10",
|
||||
"graphql-ws": "5.12.0",
|
||||
"helmet": "7.0.0",
|
||||
"icc": "3.0.0",
|
||||
"inquirer": "9.2.4",
|
||||
@@ -158,7 +159,10 @@
|
||||
"uuid": "9.0.0",
|
||||
"uuid-validate": "0.0.3",
|
||||
"vm2": "3.9.19",
|
||||
"wellknown": "0.5.0"
|
||||
"wellknown": "0.5.0",
|
||||
"ws": "8.12.1",
|
||||
"zod": "3.21.4",
|
||||
"zod-validation-error": "1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/tsconfig": "workspace:*",
|
||||
@@ -199,6 +203,7 @@
|
||||
"@types/uuid": "9.0.1",
|
||||
"@types/uuid-validate": "0.0.1",
|
||||
"@types/wellknown": "0.5.4",
|
||||
"@types/ws": "8.5.4",
|
||||
"@vitest/coverage-c8": "0.31.1",
|
||||
"copyfiles": "2.4.1",
|
||||
"form-data": "4.0.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ActionHandler, EventContext, FilterHandler, InitHandler } from '@directus/types';
|
||||
import ee2 from 'eventemitter2';
|
||||
import logger from './logger.js';
|
||||
import getDatabase from './database/index.js';
|
||||
|
||||
export class Emitter {
|
||||
private filterEmitter;
|
||||
@@ -22,11 +23,19 @@ export class Emitter {
|
||||
this.initEmitter = new ee2.EventEmitter2(emitterOptions);
|
||||
}
|
||||
|
||||
private getDefaultContext(): EventContext {
|
||||
return {
|
||||
database: getDatabase(),
|
||||
accountability: null,
|
||||
schema: null,
|
||||
};
|
||||
}
|
||||
|
||||
public async emitFilter<T>(
|
||||
event: string | string[],
|
||||
payload: T,
|
||||
meta: Record<string, any>,
|
||||
context: EventContext
|
||||
context: EventContext | null = null
|
||||
): Promise<T> {
|
||||
const events = Array.isArray(event) ? event : [event];
|
||||
|
||||
@@ -39,7 +48,7 @@ export class Emitter {
|
||||
|
||||
for (const { event, listeners } of eventListeners) {
|
||||
for (const listener of listeners) {
|
||||
const result = await listener(updatedPayload, { event, ...meta }, context);
|
||||
const result = await listener(updatedPayload, { event, ...meta }, context ?? this.getDefaultContext());
|
||||
|
||||
if (result !== undefined) {
|
||||
updatedPayload = result;
|
||||
@@ -50,11 +59,11 @@ export class Emitter {
|
||||
return updatedPayload;
|
||||
}
|
||||
|
||||
public emitAction(event: string | string[], meta: Record<string, any>, context: EventContext): void {
|
||||
public emitAction(event: string | string[], meta: Record<string, any>, context: EventContext | null = null): void {
|
||||
const events = Array.isArray(event) ? event : [event];
|
||||
|
||||
for (const event of events) {
|
||||
this.actionEmitter.emitAsync(event, { event, ...meta }, context).catch((err) => {
|
||||
this.actionEmitter.emitAsync(event, { event, ...meta }, context ?? this.getDefaultContext()).catch((err) => {
|
||||
logger.warn(`An error was thrown while executing action "${event}"`);
|
||||
logger.warn(err);
|
||||
});
|
||||
|
||||
@@ -7,10 +7,10 @@ import { parseJSON, toArray } from '@directus/utils';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import { clone, toNumber, toString } from 'lodash-es';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'path';
|
||||
import { requireYAML } from './utils/require-yaml.js';
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import { toBoolean } from './utils/to-boolean.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
@@ -206,6 +206,8 @@ const allowedEnvironmentVars = [
|
||||
// flows
|
||||
'FLOWS_EXEC_ALLOWED_MODULES',
|
||||
'FLOWS_ENV_ALLOW_LIST',
|
||||
// websockets
|
||||
'WEBSOCKETS_.+',
|
||||
].map((name) => new RegExp(`^${name}$`));
|
||||
|
||||
const acceptedEnvTypes = ['string', 'number', 'regex', 'array', 'json'];
|
||||
@@ -306,6 +308,18 @@ const defaults: Record<string, any> = {
|
||||
|
||||
GRAPHQL_INTROSPECTION: true,
|
||||
|
||||
WEBSOCKETS_ENABLED: false,
|
||||
WEBSOCKETS_REST_ENABLED: true,
|
||||
WEBSOCKETS_REST_AUTH: 'handshake',
|
||||
WEBSOCKETS_REST_AUTH_TIMEOUT: 10,
|
||||
WEBSOCKETS_REST_PATH: '/websocket',
|
||||
WEBSOCKETS_GRAPHQL_ENABLED: true,
|
||||
WEBSOCKETS_GRAPHQL_AUTH: 'handshake',
|
||||
WEBSOCKETS_GRAPHQL_AUTH_TIMEOUT: 10,
|
||||
WEBSOCKETS_GRAPHQL_PATH: '/graphql',
|
||||
WEBSOCKETS_HEARTBEAT_ENABLED: true,
|
||||
WEBSOCKETS_HEARTBEAT_PERIOD: 30,
|
||||
|
||||
FLOWS_EXEC_ALLOWED_MODULES: false,
|
||||
FLOWS_ENV_ALLOW_LIST: false,
|
||||
|
||||
@@ -571,7 +585,3 @@ function tryJSON(value: any) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function toBoolean(value: any): boolean {
|
||||
return value === 'true' || value === true || value === '1' || value === 1;
|
||||
}
|
||||
|
||||
82
api/src/messenger.test.ts
Normal file
82
api/src/messenger.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { getEnv } from './env.js';
|
||||
import { MessengerMemory, MessengerRedis } from './messenger.js';
|
||||
|
||||
vi.mock('./env');
|
||||
vi.mock('ioredis');
|
||||
|
||||
async function dynamicMessenger(mockedEnv: Record<string, any>) {
|
||||
vi.mocked(getEnv).mockReturnValue(mockedEnv);
|
||||
|
||||
return await import('./messenger.js');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe('MessengerMemory', () => {
|
||||
test('getMessenger', async () => {
|
||||
const { MessengerMemory, getMessenger } = await dynamicMessenger({
|
||||
MESSENGER_STORE: 'memory',
|
||||
});
|
||||
|
||||
const messenger = getMessenger();
|
||||
|
||||
expect(messenger).toBeInstanceOf(MessengerMemory);
|
||||
});
|
||||
|
||||
test('subscribing', () => {
|
||||
const messages: Record<string, string>[] = [];
|
||||
const testMessage = { test: 'test' };
|
||||
const messenger = new MessengerMemory();
|
||||
|
||||
messenger.subscribe('test', (data: Record<string, string>) => {
|
||||
messages.push(data);
|
||||
});
|
||||
|
||||
messenger.publish('test', testMessage);
|
||||
|
||||
expect(messenger.handlers['test']?.size ?? 0).toBe(1);
|
||||
expect(messages.length).toBe(1);
|
||||
expect(messages).toStrictEqual([testMessage]);
|
||||
|
||||
messenger.unsubscribe('test');
|
||||
|
||||
messenger.publish('test', testMessage);
|
||||
|
||||
expect(messenger.handlers['test']?.size ?? 0).toBe(0);
|
||||
expect(messages.length).toBe(1);
|
||||
expect(messages).toStrictEqual([testMessage]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessengerRedis', () => {
|
||||
test('getMessenger', async () => {
|
||||
const { MessengerRedis, getMessenger } = await dynamicMessenger({
|
||||
MESSENGER_STORE: 'redis',
|
||||
});
|
||||
|
||||
const messenger = getMessenger();
|
||||
|
||||
expect(messenger).toBeInstanceOf(MessengerRedis);
|
||||
});
|
||||
|
||||
test('subscribing', () => {
|
||||
const testMessage = { test: 'test' };
|
||||
const messenger = new MessengerRedis();
|
||||
|
||||
messenger.subscribe('test', (_data: Record<string, string>) => {
|
||||
// do nothing
|
||||
});
|
||||
|
||||
expect(messenger.sub.subscribe).toBeCalled();
|
||||
expect(messenger.sub.on).toBeCalled();
|
||||
|
||||
messenger.publish('test', testMessage);
|
||||
expect(messenger.pub.publish).toBeCalled();
|
||||
|
||||
messenger.unsubscribe('test');
|
||||
expect(messenger.sub.unsubscribe).toBeCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { parseJSON } from '@directus/utils';
|
||||
import { Redis } from 'ioredis';
|
||||
import env from './env.js';
|
||||
import { getEnv } from './env.js';
|
||||
import { getConfigFromEnv } from './utils/get-config-from-env.js';
|
||||
|
||||
export type MessengerSubscriptionCallback = (payload: Record<string, any>) => void;
|
||||
@@ -8,26 +8,31 @@ export type MessengerSubscriptionCallback = (payload: Record<string, any>) => vo
|
||||
export interface Messenger {
|
||||
publish: (channel: string, payload: Record<string, any>) => void;
|
||||
subscribe: (channel: string, callback: MessengerSubscriptionCallback) => void;
|
||||
unsubscribe: (channel: string) => void;
|
||||
unsubscribe: (channel: string, callback?: MessengerSubscriptionCallback) => void;
|
||||
}
|
||||
|
||||
export class MessengerMemory implements Messenger {
|
||||
handlers: Record<string, MessengerSubscriptionCallback>;
|
||||
handlers: Record<string, Set<MessengerSubscriptionCallback>>;
|
||||
|
||||
constructor() {
|
||||
this.handlers = {};
|
||||
}
|
||||
|
||||
publish(channel: string, payload: Record<string, any>) {
|
||||
this.handlers[channel]?.(payload);
|
||||
this.handlers[channel]?.forEach((callback) => callback(payload));
|
||||
}
|
||||
|
||||
subscribe(channel: string, callback: MessengerSubscriptionCallback) {
|
||||
this.handlers[channel] = callback;
|
||||
if (!this.handlers[channel]) this.handlers[channel] = new Set();
|
||||
this.handlers[channel]?.add(callback);
|
||||
}
|
||||
|
||||
unsubscribe(channel: string) {
|
||||
delete this.handlers[channel];
|
||||
unsubscribe(channel: string, callback?: MessengerSubscriptionCallback) {
|
||||
if (!callback) {
|
||||
delete this.handlers[channel];
|
||||
} else {
|
||||
this.handlers[channel]?.delete(callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +43,7 @@ export class MessengerRedis implements Messenger {
|
||||
|
||||
constructor() {
|
||||
const config = getConfigFromEnv('MESSENGER_REDIS');
|
||||
|
||||
const env = getEnv();
|
||||
this.pub = new Redis(env['MESSENGER_REDIS'] ?? config);
|
||||
this.sub = new Redis(env['MESSENGER_REDIS'] ?? config);
|
||||
this.namespace = env['MESSENGER_NAMESPACE'] ?? 'directus';
|
||||
@@ -69,6 +74,7 @@ let messenger: Messenger;
|
||||
|
||||
export function getMessenger() {
|
||||
if (messenger) return messenger;
|
||||
const env = getEnv();
|
||||
|
||||
if (env['MESSENGER_STORE'] === 'redis') {
|
||||
messenger = new MessengerRedis();
|
||||
|
||||
@@ -2,18 +2,19 @@ import type { Request, Response } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { Knex } from 'knex';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import '../../src/types/express.d.ts';
|
||||
import '../types/express.d.ts';
|
||||
import getDatabase from '../database/index.js';
|
||||
import emitter from '../emitter.js';
|
||||
import env from '../env.js';
|
||||
import { InvalidCredentialsException } from '../exceptions/invalid-credentials.js';
|
||||
import { handler } from './authenticate.js';
|
||||
|
||||
vi.mock('../../src/database');
|
||||
vi.mock('../database/index');
|
||||
|
||||
vi.mock('../../src/env', () => {
|
||||
vi.mock('../env', () => {
|
||||
const MOCK_ENV = {
|
||||
SECRET: 'test',
|
||||
EXTENSIONS_PATH: './extensions',
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,12 +3,9 @@ import type { NextFunction, Request, Response } from 'express';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import getDatabase from '../database/index.js';
|
||||
import emitter from '../emitter.js';
|
||||
import env from '../env.js';
|
||||
import { InvalidCredentialsException } from '../exceptions/index.js';
|
||||
import asyncHandler from '../utils/async-handler.js';
|
||||
import { getIPFromReq } from '../utils/get-ip-from-req.js';
|
||||
import isDirectusJWT from '../utils/is-directus-jwt.js';
|
||||
import { verifyAccessJWT } from '../utils/jwt.js';
|
||||
import { getAccountabilityForToken } from '../utils/get-accountability-for-token.js';
|
||||
|
||||
/**
|
||||
* Verify the passed JWT and assign the user ID and role to `req`
|
||||
@@ -48,41 +45,7 @@ export const handler = async (req: Request, _res: Response, next: NextFunction)
|
||||
return next();
|
||||
}
|
||||
|
||||
req.accountability = defaultAccountability;
|
||||
|
||||
if (req.token) {
|
||||
if (isDirectusJWT(req.token)) {
|
||||
const payload = verifyAccessJWT(req.token, env['SECRET']);
|
||||
|
||||
req.accountability.role = payload.role;
|
||||
req.accountability.admin = payload.admin_access === true || payload.admin_access == 1;
|
||||
req.accountability.app = payload.app_access === true || payload.app_access == 1;
|
||||
|
||||
if (payload.share) req.accountability.share = payload.share;
|
||||
if (payload.share_scope) req.accountability.share_scope = payload.share_scope;
|
||||
if (payload.id) req.accountability.user = payload.id;
|
||||
} else {
|
||||
// Try finding the user with the provided token
|
||||
const user = await database
|
||||
.select('directus_users.id', 'directus_users.role', 'directus_roles.admin_access', 'directus_roles.app_access')
|
||||
.from('directus_users')
|
||||
.leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
|
||||
.where({
|
||||
'directus_users.token': req.token,
|
||||
status: 'active',
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!user) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
req.accountability.user = user.id;
|
||||
req.accountability.role = user.role;
|
||||
req.accountability.admin = user.admin_access === true || user.admin_access == 1;
|
||||
req.accountability.app = user.app_access === true || user.app_access == 1;
|
||||
}
|
||||
}
|
||||
req.accountability = await getAccountabilityForToken(req.token, defaultAccountability);
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
@@ -12,6 +12,14 @@ import emitter from './emitter.js';
|
||||
import env from './env.js';
|
||||
import logger from './logger.js';
|
||||
import { getConfigFromEnv } from './utils/get-config-from-env.js';
|
||||
import {
|
||||
createSubscriptionController,
|
||||
createWebSocketController,
|
||||
getSubscriptionController,
|
||||
getWebSocketController,
|
||||
} from './websocket/controllers/index.js';
|
||||
import { startWebSocketHandlers } from './websocket/handlers/index.js';
|
||||
import { toBoolean } from './utils/to-boolean.js';
|
||||
|
||||
export let SERVER_ONLINE = true;
|
||||
|
||||
@@ -82,6 +90,12 @@ export async function createServer(): Promise<http.Server> {
|
||||
res.once('close', complete.bind(null, false));
|
||||
});
|
||||
|
||||
if (toBoolean(env['WEBSOCKETS_ENABLED']) === true) {
|
||||
createSubscriptionController(server);
|
||||
createWebSocketController(server);
|
||||
startWebSocketHandlers();
|
||||
}
|
||||
|
||||
const terminusOptions: TerminusOptions = {
|
||||
timeout:
|
||||
env['SERVER_SHUTDOWN_TIMEOUT'] >= 0 && env['SERVER_SHUTDOWN_TIMEOUT'] < Infinity
|
||||
@@ -106,6 +120,8 @@ export async function createServer(): Promise<http.Server> {
|
||||
}
|
||||
|
||||
async function onSignal() {
|
||||
getSubscriptionController()?.terminate();
|
||||
getWebSocketController()?.terminate();
|
||||
const database = getDatabase();
|
||||
await database.destroy();
|
||||
|
||||
|
||||
@@ -62,24 +62,13 @@ import { AuthenticationService } from '../authentication.js';
|
||||
import { CollectionsService } from '../collections.js';
|
||||
import { FieldsService } from '../fields.js';
|
||||
import { FilesService } from '../files.js';
|
||||
import { FlowsService } from '../flows.js';
|
||||
import { FoldersService } from '../folders.js';
|
||||
import { ItemsService } from '../items.js';
|
||||
import { NotificationsService } from '../notifications.js';
|
||||
import { OperationsService } from '../operations.js';
|
||||
import { PermissionsService } from '../permissions.js';
|
||||
import { PresetsService } from '../presets.js';
|
||||
import { RelationsService } from '../relations.js';
|
||||
import { RevisionsService } from '../revisions.js';
|
||||
import { RolesService } from '../roles.js';
|
||||
import { ServerService } from '../server.js';
|
||||
import { SettingsService } from '../settings.js';
|
||||
import { SharesService } from '../shares.js';
|
||||
import { SpecificationService } from '../specifications.js';
|
||||
import { TFAService } from '../tfa.js';
|
||||
import { UsersService } from '../users.js';
|
||||
import { UtilsService } from '../utils.js';
|
||||
import { WebhooksService } from '../webhooks.js';
|
||||
import { GraphQLBigInt } from './types/bigint.js';
|
||||
import { GraphQLDate } from './types/date.js';
|
||||
import { GraphQLGeoJSON } from './types/geojson.js';
|
||||
@@ -88,6 +77,9 @@ import { GraphQLStringOrFloat } from './types/string-or-float.js';
|
||||
import { GraphQLVoid } from './types/void.js';
|
||||
import { addPathToValidationError } from './utils/add-path-to-validation-error.js';
|
||||
import processError from './utils/process-error.js';
|
||||
import { createSubscriptionGenerator } from './subscription.js';
|
||||
import { getService } from '../../utils/get-service.js';
|
||||
import { toBoolean } from '../../utils/to-boolean.js';
|
||||
|
||||
const validationRules = Array.from(specifiedRules);
|
||||
|
||||
@@ -198,6 +190,15 @@ export class GraphQLService {
|
||||
: reduceSchema(this.schema, this.accountability?.permissions || null, ['delete']),
|
||||
};
|
||||
|
||||
const subscriptionEventType = schemaComposer.createEnumTC({
|
||||
name: 'EventEnum',
|
||||
values: {
|
||||
create: { value: 'create' },
|
||||
update: { value: 'update' },
|
||||
delete: { value: 'delete' },
|
||||
},
|
||||
});
|
||||
|
||||
const { ReadCollectionTypes } = getReadableTypes();
|
||||
|
||||
const { CreateCollectionTypes, UpdateCollectionTypes, DeleteCollectionTypes } = getWritableTypes();
|
||||
@@ -1066,6 +1067,29 @@ export class GraphQLService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const eventName = `${collection.collection}_mutated`;
|
||||
|
||||
if (collection.collection in ReadCollectionTypes) {
|
||||
const subscriptionType = schemaComposer.createObjectTC({
|
||||
name: eventName,
|
||||
fields: {
|
||||
key: new GraphQLNonNull(GraphQLID),
|
||||
event: subscriptionEventType,
|
||||
data: ReadCollectionTypes[collection.collection]!,
|
||||
},
|
||||
});
|
||||
|
||||
schemaComposer.Subscription.addFields({
|
||||
[eventName]: {
|
||||
type: subscriptionType,
|
||||
args: {
|
||||
event: subscriptionEventType,
|
||||
},
|
||||
subscribe: createSubscriptionGenerator(self, eventName),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const relation of schema.read.relations) {
|
||||
@@ -1411,7 +1435,12 @@ export class GraphQLService {
|
||||
return await this.upsertSingleton(collection, args['data'], query);
|
||||
}
|
||||
|
||||
const service = this.getService(collection);
|
||||
const service = getService(collection, {
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
schema: this.schema,
|
||||
});
|
||||
|
||||
const hasQuery = (query.fields || []).length > 0;
|
||||
|
||||
try {
|
||||
@@ -1466,7 +1495,11 @@ export class GraphQLService {
|
||||
* Execute the read action on the correct service. Checks for singleton as well.
|
||||
*/
|
||||
async read(collection: string, query: Query): Promise<Partial<Item>> {
|
||||
const service = this.getService(collection);
|
||||
const service = getService(collection, {
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
schema: this.schema,
|
||||
});
|
||||
|
||||
const result = this.schema.collections[collection]!.singleton
|
||||
? await service.readSingleton(query, { stripNonRequested: false })
|
||||
@@ -1483,7 +1516,11 @@ export class GraphQLService {
|
||||
body: Record<string, any> | Record<string, any>[],
|
||||
query: Query
|
||||
): Promise<Partial<Item> | boolean> {
|
||||
const service = this.getService(collection);
|
||||
const service = getService(collection, {
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
schema: this.schema,
|
||||
});
|
||||
|
||||
try {
|
||||
await service.upsertSingleton(body);
|
||||
@@ -1737,51 +1774,6 @@ export class GraphQLService {
|
||||
return new GraphQLError(error.message, undefined, undefined, undefined, undefined, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the correct service for the given collection. This allows the individual services to run
|
||||
* their custom checks (f.e. it allows UsersService to prevent updating TFA secret from outside)
|
||||
*/
|
||||
getService(collection: string): ItemsService {
|
||||
const opts = {
|
||||
knex: this.knex,
|
||||
accountability: this.accountability,
|
||||
schema: this.schema,
|
||||
};
|
||||
|
||||
switch (collection) {
|
||||
case 'directus_activity':
|
||||
return new ActivityService(opts);
|
||||
case 'directus_files':
|
||||
return new FilesService(opts);
|
||||
case 'directus_folders':
|
||||
return new FoldersService(opts);
|
||||
case 'directus_permissions':
|
||||
return new PermissionsService(opts);
|
||||
case 'directus_presets':
|
||||
return new PresetsService(opts);
|
||||
case 'directus_notifications':
|
||||
return new NotificationsService(opts);
|
||||
case 'directus_revisions':
|
||||
return new RevisionsService(opts);
|
||||
case 'directus_roles':
|
||||
return new RolesService(opts);
|
||||
case 'directus_settings':
|
||||
return new SettingsService(opts);
|
||||
case 'directus_users':
|
||||
return new UsersService(opts);
|
||||
case 'directus_webhooks':
|
||||
return new WebhooksService(opts);
|
||||
case 'directus_shares':
|
||||
return new SharesService(opts);
|
||||
case 'directus_flows':
|
||||
return new FlowsService(opts);
|
||||
case 'directus_operations':
|
||||
return new OperationsService(opts);
|
||||
default:
|
||||
return new ItemsService(collection, opts);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all fragments in a selectionset for the actual selection set as defined in the fragment
|
||||
* Effectively merges the selections with the fragments used in those selections
|
||||
@@ -1907,6 +1899,58 @@ export class GraphQLService {
|
||||
},
|
||||
}),
|
||||
},
|
||||
websocket: toBoolean(env['WEBSOCKETS_ENABLED'])
|
||||
? {
|
||||
type: new GraphQLObjectType({
|
||||
name: 'server_info_websocket',
|
||||
fields: {
|
||||
rest: {
|
||||
type: toBoolean(env['WEBSOCKETS_REST_ENABLED'])
|
||||
? new GraphQLObjectType({
|
||||
name: 'server_info_websocket_rest',
|
||||
fields: {
|
||||
authentication: {
|
||||
type: new GraphQLEnumType({
|
||||
name: 'server_info_websocket_rest_authentication',
|
||||
values: {
|
||||
public: { value: 'public' },
|
||||
handshake: { value: 'handshake' },
|
||||
strict: { value: 'strict' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
path: { type: GraphQLString },
|
||||
},
|
||||
})
|
||||
: GraphQLBoolean,
|
||||
},
|
||||
graphql: {
|
||||
type: toBoolean(env['WEBSOCKETS_GRAPHQL_ENABLED'])
|
||||
? new GraphQLObjectType({
|
||||
name: 'server_info_websocket_graphql',
|
||||
fields: {
|
||||
authentication: {
|
||||
type: new GraphQLEnumType({
|
||||
name: 'server_info_websocket_graphql_authentication',
|
||||
values: {
|
||||
public: { value: 'public' },
|
||||
handshake: { value: 'handshake' },
|
||||
strict: { value: 'strict' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
path: { type: GraphQLString },
|
||||
},
|
||||
})
|
||||
: GraphQLBoolean,
|
||||
},
|
||||
heartbeat: {
|
||||
type: toBoolean(env['WEBSOCKETS_HEARTBEAT_ENABLED']) ? GraphQLInt : GraphQLBoolean,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
: GraphQLBoolean,
|
||||
queryLimit: {
|
||||
type: new GraphQLObjectType({
|
||||
name: 'server_info_query_limit',
|
||||
|
||||
103
api/src/services/graphql/subscription.ts
Normal file
103
api/src/services/graphql/subscription.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { EventEmitter, on } from 'events';
|
||||
import { getMessenger } from '../../messenger.js';
|
||||
import type { GraphQLService } from './index.js';
|
||||
import { getSchema } from '../../utils/get-schema.js';
|
||||
import { ItemsService } from '../items.js';
|
||||
import type { Query } from '@directus/types';
|
||||
import type { GraphQLResolveInfo, SelectionNode } from 'graphql';
|
||||
|
||||
const messages = createPubSub(new EventEmitter());
|
||||
|
||||
export function bindPubSub() {
|
||||
const messenger = getMessenger();
|
||||
|
||||
messenger.subscribe('websocket.event', (message: Record<string, any>) => {
|
||||
messages.publish(`${message['collection']}_mutated`, message);
|
||||
});
|
||||
}
|
||||
|
||||
export function createSubscriptionGenerator(self: GraphQLService, event: string) {
|
||||
return async function* (_x: unknown, _y: unknown, _z: unknown, request: GraphQLResolveInfo) {
|
||||
const fields = parseFields(self, request);
|
||||
const args = parseArguments(request);
|
||||
|
||||
for await (const payload of messages.subscribe(event)) {
|
||||
const eventData = payload as Record<string, any>;
|
||||
|
||||
if ('event' in args && eventData['action'] !== args['event']) {
|
||||
continue; // skip filtered events
|
||||
}
|
||||
|
||||
const schema = await getSchema();
|
||||
|
||||
if (eventData['action'] === 'create') {
|
||||
const { collection, key } = eventData;
|
||||
const service = new ItemsService(collection, { schema });
|
||||
const data = await service.readOne(key, { fields } as Query);
|
||||
yield { [event]: { key, data, event: 'create' } };
|
||||
}
|
||||
|
||||
if (eventData['action'] === 'update') {
|
||||
const { collection, keys } = eventData;
|
||||
const service = new ItemsService(collection, { schema });
|
||||
|
||||
for (const key of keys) {
|
||||
const data = await service.readOne(key, { fields } as Query);
|
||||
yield { [event]: { key, data, event: 'update' } };
|
||||
}
|
||||
}
|
||||
|
||||
if (eventData['action'] === 'delete') {
|
||||
const { keys } = eventData;
|
||||
|
||||
for (const key of keys) {
|
||||
yield { [event]: { key, data: null, event: 'delete' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createPubSub<P extends { [key: string]: unknown }>(emitter: EventEmitter) {
|
||||
return {
|
||||
publish: <T extends Extract<keyof P, string>>(event: T, payload: P[T]) =>
|
||||
void emitter.emit(event as string, payload),
|
||||
subscribe: async function* <T extends Extract<keyof P, string>>(event: T): AsyncIterableIterator<P[T]> {
|
||||
const asyncIterator = on(emitter, event);
|
||||
|
||||
for await (const [value] of asyncIterator) {
|
||||
yield value;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseFields(service: GraphQLService, request: GraphQLResolveInfo) {
|
||||
const selections = request.fieldNodes[0]?.selectionSet?.selections ?? [];
|
||||
|
||||
const dataSelections = selections.reduce((result: readonly SelectionNode[], selection: SelectionNode) => {
|
||||
if (
|
||||
selection.kind === 'Field' &&
|
||||
selection.name.value === 'data' &&
|
||||
selection.selectionSet?.kind === 'SelectionSet'
|
||||
) {
|
||||
return selection.selectionSet.selections;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const { fields } = service.getQuery({}, dataSelections, request.variableValues);
|
||||
return fields ?? [];
|
||||
}
|
||||
|
||||
function parseArguments(request: GraphQLResolveInfo) {
|
||||
const args = request.fieldNodes[0]?.arguments ?? [];
|
||||
return args.reduce((result, current) => {
|
||||
if ('value' in current.value && typeof current.value.value === 'string') {
|
||||
result[current.name.value] = current.value.value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, {} as Record<string, string>);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { getStorage } from '../storage/index.js';
|
||||
import type { AbstractServiceOptions } from '../types/index.js';
|
||||
import { version } from '../utils/package.js';
|
||||
import { SettingsService } from './settings.js';
|
||||
import { toBoolean } from '../utils/to-boolean.js';
|
||||
|
||||
export class ServerService {
|
||||
knex: Knex;
|
||||
@@ -78,6 +79,32 @@ export class ServerService {
|
||||
};
|
||||
}
|
||||
|
||||
if (this.accountability?.user) {
|
||||
if (toBoolean(env['WEBSOCKETS_ENABLED'])) {
|
||||
info['websocket'] = {};
|
||||
|
||||
info['websocket'].rest = toBoolean(env['WEBSOCKETS_REST_ENABLED'])
|
||||
? {
|
||||
authentication: env['WEBSOCKETS_REST_AUTH'],
|
||||
path: env['WEBSOCKETS_REST_PATH'],
|
||||
}
|
||||
: false;
|
||||
|
||||
info['websocket'].graphql = toBoolean(env['WEBSOCKETS_GRAPHQL_ENABLED'])
|
||||
? {
|
||||
authentication: env['WEBSOCKETS_GRAPHQL_AUTH'],
|
||||
path: env['WEBSOCKETS_GRAPHQL_PATH'],
|
||||
}
|
||||
: false;
|
||||
|
||||
info['websocket'].heartbeat = toBoolean(env['WEBSOCKETS_HEARTBEAT_ENABLED'])
|
||||
? env['WEBSOCKETS_HEARTBEAT_PERIOD']
|
||||
: false;
|
||||
} else {
|
||||
info['websocket'] = false;
|
||||
}
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
|
||||
69
api/src/services/websocket.test.ts
Normal file
69
api/src/services/websocket.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import type { WebSocketClient } from '../websocket/types.js';
|
||||
import { WebSocketController, getWebSocketController } from '../websocket/controllers/index.js';
|
||||
import type { Accountability } from '@directus/types';
|
||||
import { WebSocketService } from './websocket.js';
|
||||
|
||||
vi.mock('../emitter');
|
||||
vi.mock('../websocket/controllers/index');
|
||||
|
||||
function mockClient(accountability: Accountability | null = null) {
|
||||
return {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
accountability,
|
||||
} as unknown as WebSocketClient;
|
||||
}
|
||||
|
||||
describe('WebSocketService', () => {
|
||||
test('get clients', () => {
|
||||
vi.mocked(getWebSocketController).mockReturnValue({
|
||||
clients: new Set([mockClient(), mockClient(), mockClient()]),
|
||||
} as unknown as WebSocketController);
|
||||
|
||||
const wsService = new WebSocketService();
|
||||
expect(wsService.clients().size).toBe(3);
|
||||
});
|
||||
|
||||
test('broadcast', () => {
|
||||
const clients = new Set([mockClient(), mockClient(), mockClient()]);
|
||||
const message = 'test 123';
|
||||
|
||||
vi.mocked(getWebSocketController).mockReturnValue({ clients } as unknown as WebSocketController);
|
||||
|
||||
const wsService = new WebSocketService();
|
||||
wsService.broadcast(message);
|
||||
|
||||
for (const client of clients) {
|
||||
expect(client.send).toBeCalledWith(message);
|
||||
}
|
||||
});
|
||||
|
||||
test('broadcast with role filter', () => {
|
||||
const clients = [mockClient({ user: 'test', role: 'test' }), mockClient({ user: 'test2', role: 'test2' })];
|
||||
const message = 'test 123';
|
||||
|
||||
vi.mocked(getWebSocketController).mockReturnValue({ clients: new Set(clients) } as unknown as WebSocketController);
|
||||
|
||||
const wsService = new WebSocketService();
|
||||
wsService.broadcast(message, { role: 'test' });
|
||||
|
||||
expect(clients[0]!.send).toBeCalledWith(message);
|
||||
expect(clients[1]!.send).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('broadcast with user filter', () => {
|
||||
const clients = [mockClient({ user: 'test', role: 'test' }), mockClient({ user: 'test2', role: 'test2' })];
|
||||
const message = 'test 123';
|
||||
|
||||
vi.mocked(getWebSocketController).mockReturnValue({ clients: new Set(clients) } as unknown as WebSocketController);
|
||||
|
||||
const wsService = new WebSocketService();
|
||||
wsService.broadcast(message, { user: 'test2' });
|
||||
|
||||
expect(clients[0]!.send).not.toBeCalled();
|
||||
expect(clients[1]!.send).toBeCalledWith(message);
|
||||
});
|
||||
});
|
||||
34
api/src/services/websocket.ts
Normal file
34
api/src/services/websocket.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ActionHandler } from '@directus/types';
|
||||
import { getWebSocketController } from '../websocket/controllers/index.js';
|
||||
import type { WebSocketController } from '../websocket/controllers/rest.js';
|
||||
import type { WebSocketClient } from '../websocket/types.js';
|
||||
import type { WebSocketMessage } from '../websocket/messages.js';
|
||||
import emitter from '../emitter.js';
|
||||
|
||||
export class WebSocketService {
|
||||
private controller: WebSocketController;
|
||||
|
||||
constructor() {
|
||||
this.controller = getWebSocketController();
|
||||
}
|
||||
|
||||
on(event: 'connect' | 'message' | 'error' | 'close', callback: ActionHandler) {
|
||||
emitter.onAction('websocket.' + event, callback);
|
||||
}
|
||||
|
||||
off(event: 'connect' | 'message' | 'error' | 'close', callback: ActionHandler) {
|
||||
emitter.offAction('websocket.' + event, callback);
|
||||
}
|
||||
|
||||
broadcast(message: string | WebSocketMessage, filter?: { user?: string; role?: string }) {
|
||||
this.controller.clients.forEach((client: WebSocketClient) => {
|
||||
if (filter && filter.user && filter.user !== client.accountability?.user) return;
|
||||
if (filter && filter.role && filter.role !== client.accountability?.role) return;
|
||||
client.send(typeof message === 'string' ? message : JSON.stringify(message));
|
||||
});
|
||||
}
|
||||
|
||||
clients(): Set<WebSocketClient> {
|
||||
return this.controller.clients;
|
||||
}
|
||||
}
|
||||
87
api/src/utils/get-accountability-for-role.test.ts
Normal file
87
api/src/utils/get-accountability-for-role.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { expect, describe, test, vi } from 'vitest';
|
||||
import { getAccountabilityForRole } from './get-accountability-for-role.js';
|
||||
|
||||
vi.mock('./get-permissions', () => ({
|
||||
getPermissions: vi.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
function mockDatabase() {
|
||||
const self: Record<string, any> = {
|
||||
select: vi.fn(() => self),
|
||||
from: vi.fn(() => self),
|
||||
where: vi.fn(() => self),
|
||||
first: vi.fn(),
|
||||
};
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
describe('getAccountabilityForRole', async () => {
|
||||
test('no role', async () => {
|
||||
const result = await getAccountabilityForRole(null, {
|
||||
accountability: null,
|
||||
schema: {} as any,
|
||||
database: vi.fn() as any,
|
||||
});
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
admin: false,
|
||||
app: false,
|
||||
permissions: [],
|
||||
role: null,
|
||||
user: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('system role', async () => {
|
||||
const result = await getAccountabilityForRole('system', {
|
||||
accountability: null,
|
||||
schema: {} as any,
|
||||
database: vi.fn() as any,
|
||||
});
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
admin: true,
|
||||
app: true,
|
||||
permissions: [],
|
||||
role: null,
|
||||
user: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('get role from database', async () => {
|
||||
const db = mockDatabase();
|
||||
|
||||
db['first'].mockReturnValue({
|
||||
admin_access: 'not true',
|
||||
app_access: '1',
|
||||
});
|
||||
|
||||
const result = await getAccountabilityForRole('123-456', {
|
||||
accountability: null,
|
||||
schema: {} as any,
|
||||
database: db as any,
|
||||
});
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
admin: false,
|
||||
app: true,
|
||||
permissions: [],
|
||||
role: '123-456',
|
||||
user: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('database invalid role', async () => {
|
||||
const db = mockDatabase();
|
||||
db['first'].mockReturnValue(false);
|
||||
|
||||
expect(() =>
|
||||
getAccountabilityForRole('456-789', {
|
||||
accountability: null,
|
||||
schema: {} as any,
|
||||
database: db as any,
|
||||
})
|
||||
).rejects.toThrow('Configured role "456-789" isn\'t a valid role ID or doesn\'t exist.');
|
||||
});
|
||||
});
|
||||
101
api/src/utils/get-accountability-for-token.test.ts
Normal file
101
api/src/utils/get-accountability-for-token.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { expect, describe, test, vi } from 'vitest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import env from '../env.js';
|
||||
import { getAccountabilityForToken } from './get-accountability-for-token.js';
|
||||
import getDatabase from '../database/index.js';
|
||||
|
||||
vi.mock('../env', () => {
|
||||
const MOCK_ENV = {
|
||||
SECRET: 'super-secure-secret',
|
||||
EXTENSIONS_PATH: './extensions',
|
||||
};
|
||||
|
||||
return {
|
||||
default: MOCK_ENV,
|
||||
getEnv: () => MOCK_ENV,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../database/index', () => {
|
||||
const self: Record<string, any> = {
|
||||
select: vi.fn(() => self),
|
||||
from: vi.fn(() => self),
|
||||
leftJoin: vi.fn(() => self),
|
||||
where: vi.fn(() => self),
|
||||
first: vi.fn(),
|
||||
};
|
||||
|
||||
return { default: vi.fn(() => self) };
|
||||
});
|
||||
|
||||
describe('getAccountabilityForToken', async () => {
|
||||
test('minimal token payload', async () => {
|
||||
const token = jwt.sign({ role: '123-456-789', app_access: false, admin_access: false }, env['SECRET'], {
|
||||
issuer: 'directus',
|
||||
});
|
||||
|
||||
const result = await getAccountabilityForToken(token);
|
||||
expect(result).toStrictEqual({ admin: false, app: false, role: '123-456-789', user: null });
|
||||
});
|
||||
|
||||
test('full token payload', async () => {
|
||||
const token = jwt.sign(
|
||||
{
|
||||
share: 'share-id',
|
||||
share_scope: 'share-scope',
|
||||
id: 'user-id',
|
||||
role: 'role-id',
|
||||
admin_access: 1,
|
||||
app_access: 1,
|
||||
},
|
||||
env['SECRET'],
|
||||
{ issuer: 'directus' }
|
||||
);
|
||||
|
||||
const result = await getAccountabilityForToken(token);
|
||||
expect(result.admin).toBe(true);
|
||||
expect(result.app).toBe(true);
|
||||
expect(result.role).toBe('role-id');
|
||||
expect(result.share).toBe('share-id');
|
||||
expect(result.share_scope).toBe('share-scope');
|
||||
expect(result.user).toBe('user-id');
|
||||
});
|
||||
|
||||
test('throws token expired error', async () => {
|
||||
const token = jwt.sign({ role: '123-456-789' }, env['SECRET'], { issuer: 'directus', expiresIn: -1 });
|
||||
expect(() => getAccountabilityForToken(token)).rejects.toThrow('Token expired.');
|
||||
});
|
||||
|
||||
test('throws token invalid error', async () => {
|
||||
const token = jwt.sign({ role: '123-456-789' }, 'bad-secret', { issuer: 'directus' });
|
||||
expect(() => getAccountabilityForToken(token)).rejects.toThrow('Token invalid.');
|
||||
});
|
||||
|
||||
test('find user in database', async () => {
|
||||
const db = getDatabase();
|
||||
|
||||
vi.spyOn(db, 'first').mockReturnValue({
|
||||
id: 'user-id',
|
||||
role: 'role-id',
|
||||
admin_access: false,
|
||||
app_access: true,
|
||||
} as any);
|
||||
|
||||
const token = jwt.sign({ role: '123-456-789' }, 'bad-secret');
|
||||
const result = await getAccountabilityForToken(token);
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
user: 'user-id',
|
||||
role: 'role-id',
|
||||
admin: false,
|
||||
app: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('no user found', async () => {
|
||||
const db = getDatabase();
|
||||
vi.spyOn(db, 'first').mockReturnValue(false as any);
|
||||
const token = jwt.sign({ role: '123-456-789' }, 'bad-secret');
|
||||
expect(() => getAccountabilityForToken(token)).rejects.toThrow('Invalid user credentials.');
|
||||
});
|
||||
});
|
||||
58
api/src/utils/get-accountability-for-token.ts
Normal file
58
api/src/utils/get-accountability-for-token.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import getDatabase from '../database/index.js';
|
||||
import type { Accountability } from '@directus/types';
|
||||
import isDirectusJWT from './is-directus-jwt.js';
|
||||
import { InvalidCredentialsException } from '../index.js';
|
||||
import env from '../env.js';
|
||||
import { verifyAccessJWT } from './jwt.js';
|
||||
|
||||
export async function getAccountabilityForToken(
|
||||
token?: string | null,
|
||||
accountability?: Accountability
|
||||
): Promise<Accountability> {
|
||||
if (!accountability) {
|
||||
accountability = {
|
||||
user: null,
|
||||
role: null,
|
||||
admin: false,
|
||||
app: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (token) {
|
||||
if (isDirectusJWT(token)) {
|
||||
const payload = verifyAccessJWT(token, env['SECRET'] as string);
|
||||
|
||||
accountability.role = payload.role;
|
||||
accountability.admin = payload.admin_access === true || payload.admin_access == 1;
|
||||
accountability.app = payload.app_access === true || payload.app_access == 1;
|
||||
|
||||
if (payload.share) accountability.share = payload.share;
|
||||
if (payload.share_scope) accountability.share_scope = payload.share_scope;
|
||||
if (payload.id) accountability.user = payload.id;
|
||||
} else {
|
||||
// Try finding the user with the provided token
|
||||
const database = getDatabase();
|
||||
|
||||
const user = await database
|
||||
.select('directus_users.id', 'directus_users.role', 'directus_roles.admin_access', 'directus_roles.app_access')
|
||||
.from('directus_users')
|
||||
.leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
|
||||
.where({
|
||||
'directus_users.token': token,
|
||||
status: 'active',
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!user) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
accountability.user = user.id;
|
||||
accountability.role = user.role;
|
||||
accountability.admin = user.admin_access === true || user.admin_access == 1;
|
||||
accountability.app = user.app_access === true || user.app_access == 1;
|
||||
}
|
||||
}
|
||||
|
||||
return accountability;
|
||||
}
|
||||
69
api/src/utils/get-service.ts
Normal file
69
api/src/utils/get-service.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
ActivityService,
|
||||
DashboardsService,
|
||||
FilesService,
|
||||
FlowsService,
|
||||
FoldersService,
|
||||
ItemsService,
|
||||
NotificationsService,
|
||||
OperationsService,
|
||||
PanelsService,
|
||||
PermissionsService,
|
||||
PresetsService,
|
||||
RevisionsService,
|
||||
RolesService,
|
||||
SettingsService,
|
||||
SharesService,
|
||||
UsersService,
|
||||
WebhooksService,
|
||||
} from '../index.js';
|
||||
import type { AbstractServiceOptions } from '../types/services.js';
|
||||
|
||||
/**
|
||||
* Select the correct service for the given collection. This allows the individual services to run
|
||||
* their custom checks (f.e. it allows UsersService to prevent updating TFA secret from outside)
|
||||
*/
|
||||
export function getService(collection: string, opts: AbstractServiceOptions): ItemsService {
|
||||
switch (collection) {
|
||||
case 'directus_activity':
|
||||
return new ActivityService(opts);
|
||||
// case 'directus_collections':
|
||||
// return new CollectionsService(opts);
|
||||
case 'directus_dashboards':
|
||||
return new DashboardsService(opts);
|
||||
// case 'directus_fields':
|
||||
// return new FieldsService(opts);
|
||||
case 'directus_files':
|
||||
return new FilesService(opts);
|
||||
case 'directus_flows':
|
||||
return new FlowsService(opts);
|
||||
case 'directus_folders':
|
||||
return new FoldersService(opts);
|
||||
case 'directus_notifications':
|
||||
return new NotificationsService(opts);
|
||||
case 'directus_operations':
|
||||
return new OperationsService(opts);
|
||||
case 'directus_panels':
|
||||
return new PanelsService(opts);
|
||||
case 'directus_permissions':
|
||||
return new PermissionsService(opts);
|
||||
case 'directus_presets':
|
||||
return new PresetsService(opts);
|
||||
// case 'directus_relations':
|
||||
// return new RelationsService(opts);
|
||||
case 'directus_revisions':
|
||||
return new RevisionsService(opts);
|
||||
case 'directus_roles':
|
||||
return new RolesService(opts);
|
||||
case 'directus_settings':
|
||||
return new SettingsService(opts);
|
||||
case 'directus_shares':
|
||||
return new SharesService(opts);
|
||||
case 'directus_users':
|
||||
return new UsersService(opts);
|
||||
case 'directus_webhooks':
|
||||
return new WebhooksService(opts);
|
||||
default:
|
||||
return new ItemsService(collection, opts);
|
||||
}
|
||||
}
|
||||
16
api/src/utils/to-boolean.test.ts
Normal file
16
api/src/utils/to-boolean.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { toBoolean } from './to-boolean.js';
|
||||
|
||||
test.each([
|
||||
['true', true],
|
||||
[true, true],
|
||||
['1', true],
|
||||
[1, true],
|
||||
['false', false],
|
||||
['anything', false],
|
||||
[123, false],
|
||||
[{}, false],
|
||||
[['{}'], false],
|
||||
])('toBoolean(%s) -> %s', (value, expected) => {
|
||||
expect(toBoolean(value)).toBe(expected);
|
||||
});
|
||||
6
api/src/utils/to-boolean.ts
Normal file
6
api/src/utils/to-boolean.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Convert environment variable to Boolean
|
||||
*/
|
||||
export function toBoolean(value: any): boolean {
|
||||
return value === 'true' || value === true || value === '1' || value === 1;
|
||||
}
|
||||
137
api/src/websocket/authenticate.test.ts
Normal file
137
api/src/websocket/authenticate.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { Accountability } from '@directus/types';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
import { InvalidCredentialsException } from '../index.js';
|
||||
import { getAccountabilityForRole } from '../utils/get-accountability-for-role.js';
|
||||
import { getAccountabilityForToken } from '../utils/get-accountability-for-token.js';
|
||||
import { authenticateConnection, authenticationSuccess, refreshAccountability } from './authenticate.js';
|
||||
import type { WebSocketAuthMessage } from './messages.js';
|
||||
import { getExpiresAtForToken } from './utils/get-expires-at-for-token.js';
|
||||
|
||||
vi.mock('../utils/get-accountability-for-token', () => ({
|
||||
getAccountabilityForToken: vi.fn().mockReturnValue({
|
||||
role: null, // minimum viable accountability
|
||||
} as Accountability),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/get-accountability-for-role', () => ({
|
||||
getAccountabilityForRole: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./utils/get-expires-at-for-token', () => ({
|
||||
getExpiresAtForToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/get-schema');
|
||||
|
||||
vi.mock('../services/authentication', () => ({
|
||||
AuthenticationService: vi.fn(() => ({
|
||||
login: vi.fn().mockReturnValue({ accessToken: '123', refreshToken: 'refresh', expires: 123456 }),
|
||||
refresh: vi.fn().mockReturnValue({ accessToken: '456', refreshToken: 'refresh' }),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../database');
|
||||
|
||||
describe('authenticateConnection', () => {
|
||||
test('Success with email/password', async () => {
|
||||
const TIMESTAMP = 123456789;
|
||||
(getExpiresAtForToken as Mock).mockReturnValue(TIMESTAMP);
|
||||
|
||||
const result = await authenticateConnection({
|
||||
type: 'auth',
|
||||
email: 'email',
|
||||
password: 'password',
|
||||
} as WebSocketAuthMessage);
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
accountability: { role: null },
|
||||
expires_at: TIMESTAMP,
|
||||
refresh_token: 'refresh',
|
||||
});
|
||||
});
|
||||
|
||||
test('Success with refresh_token', async () => {
|
||||
const TIMESTAMP = 987654;
|
||||
(getExpiresAtForToken as Mock).mockReturnValue(TIMESTAMP);
|
||||
|
||||
const result = await authenticateConnection({
|
||||
type: 'auth',
|
||||
refresh_token: 'refresh_token',
|
||||
} as WebSocketAuthMessage);
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
accountability: { role: null },
|
||||
expires_at: TIMESTAMP,
|
||||
refresh_token: 'refresh',
|
||||
});
|
||||
});
|
||||
|
||||
test('Success with access_token', async () => {
|
||||
const TIMESTAMP = 456987;
|
||||
(getExpiresAtForToken as Mock).mockReturnValue(TIMESTAMP);
|
||||
|
||||
const result = await authenticateConnection({
|
||||
type: 'auth',
|
||||
access_token: 'access_token',
|
||||
} as WebSocketAuthMessage);
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
accountability: { role: null },
|
||||
expires_at: TIMESTAMP,
|
||||
refresh_token: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('Failure token expired', async () => {
|
||||
(getAccountabilityForToken as Mock).mockImplementation(() => {
|
||||
throw new InvalidCredentialsException('Token expired.');
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
authenticateConnection({
|
||||
type: 'auth',
|
||||
access_token: 'expired',
|
||||
} as WebSocketAuthMessage)
|
||||
).rejects.toThrow('Token expired.');
|
||||
});
|
||||
|
||||
test('Failure authentication failed', async () => {
|
||||
expect(() =>
|
||||
authenticateConnection({
|
||||
type: 'auth',
|
||||
access_token: '',
|
||||
} as WebSocketAuthMessage)
|
||||
).rejects.toThrow('Authentication failed.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshAccountability', () => {
|
||||
test('should just work', async () => {
|
||||
(getAccountabilityForRole as Mock).mockReturnValue({
|
||||
role: '123-456-789',
|
||||
} as Accountability);
|
||||
|
||||
const result = await refreshAccountability({
|
||||
role: null,
|
||||
user: 'abc-def-ghi',
|
||||
});
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
role: '123-456-789',
|
||||
user: 'abc-def-ghi',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('authenticationSuccess', () => {
|
||||
test('without uid', async () => {
|
||||
const result = authenticationSuccess();
|
||||
expect(result).toBe('{"type":"auth","status":"ok"}');
|
||||
});
|
||||
|
||||
test('with uid', async () => {
|
||||
const result = authenticationSuccess('123456');
|
||||
expect(result).toBe('{"type":"auth","status":"ok","uid":"123456"}');
|
||||
});
|
||||
});
|
||||
79
api/src/websocket/authenticate.ts
Normal file
79
api/src/websocket/authenticate.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { Accountability } from '@directus/types';
|
||||
import { DEFAULT_AUTH_PROVIDER } from '../constants.js';
|
||||
import getDatabase from '../database/index.js';
|
||||
import { InvalidCredentialsException } from '../exceptions/index.js';
|
||||
import { AuthenticationService } from '../services/index.js';
|
||||
import { getAccountabilityForRole } from '../utils/get-accountability-for-role.js';
|
||||
import { getAccountabilityForToken } from '../utils/get-accountability-for-token.js';
|
||||
import { getSchema } from '../utils/get-schema.js';
|
||||
import { WebSocketException } from './exceptions.js';
|
||||
import type { BasicAuthMessage, WebSocketResponse } from './messages.js';
|
||||
import type { AuthenticationState } from './types.js';
|
||||
import { getExpiresAtForToken } from './utils/get-expires-at-for-token.js';
|
||||
|
||||
export async function authenticateConnection(
|
||||
message: BasicAuthMessage & Record<string, any>
|
||||
): Promise<AuthenticationState> {
|
||||
let access_token: string | undefined, refresh_token: string | undefined;
|
||||
|
||||
try {
|
||||
if ('email' in message && 'password' in message) {
|
||||
const authenticationService = new AuthenticationService({ schema: await getSchema() });
|
||||
const { accessToken, refreshToken } = await authenticationService.login(DEFAULT_AUTH_PROVIDER, message);
|
||||
access_token = accessToken;
|
||||
refresh_token = refreshToken;
|
||||
}
|
||||
|
||||
if ('refresh_token' in message) {
|
||||
const authenticationService = new AuthenticationService({ schema: await getSchema() });
|
||||
const { accessToken, refreshToken } = await authenticationService.refresh(message.refresh_token);
|
||||
access_token = accessToken;
|
||||
refresh_token = refreshToken;
|
||||
}
|
||||
|
||||
if ('access_token' in message) {
|
||||
access_token = message.access_token;
|
||||
}
|
||||
|
||||
if (!access_token) throw new Error();
|
||||
const accountability = await getAccountabilityForToken(access_token);
|
||||
const expires_at = getExpiresAtForToken(access_token);
|
||||
return { accountability, expires_at, refresh_token } as AuthenticationState;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidCredentialsException && error.message === 'Token expired.') {
|
||||
throw new WebSocketException('auth', 'TOKEN_EXPIRED', 'Token expired.', message['uid']);
|
||||
}
|
||||
|
||||
throw new WebSocketException('auth', 'AUTH_FAILED', 'Authentication failed.', message['uid']);
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshAccountability(
|
||||
accountability: Accountability | null | undefined
|
||||
): Promise<Accountability> {
|
||||
const result: Accountability = await getAccountabilityForRole(accountability?.role || null, {
|
||||
accountability: accountability || null,
|
||||
schema: await getSchema(),
|
||||
database: getDatabase(),
|
||||
});
|
||||
|
||||
result.user = accountability?.user || null;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function authenticationSuccess(uid?: string | number, refresh_token?: string): string {
|
||||
const message: WebSocketResponse = {
|
||||
type: 'auth',
|
||||
status: 'ok',
|
||||
};
|
||||
|
||||
if (uid !== undefined) {
|
||||
message.uid = uid;
|
||||
}
|
||||
|
||||
if (refresh_token !== undefined) {
|
||||
message['refresh_token'] = refresh_token;
|
||||
}
|
||||
|
||||
return JSON.stringify(message);
|
||||
}
|
||||
344
api/src/websocket/controllers/base.ts
Normal file
344
api/src/websocket/controllers/base.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import type { Accountability } from '@directus/types';
|
||||
import { parseJSON } from '@directus/utils';
|
||||
import type { IncomingMessage, Server as httpServer } from 'http';
|
||||
import type { ParsedUrlQuery } from 'querystring';
|
||||
import type { RateLimiterAbstract } from 'rate-limiter-flexible';
|
||||
import type internal from 'stream';
|
||||
import { parse } from 'url';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import { fromZodError } from 'zod-validation-error';
|
||||
import emitter from '../../emitter.js';
|
||||
import env from '../../env.js';
|
||||
import { InvalidConfigException, TokenExpiredException } from '../../exceptions/index.js';
|
||||
import logger from '../../logger.js';
|
||||
import { createRateLimiter } from '../../rate-limiter.js';
|
||||
import { getAccountabilityForToken } from '../../utils/get-accountability-for-token.js';
|
||||
import { toBoolean } from '../../utils/to-boolean.js';
|
||||
import { authenticateConnection, authenticationSuccess } from '../authenticate.js';
|
||||
import { WebSocketException, handleWebSocketException } from '../exceptions.js';
|
||||
import { AuthMode, WebSocketAuthMessage, WebSocketMessage } from '../messages.js';
|
||||
import type { AuthenticationState, UpgradeContext, WebSocketClient } from '../types.js';
|
||||
import { getExpiresAtForToken } from '../utils/get-expires-at-for-token.js';
|
||||
import { getMessageType } from '../utils/message.js';
|
||||
import { waitForAnyMessage, waitForMessageType } from '../utils/wait-for-message.js';
|
||||
import { registerWebSocketEvents } from './hooks.js';
|
||||
|
||||
const TOKEN_CHECK_INTERVAL = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
export default abstract class SocketController {
|
||||
server: WebSocket.Server;
|
||||
clients: Set<WebSocketClient>;
|
||||
authentication: {
|
||||
mode: AuthMode;
|
||||
timeout: number;
|
||||
};
|
||||
|
||||
endpoint: string;
|
||||
maxConnections: number;
|
||||
private rateLimiter: RateLimiterAbstract | null;
|
||||
private authInterval: NodeJS.Timer | null;
|
||||
|
||||
constructor(httpServer: httpServer, configPrefix: string) {
|
||||
this.server = new WebSocketServer({ noServer: true });
|
||||
this.clients = new Set();
|
||||
this.authInterval = null;
|
||||
|
||||
const { endpoint, authentication, maxConnections } = this.getEnvironmentConfig(configPrefix);
|
||||
this.endpoint = endpoint;
|
||||
this.authentication = authentication;
|
||||
this.maxConnections = maxConnections;
|
||||
this.rateLimiter = this.getRateLimiter();
|
||||
|
||||
httpServer.on('upgrade', this.handleUpgrade.bind(this));
|
||||
this.checkClientTokens();
|
||||
registerWebSocketEvents();
|
||||
}
|
||||
|
||||
protected getEnvironmentConfig(configPrefix: string): {
|
||||
endpoint: string;
|
||||
authentication: {
|
||||
mode: AuthMode;
|
||||
timeout: number;
|
||||
};
|
||||
maxConnections: number;
|
||||
} {
|
||||
const endpoint = String(env[`${configPrefix}_PATH`]);
|
||||
const authMode = AuthMode.safeParse(String(env[`${configPrefix}_AUTH`]).toLowerCase());
|
||||
const authTimeout = Number(env[`${configPrefix}_AUTH_TIMEOUT`]) * 1000;
|
||||
|
||||
const maxConnections =
|
||||
`${configPrefix}_CONN_LIMIT` in env ? Number(env[`${configPrefix}_CONN_LIMIT`]) : Number.POSITIVE_INFINITY;
|
||||
|
||||
if (!authMode.success) {
|
||||
throw new InvalidConfigException(fromZodError(authMode.error, { prefix: `${configPrefix}_AUTH` }).message);
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
maxConnections,
|
||||
authentication: {
|
||||
mode: authMode.data,
|
||||
timeout: authTimeout,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected getRateLimiter() {
|
||||
if (toBoolean(env['RATE_LIMITER_ENABLED']) === true) {
|
||||
return createRateLimiter('RATE_LIMITER', {
|
||||
keyPrefix: 'websocket',
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected async handleUpgrade(request: IncomingMessage, socket: internal.Duplex, head: Buffer) {
|
||||
const { pathname, query } = parse(request.url!, true);
|
||||
if (pathname !== this.endpoint) return;
|
||||
|
||||
if (this.clients.size >= this.maxConnections) {
|
||||
logger.debug('WebSocket upgrade denied - max connections reached');
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const context: UpgradeContext = { request, socket, head };
|
||||
|
||||
if (this.authentication.mode === 'strict') {
|
||||
await this.handleStrictUpgrade(context, query);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.authentication.mode === 'handshake') {
|
||||
await this.handleHandshakeUpgrade(context);
|
||||
return;
|
||||
}
|
||||
|
||||
this.server.handleUpgrade(request, socket, head, async (ws) => {
|
||||
const state = { accountability: null, expires_at: null } as AuthenticationState;
|
||||
this.server.emit('connection', ws, state);
|
||||
});
|
||||
}
|
||||
|
||||
protected async handleStrictUpgrade({ request, socket, head }: UpgradeContext, query: ParsedUrlQuery) {
|
||||
let accountability: Accountability | null, expires_at: number | null;
|
||||
|
||||
try {
|
||||
const token = query['access_token'] as string;
|
||||
accountability = await getAccountabilityForToken(token);
|
||||
expires_at = getExpiresAtForToken(token);
|
||||
} catch {
|
||||
accountability = null;
|
||||
expires_at = null;
|
||||
}
|
||||
|
||||
if (!accountability || !accountability.user) {
|
||||
logger.debug('WebSocket upgrade denied - ' + JSON.stringify(accountability || 'invalid'));
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
this.server.handleUpgrade(request, socket, head, async (ws) => {
|
||||
const state = { accountability, expires_at } as AuthenticationState;
|
||||
this.server.emit('connection', ws, state);
|
||||
});
|
||||
}
|
||||
|
||||
protected async handleHandshakeUpgrade({ request, socket, head }: UpgradeContext) {
|
||||
this.server.handleUpgrade(request, socket, head, async (ws) => {
|
||||
try {
|
||||
const payload = await waitForAnyMessage(ws, this.authentication.timeout);
|
||||
if (getMessageType(payload) !== 'auth') throw new Error();
|
||||
|
||||
const state = await authenticateConnection(WebSocketAuthMessage.parse(payload));
|
||||
ws.send(authenticationSuccess(payload['uid'], state.refresh_token));
|
||||
this.server.emit('connection', ws, state);
|
||||
} catch {
|
||||
logger.debug('WebSocket authentication handshake failed');
|
||||
const error = new WebSocketException('auth', 'AUTH_FAILED', 'Authentication handshake failed.');
|
||||
handleWebSocketException(ws, error, 'auth');
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createClient(ws: WebSocket, { accountability, expires_at }: AuthenticationState) {
|
||||
const client = ws as WebSocketClient;
|
||||
client.accountability = accountability;
|
||||
client.expires_at = expires_at;
|
||||
client.uid = uuid();
|
||||
client.auth_timer = null;
|
||||
|
||||
ws.on('message', async (data: WebSocket.RawData) => {
|
||||
if (this.rateLimiter !== null) {
|
||||
try {
|
||||
await this.rateLimiter.consume(client.uid);
|
||||
} catch (limit) {
|
||||
const timeout = (limit as any)?.msBeforeNext ?? this.rateLimiter.msDuration;
|
||||
|
||||
const error = new WebSocketException(
|
||||
'server',
|
||||
'REQUESTS_EXCEEDED',
|
||||
`Too many messages, retry after ${timeout}ms.`
|
||||
);
|
||||
|
||||
handleWebSocketException(client, error, 'server');
|
||||
logger.debug(`WebSocket#${client.uid} is rate limited`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let message: WebSocketMessage;
|
||||
|
||||
try {
|
||||
message = this.parseMessage(data.toString());
|
||||
} catch (err: any) {
|
||||
handleWebSocketException(client, err, 'server');
|
||||
return;
|
||||
}
|
||||
|
||||
if (getMessageType(message) === 'auth') {
|
||||
try {
|
||||
await this.handleAuthRequest(client, WebSocketAuthMessage.parse(message));
|
||||
} catch {
|
||||
// ignore errors
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// this log cannot be higher in the function or it will leak credentials
|
||||
logger.trace(`WebSocket#${client.uid} - ${JSON.stringify(message)}`);
|
||||
ws.emit('parsed-message', message);
|
||||
});
|
||||
|
||||
ws.on('error', () => {
|
||||
logger.debug(`WebSocket#${client.uid} connection errored`);
|
||||
|
||||
if (client.auth_timer) {
|
||||
clearTimeout(client.auth_timer);
|
||||
client.auth_timer = null;
|
||||
}
|
||||
|
||||
this.clients.delete(client);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
logger.debug(`WebSocket#${client.uid} connection closed`);
|
||||
|
||||
if (client.auth_timer) {
|
||||
clearTimeout(client.auth_timer);
|
||||
client.auth_timer = null;
|
||||
}
|
||||
|
||||
this.clients.delete(client);
|
||||
});
|
||||
|
||||
logger.debug(`WebSocket#${client.uid} connected`);
|
||||
|
||||
if (accountability) {
|
||||
logger.trace(`WebSocket#${client.uid} authenticated as ${JSON.stringify(accountability)}`);
|
||||
}
|
||||
|
||||
this.setTokenExpireTimer(client);
|
||||
this.clients.add(client);
|
||||
return client;
|
||||
}
|
||||
|
||||
protected parseMessage(data: string): WebSocketMessage {
|
||||
let message: WebSocketMessage;
|
||||
|
||||
try {
|
||||
message = WebSocketMessage.parse(parseJSON(data));
|
||||
} catch (err: any) {
|
||||
throw new WebSocketException('server', 'INVALID_PAYLOAD', 'Unable to parse the incoming message.');
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
protected async handleAuthRequest(client: WebSocketClient, message: WebSocketAuthMessage) {
|
||||
try {
|
||||
const { accountability, expires_at, refresh_token } = await authenticateConnection(message);
|
||||
client.accountability = accountability;
|
||||
client.expires_at = expires_at;
|
||||
this.setTokenExpireTimer(client);
|
||||
emitter.emitAction('websocket.auth.success', { client });
|
||||
client.send(authenticationSuccess(message.uid, refresh_token));
|
||||
logger.trace(`WebSocket#${client.uid} authenticated as ${JSON.stringify(client.accountability)}`);
|
||||
} catch (error) {
|
||||
logger.trace(`WebSocket#${client.uid} failed authentication`);
|
||||
emitter.emitAction('websocket.auth.failure', { client });
|
||||
|
||||
client.accountability = null;
|
||||
client.expires_at = null;
|
||||
|
||||
const _error =
|
||||
error instanceof WebSocketException
|
||||
? error
|
||||
: new WebSocketException('auth', 'AUTH_FAILED', 'Authentication failed.', message.uid);
|
||||
|
||||
handleWebSocketException(client, _error, 'auth');
|
||||
|
||||
if (this.authentication.mode !== 'public') {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTokenExpireTimer(client: WebSocketClient) {
|
||||
if (client.auth_timer !== null) {
|
||||
// clear up old timeouts if needed
|
||||
clearTimeout(client.auth_timer);
|
||||
client.auth_timer = null;
|
||||
}
|
||||
|
||||
if (!client.expires_at) return;
|
||||
|
||||
const expiresIn = client.expires_at * 1000 - Date.now();
|
||||
if (expiresIn > TOKEN_CHECK_INTERVAL) return;
|
||||
|
||||
client.auth_timer = setTimeout(() => {
|
||||
client.accountability = null;
|
||||
client.expires_at = null;
|
||||
handleWebSocketException(client, new TokenExpiredException(), 'auth');
|
||||
|
||||
waitForMessageType(client, 'auth', this.authentication.timeout).catch((msg: WebSocketMessage) => {
|
||||
const error = new WebSocketException('auth', 'AUTH_TIMEOUT', 'Authentication timed out.', msg?.uid);
|
||||
handleWebSocketException(client, error, 'auth');
|
||||
|
||||
if (this.authentication.mode !== 'public') {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
}, expiresIn);
|
||||
}
|
||||
|
||||
checkClientTokens() {
|
||||
this.authInterval = setInterval(() => {
|
||||
if (this.clients.size === 0) return;
|
||||
|
||||
// check the clients and set shorter timeouts if needed
|
||||
for (const client of this.clients) {
|
||||
if (client.expires_at === null || client.auth_timer !== null) continue;
|
||||
this.setTokenExpireTimer(client);
|
||||
}
|
||||
}, TOKEN_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
terminate() {
|
||||
if (this.authInterval) clearInterval(this.authInterval);
|
||||
|
||||
this.clients.forEach((client) => {
|
||||
if (client.auth_timer) clearTimeout(client.auth_timer);
|
||||
});
|
||||
|
||||
this.server.clients.forEach((ws) => {
|
||||
ws.terminate();
|
||||
});
|
||||
}
|
||||
}
|
||||
121
api/src/websocket/controllers/graphql.ts
Normal file
121
api/src/websocket/controllers/graphql.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { CloseCode, MessageType, makeServer } from 'graphql-ws';
|
||||
import type { Server } from 'graphql-ws';
|
||||
import type { Server as httpServer } from 'http';
|
||||
import type { WebSocket } from 'ws';
|
||||
import env from '../../env.js';
|
||||
import logger from '../../logger.js';
|
||||
import { bindPubSub } from '../../services/graphql/subscription.js';
|
||||
import { GraphQLService } from '../../services/index.js';
|
||||
import { getSchema } from '../../utils/get-schema.js';
|
||||
import { authenticateConnection, refreshAccountability } from '../authenticate.js';
|
||||
import { handleWebSocketException } from '../exceptions.js';
|
||||
import { ConnectionParams, WebSocketMessage } from '../messages.js';
|
||||
import type { AuthenticationState, GraphQLSocket, UpgradeContext, WebSocketClient } from '../types.js';
|
||||
import { getMessageType } from '../utils/message.js';
|
||||
import SocketController from './base.js';
|
||||
|
||||
export class GraphQLSubscriptionController extends SocketController {
|
||||
gql: Server<GraphQLSocket>;
|
||||
constructor(httpServer: httpServer) {
|
||||
super(httpServer, 'WEBSOCKETS_GRAPHQL');
|
||||
|
||||
this.server.on('connection', (ws: WebSocket, auth: AuthenticationState) => {
|
||||
this.bindEvents(this.createClient(ws, auth));
|
||||
});
|
||||
|
||||
this.gql = makeServer<ConnectionParams, GraphQLSocket>({
|
||||
schema: async (ctx) => {
|
||||
const accountability = ctx.extra.client.accountability;
|
||||
|
||||
// for now only the items will be watched, system events tbd
|
||||
const service = new GraphQLService({
|
||||
schema: await getSchema(),
|
||||
scope: 'items',
|
||||
accountability,
|
||||
});
|
||||
|
||||
return service.getSchema();
|
||||
},
|
||||
});
|
||||
|
||||
bindPubSub();
|
||||
logger.info(`GraphQL Subscriptions started at ws://${env['HOST']}:${env['PORT']}${this.endpoint}`);
|
||||
}
|
||||
|
||||
private bindEvents(client: WebSocketClient) {
|
||||
const closedHandler = this.gql.opened(
|
||||
{
|
||||
protocol: client.protocol,
|
||||
send: (data) =>
|
||||
new Promise((resolve, reject) => {
|
||||
client.send(data, (err) => (err ? reject(err) : resolve()));
|
||||
}),
|
||||
close: (code, reason) => client.close(code, reason), // for standard closures
|
||||
onMessage: (cb) => {
|
||||
client.on('parsed-message', async (message: WebSocketMessage) => {
|
||||
try {
|
||||
if (getMessageType(message) === 'connection_init' && this.authentication.mode !== 'strict') {
|
||||
const params = ConnectionParams.parse(message['payload']);
|
||||
|
||||
if (this.authentication.mode === 'handshake') {
|
||||
if (typeof params.access_token === 'string') {
|
||||
const { accountability, expires_at } = await authenticateConnection({
|
||||
access_token: params.access_token,
|
||||
});
|
||||
|
||||
client.accountability = accountability;
|
||||
client.expires_at = expires_at;
|
||||
} else {
|
||||
client.close(CloseCode.Forbidden, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (this.authentication.mode === 'handshake' && !client.accountability?.user) {
|
||||
// the first message should authenticate successfully in this mode
|
||||
client.close(CloseCode.Forbidden, 'Forbidden');
|
||||
return;
|
||||
} else {
|
||||
client.accountability = await refreshAccountability(client.accountability);
|
||||
}
|
||||
|
||||
await cb(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
handleWebSocketException(client, error, MessageType.Error);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
{ client }
|
||||
);
|
||||
|
||||
// notify server that the socket closed
|
||||
client.once('close', (code, reason) => closedHandler(code, reason.toString()));
|
||||
|
||||
// check strict authentication status
|
||||
if (this.authentication.mode === 'strict' && !client.accountability?.user) {
|
||||
client.close(CloseCode.Forbidden, 'Forbidden');
|
||||
}
|
||||
}
|
||||
|
||||
override setTokenExpireTimer(client: WebSocketClient) {
|
||||
if (client.auth_timer !== null) {
|
||||
clearTimeout(client.auth_timer);
|
||||
client.auth_timer = null;
|
||||
}
|
||||
|
||||
if (this.authentication.mode !== 'handshake') return;
|
||||
|
||||
client.auth_timer = setTimeout(() => {
|
||||
if (!client.accountability?.user) {
|
||||
client.close(CloseCode.Forbidden, 'Forbidden');
|
||||
}
|
||||
}, this.authentication.timeout);
|
||||
}
|
||||
|
||||
protected override async handleHandshakeUpgrade({ request, socket, head }: UpgradeContext) {
|
||||
this.server.handleUpgrade(request, socket, head, async (ws) => {
|
||||
this.server.emit('connection', ws, { accountability: null, expires_at: null });
|
||||
// actual enforcement is handled by the setTokenExpireTimer function
|
||||
});
|
||||
}
|
||||
}
|
||||
140
api/src/websocket/controllers/hooks.ts
Normal file
140
api/src/websocket/controllers/hooks.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import emitter from '../../emitter.js';
|
||||
import { getMessenger } from '../../messenger.js';
|
||||
import type { WebSocketEvent } from '../messages.js';
|
||||
|
||||
let actionsRegistered = false;
|
||||
|
||||
export function registerWebSocketEvents() {
|
||||
if (actionsRegistered) return;
|
||||
actionsRegistered = true;
|
||||
|
||||
registerActionHooks([
|
||||
'items',
|
||||
'activity',
|
||||
'collections',
|
||||
'folders',
|
||||
'permissions',
|
||||
'presets',
|
||||
'revisions',
|
||||
'roles',
|
||||
'settings',
|
||||
'users',
|
||||
'webhooks',
|
||||
]);
|
||||
|
||||
registerFieldsHooks();
|
||||
registerFilesHooks();
|
||||
registerRelationsHooks();
|
||||
}
|
||||
|
||||
function registerActionHooks(modules: string[]) {
|
||||
// register event hooks that can be handled in an uniform manner
|
||||
for (const module of modules) {
|
||||
registerAction(module + '.create', ({ key, collection, payload = {} }) => ({
|
||||
collection,
|
||||
action: 'create',
|
||||
key,
|
||||
payload,
|
||||
}));
|
||||
|
||||
registerAction(module + '.update', ({ keys, collection, payload = {} }) => ({
|
||||
collection,
|
||||
action: 'update',
|
||||
keys,
|
||||
payload,
|
||||
}));
|
||||
|
||||
registerAction(module + '.delete', ({ keys, collection, payload = [] }) => ({
|
||||
collection,
|
||||
action: 'delete',
|
||||
keys,
|
||||
payload,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function registerFieldsHooks() {
|
||||
// exception for field hooks that don't report `directus_fields` as being the collection
|
||||
registerAction('fields.create', ({ key, payload = {} }) => ({
|
||||
collection: 'directus_fields',
|
||||
action: 'create',
|
||||
key,
|
||||
payload,
|
||||
}));
|
||||
|
||||
registerAction('fields.update', ({ keys, payload = {} }) => ({
|
||||
collection: 'directus_fields',
|
||||
action: 'update',
|
||||
keys,
|
||||
payload,
|
||||
}));
|
||||
|
||||
registerAction('fields.delete', ({ keys, payload = [] }) => ({
|
||||
collection: 'directus_fields',
|
||||
action: 'delete',
|
||||
keys,
|
||||
payload,
|
||||
}));
|
||||
}
|
||||
|
||||
function registerFilesHooks() {
|
||||
// extra event for file uploads that doubles as create event
|
||||
registerAction('files.upload', ({ key, collection, payload = {} }) => ({
|
||||
collection,
|
||||
action: 'create',
|
||||
key,
|
||||
payload,
|
||||
}));
|
||||
|
||||
registerAction('files.update', ({ keys, collection, payload = {} }) => ({
|
||||
collection,
|
||||
action: 'update',
|
||||
keys,
|
||||
payload,
|
||||
}));
|
||||
|
||||
registerAction('files.delete', ({ keys, collection, payload = [] }) => ({
|
||||
collection,
|
||||
action: 'delete',
|
||||
keys,
|
||||
payload,
|
||||
}));
|
||||
}
|
||||
|
||||
function registerRelationsHooks() {
|
||||
// exception for relation hooks that don't report `directus_relations` as being the collection
|
||||
registerAction('relations.create', ({ key, payload = {} }) => ({
|
||||
collection: 'directus_relations',
|
||||
action: 'create',
|
||||
key,
|
||||
payload: { ...payload, key },
|
||||
}));
|
||||
|
||||
registerAction('relations.update', ({ keys, payload = {} }) => ({
|
||||
collection: 'directus_relations',
|
||||
action: 'update',
|
||||
keys,
|
||||
payload,
|
||||
}));
|
||||
|
||||
registerAction('relations.delete', ({ collection, payload = [] }) => ({
|
||||
collection: 'directus_relations',
|
||||
action: 'delete',
|
||||
keys: payload,
|
||||
payload: { collection, fields: payload },
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for emitter.onAction to hook into system events
|
||||
* @param event The action event to watch
|
||||
* @param transform Transformer function
|
||||
*/
|
||||
function registerAction(event: string, transform: (args: Record<string, any>) => WebSocketEvent) {
|
||||
const messenger = getMessenger();
|
||||
|
||||
emitter.onAction(event, async (data: Record<string, any>) => {
|
||||
// push the event through the Redis pub/sub
|
||||
messenger.publish('websocket.event', transform(data) as Record<string, any>);
|
||||
});
|
||||
}
|
||||
44
api/src/websocket/controllers/index.ts
Normal file
44
api/src/websocket/controllers/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Server as httpServer } from 'http';
|
||||
import env from '../../env.js';
|
||||
import { ServiceUnavailableException } from '../../index.js';
|
||||
import { toBoolean } from '../../utils/to-boolean.js';
|
||||
import { GraphQLSubscriptionController } from './graphql.js';
|
||||
import { WebSocketController } from './rest.js';
|
||||
|
||||
let websocketController: WebSocketController | undefined;
|
||||
let subscriptionController: GraphQLSubscriptionController | undefined;
|
||||
|
||||
export function createWebSocketController(server: httpServer) {
|
||||
if (toBoolean(env['WEBSOCKETS_REST_ENABLED'])) {
|
||||
websocketController = new WebSocketController(server);
|
||||
}
|
||||
}
|
||||
|
||||
export function getWebSocketController() {
|
||||
if (!toBoolean(env['WEBSOCKETS_ENABLED']) || !toBoolean(env['WEBSOCKETS_REST_ENABLED'])) {
|
||||
throw new ServiceUnavailableException('WebSocket server is disabled', {
|
||||
service: 'get-websocket-controller',
|
||||
});
|
||||
}
|
||||
|
||||
if (!websocketController) {
|
||||
throw new ServiceUnavailableException('WebSocket server is not initialized', {
|
||||
service: 'get-websocket-controller',
|
||||
});
|
||||
}
|
||||
|
||||
return websocketController;
|
||||
}
|
||||
|
||||
export function createSubscriptionController(server: httpServer) {
|
||||
if (toBoolean(env['WEBSOCKETS_GRAPHQL_ENABLED'])) {
|
||||
subscriptionController = new GraphQLSubscriptionController(server);
|
||||
}
|
||||
}
|
||||
|
||||
export function getSubscriptionController() {
|
||||
return subscriptionController;
|
||||
}
|
||||
|
||||
export * from './graphql.js';
|
||||
export * from './rest.js';
|
||||
58
api/src/websocket/controllers/rest.ts
Normal file
58
api/src/websocket/controllers/rest.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { parseJSON } from '@directus/utils';
|
||||
import type { Server as httpServer } from 'http';
|
||||
import type WebSocket from 'ws';
|
||||
import emitter from '../../emitter.js';
|
||||
import env from '../../env.js';
|
||||
import logger from '../../logger.js';
|
||||
import { refreshAccountability } from '../authenticate.js';
|
||||
import { WebSocketException, handleWebSocketException } from '../exceptions.js';
|
||||
import { WebSocketMessage } from '../messages.js';
|
||||
import type { AuthenticationState, WebSocketClient } from '../types.js';
|
||||
import SocketController from './base.js';
|
||||
|
||||
export class WebSocketController extends SocketController {
|
||||
constructor(httpServer: httpServer) {
|
||||
super(httpServer, 'WEBSOCKETS_REST');
|
||||
|
||||
this.server.on('connection', (ws: WebSocket, auth: AuthenticationState) => {
|
||||
this.bindEvents(this.createClient(ws, auth));
|
||||
});
|
||||
|
||||
logger.info(`WebSocket Server started at ws://${env['HOST']}:${env['PORT']}${this.endpoint}`);
|
||||
}
|
||||
|
||||
private bindEvents(client: WebSocketClient) {
|
||||
client.on('parsed-message', async (message: WebSocketMessage) => {
|
||||
try {
|
||||
message = WebSocketMessage.parse(await emitter.emitFilter('websocket.message', message, { client }));
|
||||
client.accountability = await refreshAccountability(client.accountability);
|
||||
emitter.emitAction('websocket.message', { message, client });
|
||||
} catch (error) {
|
||||
handleWebSocketException(client, error, 'server');
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
client.on('error', (event: WebSocket.Event) => {
|
||||
emitter.emitAction('websocket.error', { client, event });
|
||||
});
|
||||
|
||||
client.on('close', (event: WebSocket.CloseEvent) => {
|
||||
emitter.emitAction('websocket.close', { client, event });
|
||||
});
|
||||
|
||||
emitter.emitAction('websocket.connect', { client });
|
||||
}
|
||||
|
||||
protected override parseMessage(data: string): WebSocketMessage {
|
||||
let message: WebSocketMessage;
|
||||
|
||||
try {
|
||||
message = parseJSON(data);
|
||||
} catch (err: any) {
|
||||
throw new WebSocketException('server', 'INVALID_PAYLOAD', 'Unable to parse the incoming message.');
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
106
api/src/websocket/exceptions.test.ts
Normal file
106
api/src/websocket/exceptions.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import type { WebSocketClient } from './types.js';
|
||||
import { BaseException } from '@directus/exceptions';
|
||||
import { InvalidPayloadException } from '../index.js';
|
||||
import { WebSocketException, handleWebSocketException } from './exceptions.js';
|
||||
import { ZodError } from 'zod';
|
||||
import logger from '../logger.js';
|
||||
|
||||
vi.mock('../logger');
|
||||
|
||||
function mockClient() {
|
||||
return {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
accountability: null,
|
||||
} as unknown as WebSocketClient;
|
||||
}
|
||||
|
||||
describe('WebSocketException', () => {
|
||||
test('with uid', () => {
|
||||
const error = new WebSocketException('type', 'code', 'message', 123);
|
||||
const response = error.toJSON();
|
||||
|
||||
expect(response).toStrictEqual({
|
||||
type: 'type',
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'code',
|
||||
message: 'message',
|
||||
},
|
||||
uid: 123,
|
||||
});
|
||||
|
||||
expect(error.toMessage()).toBe(JSON.stringify(response));
|
||||
});
|
||||
|
||||
test('without uid', () => {
|
||||
const error = new WebSocketException('type', 'code', 'message');
|
||||
const response = error.toJSON();
|
||||
|
||||
expect(response).toStrictEqual({
|
||||
type: 'type',
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'code',
|
||||
message: 'message',
|
||||
},
|
||||
});
|
||||
|
||||
expect(error.toMessage()).toBe(JSON.stringify(response));
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleWebSocketException', () => {
|
||||
const type = 'testing';
|
||||
|
||||
test('handle BaseException', () => {
|
||||
const client = mockClient();
|
||||
const error = new BaseException('test', 200, '123');
|
||||
const expected = WebSocketException.fromException(error, type).toMessage();
|
||||
handleWebSocketException(client, error, type);
|
||||
expect(client.send).toBeCalledWith(expected);
|
||||
expect(logger.error).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('handle InvalidPayloadException', () => {
|
||||
const client = mockClient();
|
||||
const error = new InvalidPayloadException('test');
|
||||
const expected = WebSocketException.fromException(error, type).toMessage();
|
||||
handleWebSocketException(client, error, type);
|
||||
expect(client.send).toBeCalledWith(expected);
|
||||
expect(logger.error).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('handle WebSocketException', () => {
|
||||
const client = mockClient();
|
||||
const error = new WebSocketException('type', 'code', 'message', 123);
|
||||
const expected = error.toMessage();
|
||||
handleWebSocketException(client, error, type);
|
||||
expect(client.send).toBeCalledWith(expected);
|
||||
expect(logger.error).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('handle ZodError', () => {
|
||||
const client = mockClient();
|
||||
|
||||
const error = new ZodError([
|
||||
{ message: 'test', code: 'invalid_type', path: ['path'], expected: 'array', received: 'string' },
|
||||
]);
|
||||
|
||||
const expected = WebSocketException.fromZodError(error, type).toMessage();
|
||||
handleWebSocketException(client, error, type);
|
||||
expect(client.send).toBeCalledWith(expected);
|
||||
expect(logger.error).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('unhandled exception', () => {
|
||||
const client = mockClient();
|
||||
const error = new Error('regular error');
|
||||
handleWebSocketException(client, error, type);
|
||||
expect(client.send).not.toBeCalled();
|
||||
expect(logger.error).toBeCalled();
|
||||
});
|
||||
});
|
||||
69
api/src/websocket/exceptions.ts
Normal file
69
api/src/websocket/exceptions.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { BaseException } from '@directus/exceptions';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { ZodError } from 'zod';
|
||||
import { fromZodError } from 'zod-validation-error';
|
||||
import logger from '../logger.js';
|
||||
import type { WebSocketResponse } from './messages.js';
|
||||
import type { WebSocketClient } from './types.js';
|
||||
|
||||
export class WebSocketException extends Error {
|
||||
type: string;
|
||||
code: string;
|
||||
uid: string | number | undefined;
|
||||
constructor(type: string, code: string, message: string, uid?: string | number) {
|
||||
super(message);
|
||||
this.type = type;
|
||||
this.code = code;
|
||||
this.uid = uid;
|
||||
}
|
||||
|
||||
toJSON(): WebSocketResponse {
|
||||
const message: WebSocketResponse = {
|
||||
type: this.type,
|
||||
status: 'error',
|
||||
error: {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
},
|
||||
};
|
||||
|
||||
if (this.uid !== undefined) {
|
||||
message.uid = this.uid;
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
toMessage(): string {
|
||||
return JSON.stringify(this.toJSON());
|
||||
}
|
||||
|
||||
static fromException(error: BaseException, type = 'unknown') {
|
||||
return new WebSocketException(type, error.code, error.message);
|
||||
}
|
||||
|
||||
static fromZodError(error: ZodError, type = 'unknown') {
|
||||
const zError = fromZodError(error);
|
||||
return new WebSocketException(type, 'INVALID_PAYLOAD', zError.message);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleWebSocketException(client: WebSocketClient | WebSocket, error: unknown, type?: string): void {
|
||||
if (error instanceof BaseException) {
|
||||
client.send(WebSocketException.fromException(error, type).toMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof WebSocketException) {
|
||||
client.send(error.toMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof ZodError) {
|
||||
client.send(WebSocketException.fromZodError(error, type).toMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
// unhandled exceptions
|
||||
logger.error(`WebSocket unhandled exception ${JSON.stringify({ type, error })}`);
|
||||
}
|
||||
98
api/src/websocket/handlers/heartbeat.test.ts
Normal file
98
api/src/websocket/handlers/heartbeat.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { EventContext } from '@directus/types';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
import emitter from '../../emitter.js';
|
||||
import { WebSocketController, getWebSocketController } from '../controllers/index.js';
|
||||
import type { WebSocketClient } from '../types.js';
|
||||
import { HeartbeatHandler } from './heartbeat.js';
|
||||
|
||||
// mocking
|
||||
vi.mock('../controllers', () => ({
|
||||
getWebSocketController: vi.fn(() => ({
|
||||
clients: new Set(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../env', async () => {
|
||||
const actual = (await vi.importActual('../../env')) as { default: Record<string, any> };
|
||||
|
||||
const MOCK_ENV = {
|
||||
...actual.default,
|
||||
WEBSOCKETS_HEARTBEAT_PERIOD: 1,
|
||||
};
|
||||
|
||||
return {
|
||||
default: MOCK_ENV,
|
||||
getEnv: () => MOCK_ENV,
|
||||
};
|
||||
});
|
||||
|
||||
function mockClient() {
|
||||
return {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
} as unknown as WebSocketClient;
|
||||
}
|
||||
|
||||
describe('WebSocket heartbeat handler', () => {
|
||||
let controller: WebSocketController;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
controller = getWebSocketController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('client should ping', async () => {
|
||||
// initialize handler
|
||||
new HeartbeatHandler(controller);
|
||||
// connect fake client
|
||||
const fakeClient = mockClient();
|
||||
|
||||
(fakeClient.send as Mock).mockImplementation(() => {
|
||||
//respond with a message
|
||||
emitter.emitAction('websocket.message', { client: fakeClient, message: { type: 'pong' } }, {} as EventContext);
|
||||
});
|
||||
|
||||
controller.clients.add(fakeClient);
|
||||
emitter.emitAction('websocket.connect', {}, {} as EventContext);
|
||||
// wait for ping
|
||||
vi.advanceTimersByTime(1000); // 1sec heartbeat interval
|
||||
expect(fakeClient.send).toBeCalled();
|
||||
// wait for another timeout
|
||||
vi.advanceTimersByTime(1000); // 1sec heartbeat interval
|
||||
expect(fakeClient.send).toBeCalled();
|
||||
// the connection should not have been closed
|
||||
expect(fakeClient.close).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('connection should be closed', async () => {
|
||||
// initialize handler
|
||||
new HeartbeatHandler(controller);
|
||||
// connect fake client
|
||||
const fakeClient = mockClient();
|
||||
controller.clients.add(fakeClient);
|
||||
emitter.emitAction('websocket.connect', {}, {} as EventContext);
|
||||
vi.advanceTimersByTime(2 * 1000); // 2x 1sec heartbeat interval
|
||||
expect(fakeClient.send).toBeCalled();
|
||||
// the connection should have been closed
|
||||
expect(fakeClient.close).toBeCalled();
|
||||
});
|
||||
|
||||
test('the server should pong if the client pings', async () => {
|
||||
// initialize handler
|
||||
new HeartbeatHandler(controller);
|
||||
// connect fake client
|
||||
const fakeClient = mockClient();
|
||||
controller.clients.add(fakeClient);
|
||||
emitter.emitAction('websocket.connect', {}, {} as EventContext);
|
||||
emitter.emitAction('websocket.message', { client: fakeClient, message: { type: 'ping' } }, {} as EventContext);
|
||||
expect(fakeClient.send).toBeCalled();
|
||||
});
|
||||
});
|
||||
87
api/src/websocket/handlers/heartbeat.ts
Normal file
87
api/src/websocket/handlers/heartbeat.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { ActionHandler } from '@directus/types';
|
||||
import emitter from '../../emitter.js';
|
||||
import env from '../../env.js';
|
||||
import { toBoolean } from '../../utils/to-boolean.js';
|
||||
import { WebSocketController, getWebSocketController } from '../controllers/index.js';
|
||||
import { WebSocketMessage } from '../messages.js';
|
||||
import type { WebSocketClient } from '../types.js';
|
||||
import { fmtMessage, getMessageType } from '../utils/message.js';
|
||||
|
||||
const HEARTBEAT_FREQUENCY = Number(env['WEBSOCKETS_HEARTBEAT_PERIOD']) * 1000;
|
||||
|
||||
export class HeartbeatHandler {
|
||||
private pulse: NodeJS.Timer | undefined;
|
||||
private controller: WebSocketController;
|
||||
|
||||
constructor(controller?: WebSocketController) {
|
||||
this.controller = controller ?? getWebSocketController();
|
||||
|
||||
emitter.onAction('websocket.message', ({ client, message }) => {
|
||||
try {
|
||||
this.onMessage(client, WebSocketMessage.parse(message));
|
||||
} catch {
|
||||
/* ignore errors */
|
||||
}
|
||||
});
|
||||
|
||||
if (toBoolean(env['WEBSOCKETS_HEARTBEAT_ENABLED']) === true) {
|
||||
emitter.onAction('websocket.connect', () => this.checkClients());
|
||||
emitter.onAction('websocket.error', () => this.checkClients());
|
||||
emitter.onAction('websocket.close', () => this.checkClients());
|
||||
}
|
||||
}
|
||||
|
||||
private checkClients() {
|
||||
const hasClients = this.controller.clients.size > 0;
|
||||
|
||||
if (hasClients && !this.pulse) {
|
||||
this.pulse = setInterval(() => {
|
||||
this.pingClients();
|
||||
}, HEARTBEAT_FREQUENCY);
|
||||
}
|
||||
|
||||
if (!hasClients && this.pulse) {
|
||||
clearInterval(this.pulse);
|
||||
this.pulse = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
onMessage(client: WebSocketClient, message: WebSocketMessage) {
|
||||
if (getMessageType(message) !== 'ping') return;
|
||||
// send pong message back as acknowledgement
|
||||
const data = 'uid' in message ? { uid: message.uid } : {};
|
||||
client.send(fmtMessage('pong', data));
|
||||
}
|
||||
|
||||
pingClients() {
|
||||
const pendingClients = new Set<WebSocketClient>(this.controller.clients);
|
||||
const activeClients = new Set<WebSocketClient>();
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
// close connections that haven't responded
|
||||
for (const client of pendingClients) {
|
||||
client.close();
|
||||
}
|
||||
}, HEARTBEAT_FREQUENCY);
|
||||
|
||||
const messageWatcher: ActionHandler = ({ client }) => {
|
||||
// any message means this connection is still open
|
||||
if (!activeClients.has(client)) {
|
||||
pendingClients.delete(client);
|
||||
activeClients.add(client);
|
||||
}
|
||||
|
||||
if (pendingClients.size === 0) {
|
||||
clearTimeout(timeout);
|
||||
emitter.offAction('websocket.message', messageWatcher);
|
||||
}
|
||||
};
|
||||
|
||||
emitter.onAction('websocket.message', messageWatcher);
|
||||
|
||||
// ping all the clients
|
||||
for (const client of pendingClients) {
|
||||
client.send(fmtMessage('ping'));
|
||||
}
|
||||
}
|
||||
}
|
||||
13
api/src/websocket/handlers/index.ts
Normal file
13
api/src/websocket/handlers/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { HeartbeatHandler } from './heartbeat.js';
|
||||
import { ItemsHandler } from './items.js';
|
||||
import { SubscribeHandler } from './subscribe.js';
|
||||
|
||||
export function startWebSocketHandlers() {
|
||||
new HeartbeatHandler();
|
||||
new ItemsHandler();
|
||||
new SubscribeHandler();
|
||||
}
|
||||
|
||||
export * from './heartbeat.js';
|
||||
export * from './items.js';
|
||||
export * from './subscribe.js';
|
||||
295
api/src/websocket/handlers/items.test.ts
Normal file
295
api/src/websocket/handlers/items.test.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import type { EventContext } from '@directus/types';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
import emitter from '../../emitter.js';
|
||||
import { ItemsService, MetaService } from '../../services/index.js';
|
||||
import { getSchema } from '../../utils/get-schema.js';
|
||||
import type { WebSocketClient } from '../types.js';
|
||||
import { ItemsHandler } from './items.js';
|
||||
|
||||
// mocking
|
||||
vi.mock('../controllers', () => ({
|
||||
getWebSocketController: vi.fn(() => ({
|
||||
clients: new Set(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/get-schema', () => ({
|
||||
getSchema: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services', () => ({
|
||||
ItemsService: vi.fn(),
|
||||
MetaService: vi.fn(),
|
||||
}));
|
||||
|
||||
function mockClient() {
|
||||
return {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
accountability: null,
|
||||
} as unknown as WebSocketClient;
|
||||
}
|
||||
|
||||
describe('WebSocket heartbeat handler', () => {
|
||||
let handler: ItemsHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// initialize handler
|
||||
handler = new ItemsHandler();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
emitter.offAll();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('ignore other message types', async () => {
|
||||
const spy = vi.spyOn(handler, 'onMessage');
|
||||
|
||||
// receive message
|
||||
emitter.emitAction(
|
||||
'websocket.message',
|
||||
{
|
||||
client: mockClient(),
|
||||
message: { type: 'pong' },
|
||||
},
|
||||
{} as EventContext
|
||||
);
|
||||
|
||||
// expect nothing
|
||||
expect(spy).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('invalid collection should error', async () => {
|
||||
(getSchema as Mock).mockImplementation(() => ({ collections: {} }));
|
||||
// receive message
|
||||
const fakeClient = mockClient();
|
||||
|
||||
emitter.emitAction(
|
||||
'websocket.message',
|
||||
{
|
||||
client: fakeClient,
|
||||
message: { type: 'items', collection: 'test', action: 'create', data: {} },
|
||||
},
|
||||
{} as EventContext
|
||||
);
|
||||
|
||||
await vi.runAllTimersAsync(); // flush promises to make sure the event is handled
|
||||
|
||||
// expect error
|
||||
expect(fakeClient.send).toBeCalledWith(
|
||||
'{"type":"items","status":"error","error":{"code":"INVALID_COLLECTION","message":"The provided collection does not exists or is not accessible."}}'
|
||||
);
|
||||
});
|
||||
|
||||
test('create one item', async () => {
|
||||
// do mocking
|
||||
(getSchema as Mock).mockImplementation(() => ({ collections: { test: [] } }));
|
||||
|
||||
const createOne = vi.fn(),
|
||||
readOne = vi.fn();
|
||||
|
||||
(ItemsService as Mock).mockImplementation(() => ({ createOne, readOne }));
|
||||
// receive message
|
||||
const fakeClient = mockClient();
|
||||
|
||||
emitter.emitAction(
|
||||
'websocket.message',
|
||||
{
|
||||
client: fakeClient,
|
||||
message: { type: 'items', collection: 'test', action: 'create', data: {} },
|
||||
},
|
||||
{} as EventContext
|
||||
);
|
||||
|
||||
await vi.runAllTimersAsync(); // flush promises to make sure the event is handled
|
||||
// expect service functions
|
||||
expect(createOne).toBeCalled();
|
||||
expect(readOne).toBeCalled();
|
||||
expect(fakeClient.send).toBeCalled();
|
||||
});
|
||||
|
||||
test('create multiple items', async () => {
|
||||
// do mocking
|
||||
(getSchema as Mock).mockImplementation(() => ({ collections: { test: [] } }));
|
||||
|
||||
const createMany = vi.fn(),
|
||||
readMany = vi.fn();
|
||||
|
||||
(ItemsService as Mock).mockImplementation(() => ({ createMany, readMany }));
|
||||
// receive message
|
||||
const fakeClient = mockClient();
|
||||
|
||||
emitter.emitAction(
|
||||
'websocket.message',
|
||||
{
|
||||
client: fakeClient,
|
||||
message: { type: 'items', collection: 'test', action: 'create', data: [{}, {}] },
|
||||
},
|
||||
{} as EventContext
|
||||
);
|
||||
|
||||
await vi.runAllTimersAsync(); // flush promises to make sure the event is handled
|
||||
// expect service functions
|
||||
expect(createMany).toBeCalled();
|
||||
expect(readMany).toBeCalled();
|
||||
expect(fakeClient.send).toBeCalled();
|
||||
});
|
||||
|
||||
test('read by query', async () => {
|
||||
// do mocking
|
||||
(getSchema as Mock).mockImplementation(() => ({ collections: { test: [] } }));
|
||||
const readByQuery = vi.fn();
|
||||
(ItemsService as Mock).mockImplementation(() => ({ readByQuery }));
|
||||
const getMetaForQuery = vi.fn();
|
||||
(MetaService as Mock).mockImplementation(() => ({ getMetaForQuery }));
|
||||
// receive message
|
||||
const fakeClient = mockClient();
|
||||
|
||||
emitter.emitAction(
|
||||
'websocket.message',
|
||||
{
|
||||
client: fakeClient,
|
||||
message: { type: 'items', collection: 'test', action: 'read', query: {} },
|
||||
},
|
||||
{} as EventContext
|
||||
);
|
||||
|
||||
await vi.runAllTimersAsync(); // flush promises to make sure the event is handled
|
||||
// expect service functions
|
||||
expect(readByQuery).toBeCalled();
|
||||
expect(getMetaForQuery).toBeCalled();
|
||||
expect(fakeClient.send).toBeCalled();
|
||||
});
|
||||
|
||||
test('update one item', async () => {
|
||||
// do mocking
|
||||
(getSchema as Mock).mockImplementation(() => ({ collections: { test: [] } }));
|
||||
|
||||
const updateOne = vi.fn(),
|
||||
readOne = vi.fn();
|
||||
|
||||
(ItemsService as Mock).mockImplementation(() => ({ updateOne, readOne }));
|
||||
// receive message
|
||||
const fakeClient = mockClient();
|
||||
|
||||
emitter.emitAction(
|
||||
'websocket.message',
|
||||
{
|
||||
client: fakeClient,
|
||||
message: { type: 'items', collection: 'test', action: 'update', data: {}, id: '123' },
|
||||
},
|
||||
{} as EventContext
|
||||
);
|
||||
|
||||
await vi.runAllTimersAsync(); // flush promises to make sure the event is handled
|
||||
// expect service functions
|
||||
expect(updateOne).toBeCalled();
|
||||
expect(readOne).toBeCalled();
|
||||
expect(fakeClient.send).toBeCalled();
|
||||
});
|
||||
|
||||
test('update multiple items', async () => {
|
||||
// do mocking
|
||||
(getSchema as Mock).mockImplementation(() => ({ collections: { test: [] } }));
|
||||
|
||||
const updateMany = vi.fn(),
|
||||
readMany = vi.fn();
|
||||
|
||||
(ItemsService as Mock).mockImplementation(() => ({ updateMany, readMany }));
|
||||
const getMetaForQuery = vi.fn();
|
||||
(MetaService as Mock).mockImplementation(() => ({ getMetaForQuery }));
|
||||
// receive message
|
||||
const fakeClient = mockClient();
|
||||
|
||||
emitter.emitAction(
|
||||
'websocket.message',
|
||||
{
|
||||
client: fakeClient,
|
||||
message: { type: 'items', collection: 'test', action: 'update', data: {}, ids: ['123', '456'] },
|
||||
},
|
||||
{} as EventContext
|
||||
);
|
||||
|
||||
await vi.runAllTimersAsync(); // flush promises to make sure the event is handled
|
||||
// expect service functions
|
||||
expect(updateMany).toBeCalled();
|
||||
expect(getMetaForQuery).toBeCalled();
|
||||
expect(readMany).toBeCalled();
|
||||
expect(fakeClient.send).toBeCalled();
|
||||
});
|
||||
|
||||
test('delete one item', async () => {
|
||||
// do mocking
|
||||
(getSchema as Mock).mockImplementation(() => ({ collections: { test: [] } }));
|
||||
const deleteOne = vi.fn();
|
||||
(ItemsService as Mock).mockImplementation(() => ({ deleteOne }));
|
||||
// receive message
|
||||
const fakeClient = mockClient();
|
||||
|
||||
emitter.emitAction(
|
||||
'websocket.message',
|
||||
{
|
||||
client: fakeClient,
|
||||
message: { type: 'items', collection: 'test', action: 'delete', id: '123' },
|
||||
},
|
||||
{} as EventContext
|
||||
);
|
||||
|
||||
await vi.runAllTimersAsync(); // flush promises to make sure the event is handled
|
||||
// expect service functions
|
||||
expect(deleteOne).toBeCalled();
|
||||
expect(fakeClient.send).toBeCalled();
|
||||
});
|
||||
|
||||
test('delete multiple items by id', async () => {
|
||||
// do mocking
|
||||
(getSchema as Mock).mockImplementation(() => ({ collections: { test: [] } }));
|
||||
const deleteMany = vi.fn();
|
||||
(ItemsService as Mock).mockImplementation(() => ({ deleteMany }));
|
||||
// receive message
|
||||
const fakeClient = mockClient();
|
||||
|
||||
emitter.emitAction(
|
||||
'websocket.message',
|
||||
{
|
||||
client: fakeClient,
|
||||
message: { type: 'items', collection: 'test', action: 'delete', ids: ['123', 456] },
|
||||
},
|
||||
{} as EventContext
|
||||
);
|
||||
|
||||
await vi.runAllTimersAsync(); // flush promises to make sure the event is handled
|
||||
// expect service functions
|
||||
expect(deleteMany).toBeCalled();
|
||||
expect(fakeClient.send).toBeCalled();
|
||||
});
|
||||
|
||||
test('delete multiple items by query', async () => {
|
||||
// do mocking
|
||||
(getSchema as Mock).mockImplementation(() => ({ collections: { test: [] } }));
|
||||
const deleteByQuery = vi.fn();
|
||||
(ItemsService as Mock).mockImplementation(() => ({ deleteByQuery }));
|
||||
// receive message
|
||||
const fakeClient = mockClient();
|
||||
|
||||
emitter.emitAction(
|
||||
'websocket.message',
|
||||
{
|
||||
client: fakeClient,
|
||||
message: { type: 'items', collection: 'test', action: 'delete', query: {} },
|
||||
},
|
||||
{} as EventContext
|
||||
);
|
||||
|
||||
await vi.runAllTimersAsync(); // flush promises to make sure the event is handled
|
||||
// expect service functions
|
||||
expect(deleteByQuery).toBeCalled();
|
||||
expect(fakeClient.send).toBeCalled();
|
||||
});
|
||||
});
|
||||
117
api/src/websocket/handlers/items.ts
Normal file
117
api/src/websocket/handlers/items.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import emitter from '../../emitter.js';
|
||||
import { ItemsService, MetaService } from '../../services/index.js';
|
||||
import { getSchema } from '../../utils/get-schema.js';
|
||||
import { sanitizeQuery } from '../../utils/sanitize-query.js';
|
||||
import { WebSocketException, handleWebSocketException } from '../exceptions.js';
|
||||
import { WebSocketItemsMessage } from '../messages.js';
|
||||
import type { WebSocketClient } from '../types.js';
|
||||
import { fmtMessage, getMessageType } from '../utils/message.js';
|
||||
|
||||
export class ItemsHandler {
|
||||
constructor() {
|
||||
emitter.onAction('websocket.message', ({ client, message }) => {
|
||||
if (getMessageType(message) !== 'items') return;
|
||||
|
||||
try {
|
||||
const parsedMessage = WebSocketItemsMessage.parse(message);
|
||||
|
||||
this.onMessage(client, parsedMessage).catch((err) => {
|
||||
// this catch is required because the async onMessage function is not awaited
|
||||
handleWebSocketException(client, err, 'items');
|
||||
});
|
||||
} catch (err) {
|
||||
handleWebSocketException(client, err, 'items');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async onMessage(client: WebSocketClient, message: WebSocketItemsMessage) {
|
||||
const uid = message.uid;
|
||||
const accountability = client.accountability;
|
||||
const schema = await getSchema();
|
||||
|
||||
if (!schema.collections[message.collection] || message.collection.startsWith('directus_')) {
|
||||
throw new WebSocketException(
|
||||
'items',
|
||||
'INVALID_COLLECTION',
|
||||
'The provided collection does not exists or is not accessible.',
|
||||
uid
|
||||
);
|
||||
}
|
||||
|
||||
const isSingleton = !!schema.collections[message.collection]?.singleton;
|
||||
const service = new ItemsService(message.collection, { schema, accountability });
|
||||
const metaService = new MetaService({ schema, accountability });
|
||||
let result, meta;
|
||||
|
||||
if (message.action === 'create') {
|
||||
const query = sanitizeQuery(message?.query ?? {}, accountability);
|
||||
|
||||
if (Array.isArray(message.data)) {
|
||||
const keys = await service.createMany(message.data);
|
||||
result = await service.readMany(keys, query);
|
||||
} else {
|
||||
const key = await service.createOne(message.data);
|
||||
result = await service.readOne(key, query);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.action === 'read') {
|
||||
const query = sanitizeQuery(message.query ?? {}, accountability);
|
||||
|
||||
if (message.id) {
|
||||
result = await service.readOne(message.id, query);
|
||||
} else if (message.ids) {
|
||||
result = await service.readMany(message.ids, query);
|
||||
} else if (isSingleton) {
|
||||
result = await service.readSingleton(query);
|
||||
} else {
|
||||
result = await service.readByQuery(query);
|
||||
}
|
||||
|
||||
meta = await metaService.getMetaForQuery(message.collection, query);
|
||||
}
|
||||
|
||||
if (message.action === 'update') {
|
||||
const query = sanitizeQuery(message.query ?? {}, accountability);
|
||||
|
||||
if (message.id) {
|
||||
const key = await service.updateOne(message.id, message.data);
|
||||
result = await service.readOne(key);
|
||||
} else if (message.ids) {
|
||||
const keys = await service.updateMany(message.ids, message.data);
|
||||
meta = await metaService.getMetaForQuery(message.collection, query);
|
||||
result = await service.readMany(keys, query);
|
||||
} else if (isSingleton) {
|
||||
await service.upsertSingleton(message.data);
|
||||
result = await service.readSingleton(query);
|
||||
} else {
|
||||
const keys = await service.updateByQuery(query, message.data);
|
||||
meta = await metaService.getMetaForQuery(message.collection, query);
|
||||
result = await service.readMany(keys, query);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.action === 'delete') {
|
||||
if (message.id) {
|
||||
await service.deleteOne(message.id);
|
||||
result = message.id;
|
||||
} else if (message.ids) {
|
||||
await service.deleteMany(message.ids);
|
||||
result = message.ids;
|
||||
} else if (message.query) {
|
||||
const query = sanitizeQuery(message.query, accountability);
|
||||
result = await service.deleteByQuery(query);
|
||||
} else {
|
||||
throw new WebSocketException(
|
||||
'items',
|
||||
'INVALID_PAYLOAD',
|
||||
"Either 'ids', 'id' or 'query' is required for a DELETE request.",
|
||||
uid
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
client.send(fmtMessage('items', { data: result, ...(meta ? { meta } : {}) }, uid));
|
||||
}
|
||||
}
|
||||
282
api/src/websocket/handlers/subscribe.test.ts
Normal file
282
api/src/websocket/handlers/subscribe.test.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { expect, describe, test, vi, beforeEach, afterEach } from 'vitest';
|
||||
import emitter from '../../emitter.js';
|
||||
import { SubscribeHandler } from './subscribe.js';
|
||||
import type { WebSocketClient } from '../types.js';
|
||||
import { getSchema } from '../../utils/get-schema.js';
|
||||
import type { CollectionsOverview, Relation } from '@directus/types';
|
||||
|
||||
// mocking
|
||||
vi.mock('../controllers', () => ({
|
||||
getWebSocketController: vi.fn(() => ({
|
||||
clients: new Set(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/get-schema', () => ({
|
||||
getSchema: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services', () => ({
|
||||
ItemsService: vi.fn(() => ({
|
||||
readByQuery: vi.fn(),
|
||||
})),
|
||||
MetaService: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../database/index');
|
||||
|
||||
function mockClient() {
|
||||
return {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
accountability: null,
|
||||
} as unknown as WebSocketClient;
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), ms);
|
||||
});
|
||||
}
|
||||
|
||||
describe('WebSocket heartbeat handler', () => {
|
||||
let handler: SubscribeHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
// initialize handler
|
||||
handler = new SubscribeHandler();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
emitter.offAll();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('ignore other message types', async () => {
|
||||
const spy = vi.spyOn(handler, 'onMessage');
|
||||
|
||||
// receive message
|
||||
emitter.emitAction('websocket.message', {
|
||||
client: mockClient(),
|
||||
message: { type: 'ping' },
|
||||
});
|
||||
|
||||
// expect nothing
|
||||
expect(spy).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('should fail subscribe to non-existing collection', async () => {
|
||||
vi.mocked(getSchema).mockImplementation(async () => ({
|
||||
collections: {} as CollectionsOverview,
|
||||
relations: [] as Relation[],
|
||||
}));
|
||||
|
||||
const subscribe = vi.spyOn(handler, 'subscribe');
|
||||
const onMessage = vi.spyOn(handler, 'onMessage');
|
||||
|
||||
// receive message
|
||||
emitter.emitAction('websocket.message', {
|
||||
client: mockClient(),
|
||||
message: {
|
||||
type: 'subscribe',
|
||||
collection: 'does_not_exist',
|
||||
},
|
||||
});
|
||||
|
||||
await delay(10);
|
||||
|
||||
// expect
|
||||
expect(onMessage).toBeCalled();
|
||||
expect(subscribe).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('should subscribe/unsubscribe to collection', async () => {
|
||||
const client = mockClient();
|
||||
|
||||
vi.mocked(getSchema).mockImplementation(async () => ({
|
||||
collections: {
|
||||
test_collection: {
|
||||
collection: 'test_collection',
|
||||
primary: 'id',
|
||||
singleton: false,
|
||||
sortField: null,
|
||||
note: null,
|
||||
accountability: null,
|
||||
fields: {},
|
||||
},
|
||||
} as CollectionsOverview,
|
||||
relations: [] as Relation[],
|
||||
}));
|
||||
|
||||
const subscribe = vi.spyOn(handler, 'subscribe');
|
||||
const onMessage = vi.spyOn(handler, 'onMessage');
|
||||
|
||||
// receive message
|
||||
emitter.emitAction('websocket.message', {
|
||||
client,
|
||||
message: {
|
||||
type: 'subscribe',
|
||||
collection: 'test_collection',
|
||||
uid: '123',
|
||||
},
|
||||
});
|
||||
|
||||
await delay(10);
|
||||
|
||||
// expect
|
||||
expect(onMessage).toBeCalled();
|
||||
expect(subscribe).toBeCalled();
|
||||
expect(handler.subscriptions['test_collection']?.size).toBe(1);
|
||||
});
|
||||
|
||||
test('unsubscribe a specific subscription', async () => {
|
||||
const client = mockClient();
|
||||
|
||||
vi.mocked(getSchema).mockImplementation(async () => ({
|
||||
collections: {
|
||||
test_collection: {
|
||||
collection: 'test_collection',
|
||||
primary: 'id',
|
||||
singleton: false,
|
||||
sortField: null,
|
||||
note: null,
|
||||
accountability: null,
|
||||
fields: {},
|
||||
},
|
||||
other_collection: {
|
||||
collection: 'other_collection',
|
||||
primary: 'id',
|
||||
singleton: false,
|
||||
sortField: null,
|
||||
note: null,
|
||||
accountability: null,
|
||||
fields: {},
|
||||
},
|
||||
} as CollectionsOverview,
|
||||
relations: [] as Relation[],
|
||||
}));
|
||||
|
||||
const unsubscribe = vi.spyOn(handler, 'unsubscribe');
|
||||
const subscribe = vi.spyOn(handler, 'subscribe');
|
||||
const onMessage = vi.spyOn(handler, 'onMessage');
|
||||
|
||||
// subscribe
|
||||
emitter.emitAction('websocket.message', {
|
||||
client,
|
||||
message: {
|
||||
type: 'subscribe',
|
||||
collection: 'test_collection',
|
||||
uid: '123',
|
||||
},
|
||||
});
|
||||
|
||||
emitter.emitAction('websocket.message', {
|
||||
client,
|
||||
message: {
|
||||
type: 'subscribe',
|
||||
collection: 'other_collection',
|
||||
uid: '456',
|
||||
},
|
||||
});
|
||||
|
||||
await delay(10);
|
||||
|
||||
// expect
|
||||
expect(onMessage).toBeCalledTimes(2);
|
||||
expect(subscribe).toBeCalledTimes(2);
|
||||
expect(handler.subscriptions['test_collection']?.size).toBe(1);
|
||||
expect(handler.subscriptions['other_collection']?.size).toBe(1);
|
||||
|
||||
// unsubscribe
|
||||
emitter.emitAction('websocket.message', {
|
||||
client,
|
||||
message: {
|
||||
type: 'unsubscribe',
|
||||
uid: '123',
|
||||
},
|
||||
});
|
||||
|
||||
await delay(10);
|
||||
|
||||
// expect
|
||||
expect(unsubscribe).toBeCalled();
|
||||
expect(handler.subscriptions['test_collection']?.size).toBe(0);
|
||||
expect(handler.subscriptions['other_collection']?.size).toBe(1);
|
||||
});
|
||||
|
||||
test('unsubscribe all subscriptions', async () => {
|
||||
const client = mockClient();
|
||||
|
||||
vi.mocked(getSchema).mockImplementation(async () => ({
|
||||
collections: {
|
||||
test_collection: {
|
||||
collection: 'test_collection',
|
||||
primary: 'id',
|
||||
singleton: false,
|
||||
sortField: null,
|
||||
note: null,
|
||||
accountability: null,
|
||||
fields: {},
|
||||
},
|
||||
other_collection: {
|
||||
collection: 'other_collection',
|
||||
primary: 'id',
|
||||
singleton: false,
|
||||
sortField: null,
|
||||
note: null,
|
||||
accountability: null,
|
||||
fields: {},
|
||||
},
|
||||
} as CollectionsOverview,
|
||||
relations: [] as Relation[],
|
||||
}));
|
||||
|
||||
const unsubscribe = vi.spyOn(handler, 'unsubscribe');
|
||||
const subscribe = vi.spyOn(handler, 'subscribe');
|
||||
const onMessage = vi.spyOn(handler, 'onMessage');
|
||||
|
||||
// subscribe
|
||||
emitter.emitAction('websocket.message', {
|
||||
client,
|
||||
message: {
|
||||
type: 'subscribe',
|
||||
collection: 'test_collection',
|
||||
uid: '123',
|
||||
},
|
||||
});
|
||||
|
||||
emitter.emitAction('websocket.message', {
|
||||
client,
|
||||
message: {
|
||||
type: 'subscribe',
|
||||
collection: 'other_collection',
|
||||
uid: '456',
|
||||
},
|
||||
});
|
||||
|
||||
await delay(10);
|
||||
|
||||
// expect
|
||||
expect(onMessage).toBeCalledTimes(2);
|
||||
expect(subscribe).toBeCalledTimes(2);
|
||||
expect(handler.subscriptions['test_collection']?.size).toBe(1);
|
||||
expect(handler.subscriptions['other_collection']?.size).toBe(1);
|
||||
|
||||
// unsubscribe
|
||||
emitter.emitAction('websocket.message', {
|
||||
client,
|
||||
message: {
|
||||
type: 'unsubscribe',
|
||||
},
|
||||
});
|
||||
|
||||
await delay(10);
|
||||
|
||||
// expect
|
||||
expect(unsubscribe).toBeCalled();
|
||||
expect(handler.subscriptions['test_collection']?.size).toBe(0);
|
||||
expect(handler.subscriptions['other_collection']?.size).toBe(0);
|
||||
});
|
||||
});
|
||||
342
api/src/websocket/handlers/subscribe.ts
Normal file
342
api/src/websocket/handlers/subscribe.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import type { Accountability, SchemaOverview } from '@directus/types';
|
||||
import emitter from '../../emitter.js';
|
||||
import { InvalidPayloadException } from '../../index.js';
|
||||
import { getMessenger } from '../../messenger.js';
|
||||
import type { Messenger } from '../../messenger.js';
|
||||
import { CollectionsService, FieldsService, MetaService } from '../../services/index.js';
|
||||
import { getSchema } from '../../utils/get-schema.js';
|
||||
import { getService } from '../../utils/get-service.js';
|
||||
import { sanitizeQuery } from '../../utils/sanitize-query.js';
|
||||
import { refreshAccountability } from '../authenticate.js';
|
||||
import { WebSocketException, handleWebSocketException } from '../exceptions.js';
|
||||
import type { WebSocketEvent } from '../messages.js';
|
||||
import { WebSocketSubscribeMessage } from '../messages.js';
|
||||
import type { Subscription, SubscriptionEvent, WebSocketClient } from '../types.js';
|
||||
import { fmtMessage, getMessageType } from '../utils/message.js';
|
||||
|
||||
/**
|
||||
* Handler responsible for subscriptions
|
||||
*/
|
||||
export class SubscribeHandler {
|
||||
// storage of subscriptions per collection
|
||||
subscriptions: Record<string, Set<Subscription>>;
|
||||
// internal message bus
|
||||
protected messenger: Messenger;
|
||||
/**
|
||||
* Initialize the handler
|
||||
*/
|
||||
constructor() {
|
||||
this.subscriptions = {};
|
||||
this.messenger = getMessenger();
|
||||
this.bindWebSocket();
|
||||
|
||||
// listen to the Redis pub/sub and dispatch
|
||||
this.messenger.subscribe('websocket.event', (message: Record<string, any>) => {
|
||||
try {
|
||||
this.dispatch(message as WebSocketEvent);
|
||||
} 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) {
|
||||
handleWebSocketException(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: Subscription) {
|
||||
const { collection } = subscription;
|
||||
|
||||
if ('item' in subscription && ['directus_fields', 'directus_relations'].includes(collection)) {
|
||||
throw new InvalidPayloadException(`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: WebSocketClient, uid?: string | number) {
|
||||
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: WebSocketEvent) {
|
||||
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
|
||||
}
|
||||
|
||||
try {
|
||||
client.accountability = await refreshAccountability(client.accountability);
|
||||
|
||||
const result =
|
||||
'item' in subscription
|
||||
? await this.getSinglePayload(subscription, client.accountability, schema, event)
|
||||
: await this.getMultiPayload(subscription, client.accountability, schema, event);
|
||||
|
||||
if (Array.isArray(result?.['data']) && result?.['data']?.length === 0) return;
|
||||
|
||||
client.send(fmtMessage('subscription', result, subscription.uid));
|
||||
} catch (err) {
|
||||
handleWebSocketException(client, err, 'subscribe');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming (un)subscribe requests
|
||||
*/
|
||||
async onMessage(client: WebSocketClient, message: WebSocketSubscribeMessage) {
|
||||
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 WebSocketException(
|
||||
'subscribe',
|
||||
'INVALID_COLLECTION',
|
||||
'The provided collection does not exists or is not accessible.',
|
||||
message.uid
|
||||
);
|
||||
}
|
||||
|
||||
const subscription: Subscription = {
|
||||
client,
|
||||
collection,
|
||||
};
|
||||
|
||||
if ('event' in message) {
|
||||
subscription.event = message.event as SubscriptionEvent;
|
||||
}
|
||||
|
||||
if ('query' in message) {
|
||||
subscription.query = sanitizeQuery(message.query!, 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);
|
||||
}
|
||||
|
||||
let data: Record<string, any>;
|
||||
|
||||
if (subscription.event === undefined) {
|
||||
data =
|
||||
'item' in subscription
|
||||
? await this.getSinglePayload(subscription, accountability, schema)
|
||||
: await this.getMultiPayload(subscription, accountability, schema);
|
||||
} else {
|
||||
data = { 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) {
|
||||
handleWebSocketException(client, err, 'subscribe');
|
||||
}
|
||||
}
|
||||
|
||||
if (getMessageType(message) === 'unsubscribe') {
|
||||
try {
|
||||
this.unsubscribe(client, message.uid);
|
||||
|
||||
client.send(fmtMessage('subscription', { event: 'unsubscribe' }, message.uid));
|
||||
} catch (err) {
|
||||
handleWebSocketException(client, err, 'unsubscribe');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getSinglePayload(
|
||||
subscription: Subscription,
|
||||
accountability: Accountability | null,
|
||||
schema: SchemaOverview,
|
||||
event?: WebSocketEvent
|
||||
): Promise<Record<string, any>> {
|
||||
const metaService = new MetaService({ schema, accountability });
|
||||
const query = subscription.query ?? {};
|
||||
const id = subscription.item!;
|
||||
|
||||
const result: Record<string, any> = {
|
||||
event: event?.action ?? 'init',
|
||||
};
|
||||
|
||||
if (subscription.collection === 'directus_collections') {
|
||||
const service = new CollectionsService({ schema, accountability });
|
||||
result['data'] = await service.readOne(String(id));
|
||||
} else {
|
||||
const service = getService(subscription.collection, { schema, accountability });
|
||||
result['data'] = await service.readOne(id, query);
|
||||
}
|
||||
|
||||
if ('meta' in query) {
|
||||
result['meta'] = await metaService.getMetaForQuery(subscription.collection, query);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async getMultiPayload(
|
||||
subscription: Subscription,
|
||||
accountability: Accountability | null,
|
||||
schema: SchemaOverview,
|
||||
event?: WebSocketEvent
|
||||
): Promise<Record<string, any>> {
|
||||
const metaService = new MetaService({ schema, accountability });
|
||||
|
||||
const result: Record<string, any> = {
|
||||
event: event?.action ?? 'init',
|
||||
};
|
||||
|
||||
switch (subscription.collection) {
|
||||
case 'directus_collections':
|
||||
result['data'] = await this.getCollectionPayload(accountability, schema, event);
|
||||
break;
|
||||
case 'directus_fields':
|
||||
result['data'] = await this.getFieldsPayload(accountability, schema, event);
|
||||
break;
|
||||
case 'directus_relations':
|
||||
result['data'] = event?.payload;
|
||||
break;
|
||||
default:
|
||||
result['data'] = await this.getItemsPayload(subscription, accountability, schema, event);
|
||||
break;
|
||||
}
|
||||
|
||||
const query = subscription.query ?? {};
|
||||
|
||||
if ('meta' in query) {
|
||||
result['meta'] = await metaService.getMetaForQuery(subscription.collection, query);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async getCollectionPayload(
|
||||
accountability: Accountability | null,
|
||||
schema: SchemaOverview,
|
||||
event?: WebSocketEvent
|
||||
) {
|
||||
const service = new CollectionsService({ schema, accountability });
|
||||
|
||||
if (!event?.action) {
|
||||
return await service.readByQuery();
|
||||
} else if (event.action === 'create') {
|
||||
return await service.readMany([String(event.key)]);
|
||||
} else if (event.action === 'delete') {
|
||||
return event.keys;
|
||||
} else {
|
||||
return await service.readMany(event.keys.map((key: any) => String(key)));
|
||||
}
|
||||
}
|
||||
|
||||
private async getFieldsPayload(
|
||||
accountability: Accountability | null,
|
||||
schema: SchemaOverview,
|
||||
event?: WebSocketEvent
|
||||
) {
|
||||
const service = new FieldsService({ schema, accountability });
|
||||
|
||||
if (!event?.action) {
|
||||
return await service.readAll();
|
||||
} else if (event.action === 'delete') {
|
||||
return event.keys;
|
||||
} else {
|
||||
return await service.readOne(event.payload?.['collection'], event.payload?.['field']);
|
||||
}
|
||||
}
|
||||
|
||||
private async getItemsPayload(
|
||||
subscription: Subscription,
|
||||
accountability: Accountability | null,
|
||||
schema: SchemaOverview,
|
||||
event?: WebSocketEvent
|
||||
) {
|
||||
const query = subscription.query ?? {};
|
||||
const service = getService(subscription.collection, { schema, accountability });
|
||||
|
||||
if (!event?.action) {
|
||||
return await service.readByQuery(query);
|
||||
} else if (event.action === 'create') {
|
||||
return await service.readMany([event.key], query);
|
||||
} else if (event.action === 'delete') {
|
||||
return event.keys;
|
||||
} else {
|
||||
return await service.readMany(event.keys, query);
|
||||
}
|
||||
}
|
||||
|
||||
private getSubscription(client: WebSocketClient, uid: string | number) {
|
||||
for (const userSubscriptions of Object.values(this.subscriptions)) {
|
||||
for (const subscription of userSubscriptions) {
|
||||
if (subscription.client === client && subscription.uid === uid) {
|
||||
return subscription;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
118
api/src/websocket/messages.ts
Normal file
118
api/src/websocket/messages.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { Item, Query } from '@directus/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
const zodStringOrNumber = z.union([z.string(), z.number()]);
|
||||
|
||||
export const WebSocketMessage = z
|
||||
.object({
|
||||
type: z.string(),
|
||||
uid: zodStringOrNumber.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export type WebSocketMessage = z.infer<typeof WebSocketMessage>;
|
||||
|
||||
export const WebSocketResponse = z.discriminatedUnion('status', [
|
||||
WebSocketMessage.extend({
|
||||
status: z.literal('ok'),
|
||||
}),
|
||||
WebSocketMessage.extend({
|
||||
status: z.literal('error'),
|
||||
error: z
|
||||
.object({
|
||||
code: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.passthrough(),
|
||||
}),
|
||||
]);
|
||||
export type WebSocketResponse = z.infer<typeof WebSocketResponse>;
|
||||
|
||||
export const ConnectionParams = z.object({ access_token: z.string().optional() });
|
||||
export type ConnectionParams = z.infer<typeof ConnectionParams>;
|
||||
|
||||
export const BasicAuthMessage = z.union([
|
||||
z.object({ email: z.string().email(), password: z.string() }),
|
||||
z.object({ access_token: z.string() }),
|
||||
z.object({ refresh_token: z.string() }),
|
||||
]);
|
||||
export type BasicAuthMessage = z.infer<typeof BasicAuthMessage>;
|
||||
|
||||
export const WebSocketAuthMessage = WebSocketMessage.extend({
|
||||
type: z.literal('auth'),
|
||||
}).and(BasicAuthMessage);
|
||||
export type WebSocketAuthMessage = z.infer<typeof WebSocketAuthMessage>;
|
||||
|
||||
export const WebSocketSubscribeMessage = z.discriminatedUnion('type', [
|
||||
WebSocketMessage.extend({
|
||||
type: z.literal('subscribe'),
|
||||
collection: z.string(),
|
||||
event: z.union([z.literal('create'), z.literal('update'), z.literal('delete')]).optional(),
|
||||
item: zodStringOrNumber.optional(),
|
||||
query: z.custom<Query>().optional(),
|
||||
}),
|
||||
WebSocketMessage.extend({
|
||||
type: z.literal('unsubscribe'),
|
||||
}),
|
||||
]);
|
||||
export type WebSocketSubscribeMessage = z.infer<typeof WebSocketSubscribeMessage>;
|
||||
|
||||
const ZodItem = z.custom<Partial<Item>>();
|
||||
|
||||
const PartialItemsMessage = z.object({
|
||||
uid: zodStringOrNumber.optional(),
|
||||
type: z.literal('items'),
|
||||
collection: z.string(),
|
||||
});
|
||||
|
||||
export const WebSocketItemsMessage = z.union([
|
||||
PartialItemsMessage.extend({
|
||||
action: z.literal('create'),
|
||||
data: z.union([z.array(ZodItem), ZodItem]),
|
||||
query: z.custom<Query>().optional(),
|
||||
}),
|
||||
PartialItemsMessage.extend({
|
||||
action: z.literal('read'),
|
||||
ids: z.array(zodStringOrNumber).optional(),
|
||||
id: zodStringOrNumber.optional(),
|
||||
query: z.custom<Query>().optional(),
|
||||
}),
|
||||
PartialItemsMessage.extend({
|
||||
action: z.literal('update'),
|
||||
data: ZodItem,
|
||||
ids: z.array(zodStringOrNumber).optional(),
|
||||
id: zodStringOrNumber.optional(),
|
||||
query: z.custom<Query>().optional(),
|
||||
}),
|
||||
PartialItemsMessage.extend({
|
||||
action: z.literal('delete'),
|
||||
ids: z.array(zodStringOrNumber).optional(),
|
||||
id: zodStringOrNumber.optional(),
|
||||
query: z.custom<Query>().optional(),
|
||||
}),
|
||||
]);
|
||||
export type WebSocketItemsMessage = z.infer<typeof WebSocketItemsMessage>;
|
||||
|
||||
export const WebSocketEvent = z.discriminatedUnion('action', [
|
||||
z.object({
|
||||
action: z.literal('create'),
|
||||
collection: z.string(),
|
||||
payload: z.record(z.any()).optional(),
|
||||
key: zodStringOrNumber,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('update'),
|
||||
collection: z.string(),
|
||||
payload: z.record(z.any()).optional(),
|
||||
keys: z.array(zodStringOrNumber),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('delete'),
|
||||
collection: z.string(),
|
||||
payload: z.record(z.any()).optional(),
|
||||
keys: z.array(zodStringOrNumber),
|
||||
}),
|
||||
]);
|
||||
export type WebSocketEvent = z.infer<typeof WebSocketEvent>;
|
||||
|
||||
export const AuthMode = z.union([z.literal('public'), z.literal('handshake'), z.literal('strict')]);
|
||||
export type AuthMode = z.infer<typeof AuthMode>;
|
||||
35
api/src/websocket/types.ts
Normal file
35
api/src/websocket/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Accountability, Query } from '@directus/types';
|
||||
import type { IncomingMessage } from 'http';
|
||||
import type internal from 'stream';
|
||||
import type { WebSocket } from 'ws';
|
||||
|
||||
export type AuthenticationState = {
|
||||
accountability: Accountability | null;
|
||||
expires_at: number | null;
|
||||
refresh_token?: string;
|
||||
};
|
||||
|
||||
export type WebSocketClient = WebSocket &
|
||||
AuthenticationState & { uid: string | number; auth_timer: NodeJS.Timer | null };
|
||||
export type UpgradeRequest = IncomingMessage & AuthenticationState;
|
||||
|
||||
export type SubscriptionEvent = 'create' | 'update' | 'delete';
|
||||
|
||||
export type Subscription = {
|
||||
uid?: string | number;
|
||||
query?: Query;
|
||||
item?: string | number;
|
||||
event?: SubscriptionEvent;
|
||||
collection: string;
|
||||
client: WebSocketClient;
|
||||
};
|
||||
|
||||
export type UpgradeContext = {
|
||||
request: IncomingMessage;
|
||||
socket: internal.Duplex;
|
||||
head: Buffer;
|
||||
};
|
||||
|
||||
export type GraphQLSocket = {
|
||||
client: WebSocketClient;
|
||||
};
|
||||
23
api/src/websocket/utils/get-expires-at-for-token.test.ts
Normal file
23
api/src/websocket/utils/get-expires-at-for-token.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { getExpiresAtForToken } from './get-expires-at-for-token.js';
|
||||
|
||||
describe('getExpiresAtForToken', () => {
|
||||
test('Returns null for non-jwt tokens', () => {
|
||||
const result = getExpiresAtForToken('not-a-jwt');
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
test('Returns null for jwt with no exp field', () => {
|
||||
const token = jwt.sign({ payload: 'content' }, 'secret', { issuer: 'tim' });
|
||||
const result = getExpiresAtForToken(token);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
test('Returns expiresAt field for jwt with exp as number', () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const token = jwt.sign({ payload: 'content' }, 'secret', { expiresIn: 42 });
|
||||
const result = getExpiresAtForToken(token);
|
||||
expect(result).toBeGreaterThan(now);
|
||||
});
|
||||
});
|
||||
11
api/src/websocket/utils/get-expires-at-for-token.ts
Normal file
11
api/src/websocket/utils/get-expires-at-for-token.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export function getExpiresAtForToken(token: string): number | null {
|
||||
const decoded = jwt.decode(token);
|
||||
|
||||
if (decoded && typeof decoded === 'object' && decoded.exp) {
|
||||
return decoded.exp;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
75
api/src/websocket/utils/message.test.ts
Normal file
75
api/src/websocket/utils/message.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import type { WebSocketClient } from '../types.js';
|
||||
import { fmtMessage, getMessageType, safeSend } from './message.js';
|
||||
|
||||
describe('fmtMessage util', () => {
|
||||
test('Returns formatted message', () => {
|
||||
const result = fmtMessage('test', { test: 'abc' });
|
||||
expect(result).toStrictEqual('{"type":"test","test":"abc"}');
|
||||
});
|
||||
|
||||
test('Returns formatted message with uid', () => {
|
||||
const result = fmtMessage('test', { test: 'abc' }, '123');
|
||||
expect(result).toStrictEqual('{"type":"test","test":"abc","uid":"123"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeSend util', () => {
|
||||
test('Ignore for closed connections', async () => {
|
||||
const fakeClient = {
|
||||
readyState: 3, // closed
|
||||
OPEN: 1,
|
||||
bufferedAmount: 0,
|
||||
send: vi.fn(),
|
||||
} as unknown as WebSocketClient;
|
||||
|
||||
const result = await safeSend(fakeClient, 'not used');
|
||||
expect(result).toBe(false);
|
||||
expect(fakeClient.send).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('Wait for buffer', async () => {
|
||||
const fakeClient = {
|
||||
readyState: 1, // open
|
||||
OPEN: 1,
|
||||
bufferedAmount: 4,
|
||||
send: vi.fn(),
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
fakeClient.bufferedAmount = 0;
|
||||
}, 10);
|
||||
|
||||
const result = await safeSend(fakeClient as unknown as WebSocketClient, 'a message', 20);
|
||||
expect(result).toBe(true);
|
||||
expect(fakeClient.send).toBeCalledWith('a message');
|
||||
});
|
||||
|
||||
test('send message', async () => {
|
||||
const fakeClient = {
|
||||
readyState: 1, // open
|
||||
OPEN: 1,
|
||||
bufferedAmount: 0,
|
||||
send: vi.fn(),
|
||||
} as unknown as WebSocketClient;
|
||||
|
||||
const result = await safeSend(fakeClient as unknown as WebSocketClient, 'a message');
|
||||
expect(result).toBe(true);
|
||||
expect(fakeClient.send).toBeCalledWith('a message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMessageType util', () => {
|
||||
test('Fails graceously', () => {
|
||||
expect(getMessageType(null)).toBe('');
|
||||
expect(getMessageType(undefined)).toBe('');
|
||||
expect(getMessageType(false)).toBe('');
|
||||
expect(getMessageType(123456)).toBe('');
|
||||
expect(getMessageType([])).toBe('');
|
||||
});
|
||||
|
||||
test('Get the type property', () => {
|
||||
expect(getMessageType({ type: 'test' })).toBe('test');
|
||||
expect(getMessageType({ type: 123 })).toBe('123');
|
||||
});
|
||||
});
|
||||
34
api/src/websocket/utils/message.ts
Normal file
34
api/src/websocket/utils/message.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { WebSocketClient } from '../types.js';
|
||||
|
||||
// a simple util for building a message object
|
||||
export const fmtMessage = (type: string, data: Record<string, any> = {}, uid?: string | number) => {
|
||||
const message: Record<string, any> = { type, ...data };
|
||||
|
||||
if (uid !== undefined) {
|
||||
message['uid'] = uid;
|
||||
}
|
||||
|
||||
return JSON.stringify(message);
|
||||
};
|
||||
|
||||
// we may need this later for slow connections
|
||||
export const safeSend = async (client: WebSocketClient, data: string, delay = 100) => {
|
||||
if (client.readyState !== client.OPEN) return false;
|
||||
|
||||
if (client.bufferedAmount > 0) {
|
||||
// wait for the buffer to clear
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
safeSend(client, data, delay).then((success) => resolve(success));
|
||||
}, delay);
|
||||
});
|
||||
}
|
||||
|
||||
client.send(data);
|
||||
return true;
|
||||
};
|
||||
|
||||
// an often used message type extractor function
|
||||
export const getMessageType = (message: any): string => {
|
||||
return typeof message !== 'object' || Array.isArray(message) || message === null ? '' : String(message.type);
|
||||
};
|
||||
94
api/src/websocket/utils/wait-for-message.test.ts
Normal file
94
api/src/websocket/utils/wait-for-message.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import type { RawData, WebSocket } from 'ws';
|
||||
import { waitForAnyMessage, waitForMessageType } from './wait-for-message.js';
|
||||
|
||||
function bufferMessage(msg: any): RawData {
|
||||
return Buffer.from(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
function mockClient(handler: (callback: (event: RawData) => void) => void) {
|
||||
return {
|
||||
on: vi.fn().mockImplementation((type: string, callback: (event: RawData) => void) => {
|
||||
if (type === 'message') handler(callback);
|
||||
}),
|
||||
off: vi.fn(),
|
||||
} as unknown as WebSocket;
|
||||
}
|
||||
|
||||
describe('Wait for messages', () => {
|
||||
test('should succeed, 5ms delay, 10ms timeout', async () => {
|
||||
const TEST_TIMEOUT = 10;
|
||||
const TEST_MSG = { type: 'test', id: 1 };
|
||||
|
||||
const fakeClient = mockClient((callback) => {
|
||||
setTimeout(() => {
|
||||
callback(bufferMessage(TEST_MSG));
|
||||
}, 5);
|
||||
});
|
||||
|
||||
const msg = await waitForAnyMessage(fakeClient, TEST_TIMEOUT);
|
||||
|
||||
expect(msg).toStrictEqual(TEST_MSG);
|
||||
});
|
||||
|
||||
test('should fail, 10ms delay, 5ms timeout', async () => {
|
||||
const TEST_TIMEOUT = 5;
|
||||
const TEST_MSG = { type: 'test', id: 1 };
|
||||
|
||||
const fakeClient = mockClient((callback) => {
|
||||
setTimeout(() => {
|
||||
callback(bufferMessage(TEST_MSG));
|
||||
}, 10);
|
||||
});
|
||||
|
||||
expect(() => waitForAnyMessage(fakeClient, TEST_TIMEOUT)).rejects.toBe(undefined);
|
||||
});
|
||||
|
||||
test('should fail parsing', async () => {
|
||||
const TEST_TIMEOUT = 5;
|
||||
|
||||
const fakeClient = mockClient((callback) => {
|
||||
setTimeout(() => {
|
||||
callback(Buffer.from('{invalid:json}'));
|
||||
}, 10);
|
||||
});
|
||||
|
||||
expect(() => waitForAnyMessage(fakeClient, TEST_TIMEOUT)).rejects.toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Wait for specific types messages', () => {
|
||||
const MSG_A = { type: 'test', id: 1 };
|
||||
const MSG_B = { type: 'other', id: 2 };
|
||||
|
||||
test('should find the correct message', async () => {
|
||||
const fakeClient = mockClient((callback) => {
|
||||
setTimeout(() => callback(bufferMessage(MSG_B)), 5);
|
||||
setTimeout(() => callback(bufferMessage(MSG_A)), 10);
|
||||
});
|
||||
|
||||
const msg = await waitForMessageType(fakeClient, 'test', 15);
|
||||
|
||||
expect(msg).toStrictEqual(MSG_A);
|
||||
});
|
||||
|
||||
test('should fail, no matching type', async () => {
|
||||
const fakeClient = mockClient((callback) => {
|
||||
setTimeout(() => {
|
||||
callback(bufferMessage(MSG_B));
|
||||
}, 5);
|
||||
});
|
||||
|
||||
expect(() => waitForMessageType(fakeClient, 'test', 10)).rejects.toBe(undefined);
|
||||
});
|
||||
|
||||
test('should fail parsing', async () => {
|
||||
const fakeClient = mockClient((callback) => {
|
||||
setTimeout(() => {
|
||||
callback(bufferMessage({ id: 2 }));
|
||||
}, 5);
|
||||
});
|
||||
|
||||
expect(() => waitForMessageType(fakeClient, 'test', 10)).rejects.toBe(undefined);
|
||||
});
|
||||
});
|
||||
52
api/src/websocket/utils/wait-for-message.ts
Normal file
52
api/src/websocket/utils/wait-for-message.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { parseJSON } from '@directus/utils';
|
||||
import type { RawData, WebSocket } from 'ws';
|
||||
import { WebSocketMessage } from '../messages.js';
|
||||
import { getMessageType } from './message.js';
|
||||
|
||||
export const waitForAnyMessage = (client: WebSocket, timeout: number): Promise<Record<string, any>> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.on('message', awaitMessage);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
client.off('message', awaitMessage);
|
||||
reject();
|
||||
}, timeout);
|
||||
|
||||
function awaitMessage(event: RawData) {
|
||||
try {
|
||||
clearTimeout(timer);
|
||||
client.off('message', awaitMessage);
|
||||
resolve(parseJSON(event.toString()));
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const waitForMessageType = (client: WebSocket, type: string, timeout: number): Promise<WebSocketMessage> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.on('message', awaitMessage);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
client.off('message', awaitMessage);
|
||||
reject();
|
||||
}, timeout);
|
||||
|
||||
function awaitMessage(event: RawData) {
|
||||
let msg: WebSocketMessage;
|
||||
|
||||
try {
|
||||
msg = WebSocketMessage.parse(parseJSON(event.toString()));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getMessageType(msg) === type) {
|
||||
clearTimeout(timer);
|
||||
client.off('message', awaitMessage);
|
||||
resolve(msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -29,6 +29,7 @@
|
||||
- phazonoverload
|
||||
- akshay-sood
|
||||
- nickrum
|
||||
- estheragbaje
|
||||
- danielduckworth
|
||||
- JonathanSchndr
|
||||
- ArthurYidi
|
||||
|
||||
@@ -84,9 +84,9 @@ img {
|
||||
grid-template-columns: auto;
|
||||
gap: 0;
|
||||
}
|
||||
.vp-doc h2,
|
||||
.vp-doc h3 {
|
||||
margin-top: 1em;
|
||||
|
||||
img {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
25
docs/.vitepress/components/Contributors.vue
Normal file
25
docs/.vitepress/components/Contributors.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
contributors: { type: String, required: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="contributors">
|
||||
<p>
|
||||
<b>Contributors:</b>
|
||||
<span>{{ contributors }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
p {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
padding: 1em;
|
||||
border-radius: 8px;
|
||||
}
|
||||
b {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
</style>
|
||||
@@ -513,6 +513,17 @@ function sidebar() {
|
||||
link: '/guides/migration/index.html',
|
||||
text: 'Schema Migration',
|
||||
},
|
||||
{
|
||||
text: 'Real-Time',
|
||||
items: [
|
||||
{ text: 'Getting Started', link: '/guides/real-time/getting-started/index.html' },
|
||||
{ text: 'Authentication', link: '/guides/real-time/authentication' },
|
||||
{ text: 'Operations', link: '/guides/real-time/operations' },
|
||||
{ text: 'Subscriptions', link: '/guides/real-time/subscriptions/index.html' },
|
||||
{ text: 'Build a Multi-User Chat', link: '/guides/real-time/chat/index.html' },
|
||||
{ text: 'Build a Live Poll Result', link: '/guides/real-time/live-poll' },
|
||||
]
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ const { Layout } = DefaultTheme;
|
||||
const { page } = useData();
|
||||
const route = useRoute();
|
||||
const title = computed(() => page.value.title);
|
||||
const contributors = computed(() => page.value.frontmatter.contributors);
|
||||
const path = computed(() => route.path);
|
||||
</script>
|
||||
|
||||
@@ -15,6 +16,13 @@ const path = computed(() => route.path);
|
||||
<Layout>
|
||||
<template #doc-footer-before>
|
||||
<ArticleFeedback :url="path" :title="title" />
|
||||
<Contributors id="contributors" :contributors="contributors" />
|
||||
</template>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#contributors {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import { createHead } from '@unhead/vue';
|
||||
|
||||
import Layout from './DocLayout.vue';
|
||||
import Card from '../components/Card.vue';
|
||||
import Contributors from '../components/Contributors.vue';
|
||||
|
||||
import './vars.css';
|
||||
import './overrides.css';
|
||||
@@ -16,5 +17,6 @@ export default {
|
||||
const head = createHead();
|
||||
ctx.app.use(head);
|
||||
ctx.app.component('Card', Card);
|
||||
ctx.app.component('Contributors', Contributors);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -68,6 +68,8 @@
|
||||
(W|w)alkthrough
|
||||
(W|w)ebhook.?
|
||||
(W|w)ebhooks?
|
||||
(W|w)ebSocket
|
||||
(W|w)ebSockets
|
||||
[0-9]*(KB|MB|GB|K|px|pm|am)
|
||||
[0-9]*[Kk]i?B
|
||||
[0-9]*x[0-9]*
|
||||
@@ -310,6 +312,7 @@ readonly
|
||||
readTime
|
||||
rebase
|
||||
reconfigurations
|
||||
reconnection
|
||||
reframe
|
||||
REKT
|
||||
relationally
|
||||
@@ -372,6 +375,8 @@ upscaled
|
||||
upvotes
|
||||
userbase
|
||||
uuid
|
||||
UID
|
||||
UIDs
|
||||
UUID
|
||||
UUIDs
|
||||
UUIDv4
|
||||
|
||||
126
docs/guides/real-time/authentication.md
Normal file
126
docs/guides/real-time/authentication.md
Normal file
@@ -0,0 +1,126 @@
|
||||
---
|
||||
contributors: Kevin Lewis
|
||||
description: "Learn about Directus' real-time security and authentication settings."
|
||||
---
|
||||
|
||||
|
||||
# WebSocket & GraphQL Authentication
|
||||
|
||||
Authentication is an important part of establishing your persistent connection with a Directus project.
|
||||
|
||||
## Authentication Modes
|
||||
|
||||
There are three authentication modes in Directus.
|
||||
|
||||
| Mode | Description |
|
||||
|---|---|
|
||||
| **public** | No authentication is required. |
|
||||
| **handshake** | No authentication required to connect. First message must be an authentication request sent before the timeout. |
|
||||
| **strict** | Authentication is required as a URL parameter on the initial connection. |
|
||||
|
||||
### Changing Authentication Modes
|
||||
|
||||
Your whole project will use the same, single, authentication mode. You cannot use multiple authentication modes in a single project.
|
||||
|
||||
By default, the `handshake` authentication mode is used. If self-hosting your project, you may change the mode used by setting the `WEBSOCKETS_REST_AUTH` and `WEBSOCKETS_GRAPHQL_AUTH` environment variables.
|
||||
|
||||
## REST Authentication Flow
|
||||
|
||||
### Public Mode
|
||||
|
||||
You do not need to authenticate if using a public authentication mode, but you are limited to the public role only.
|
||||
|
||||
If you want to change your role, follow the flow for the `handshake` authentication mode.
|
||||
|
||||
### Handshake Mode
|
||||
|
||||
Your first message must include authentication details and be sent before the timeout. There are three options:
|
||||
|
||||
**Access Token**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "auth",
|
||||
"access_token": "your-access-token"
|
||||
}
|
||||
```
|
||||
|
||||
**Email and Password**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "auth",
|
||||
"email": "user@email.com",
|
||||
"password": "your-password"
|
||||
}
|
||||
```
|
||||
|
||||
**Refresh Token**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "auth",
|
||||
"refresh_token": "token"
|
||||
}
|
||||
```
|
||||
|
||||
On successful authentication you’ll receive a confirmation message. This message includes a `refresh_token` when using email/password and refresh_token credentials.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "auth",
|
||||
"status": "ok",
|
||||
"refresh_token": "a-token-to-use-later"
|
||||
}
|
||||
```
|
||||
|
||||
When the client receives an auth expired error, a new authentication request is expected within the set timeout or the connection will be closed.
|
||||
|
||||
### Strict Mode
|
||||
|
||||
When initially opening your connection, add a `access_token` query parameter to your request.
|
||||
|
||||
Once initially authenticated, all 3 authentication are available.
|
||||
|
||||
## GraphQL Authentication Flow
|
||||
|
||||
GraphQL puts more responsibility on the client for handling the re-authentication flow and has no supported way for us to implement it without breaking compatibility with existing clients. Because of this, you may only use an `access_token` for authentication at this time.
|
||||
|
||||
When a token expires, the connection will be closed with a `Forbidden` message, signaling to the client to refresh their `access_token` and reconnect.
|
||||
|
||||
### Public Mode
|
||||
|
||||
```js
|
||||
import { createClient } from 'graphql-ws';
|
||||
const client = createClient({
|
||||
url: 'ws://your-directus-url/graphql',
|
||||
keepAlive: 30000
|
||||
});
|
||||
```
|
||||
|
||||
### Handshake Mode
|
||||
|
||||
```js
|
||||
import { createClient } from 'graphql-ws';
|
||||
const client = createClient({
|
||||
url: 'ws://your-directus-url/graphql',
|
||||
keepAlive: 30000,
|
||||
connectionParams: async () => {
|
||||
return { access_token: 'MY_TOKEN' };
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Strict Mode
|
||||
|
||||
```js
|
||||
import { createClient } from 'graphql-ws';
|
||||
const client = createClient({
|
||||
url: 'ws://your-directus-url/graphql?access_token=your-access-token',
|
||||
keepAlive: 30000,
|
||||
});
|
||||
```
|
||||
|
||||
## Rate Limiter / Messenger
|
||||
|
||||
WebSockets and GraphQL Subscriptions use the same globally-set configuration for the rate limiter and messenger.
|
||||
27
docs/guides/real-time/chat/index.md
Normal file
27
docs/guides/real-time/chat/index.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
description: Find various tutorials on building a real-time multi-user chat.
|
||||
---
|
||||
|
||||
|
||||
# Build a Multi-User Chat
|
||||
|
||||
<Card
|
||||
title="JavaScript"
|
||||
h="2"
|
||||
text="Build a multi-user chat application with vanilla JavaScript."
|
||||
url="/guides/real-time/chat/javascript"
|
||||
icon="/icons/js.svg" />
|
||||
|
||||
<Card
|
||||
title="Vue.js"
|
||||
h="2"
|
||||
text="Build a multi-user chat application with Vue.js."
|
||||
url="/guides/real-time/chat/vue"
|
||||
icon="/icons/vue.svg" />
|
||||
|
||||
<Card
|
||||
title="React.js"
|
||||
h="2"
|
||||
text="Build a multi-user chat application with React.js."
|
||||
url="/guides/real-time/chat/react"
|
||||
icon="/icons/react.svg" />
|
||||
298
docs/guides/real-time/chat/javascript.md
Normal file
298
docs/guides/real-time/chat/javascript.md
Normal file
@@ -0,0 +1,298 @@
|
||||
---
|
||||
contributors: Kevin Lewis
|
||||
description: Learn how to build a real-time multi-user chat with WebSockets and JavaScript.
|
||||
---
|
||||
|
||||
# Build a Multi-User Chat With JavaScript
|
||||
|
||||
In this guide, you will build a multi-user chat application with Directus’ WebSockets interface that authenticate users with an existing account, show historical messages stored in Directus, allow users to send new messages, and immediately update all connected chats.
|
||||
|
||||
## Before You Start
|
||||
|
||||
### Set Up Your Directus Project
|
||||
You will need a Directus project. If you don’t already have one, the easiest way to get started is with our [managed Directus Cloud service](https://directus.cloud).
|
||||
|
||||
Create a new collection called `messages`, with `date_created` and `user_created` fields enabled in the *Optional System Fields* pane on collection creation. Create a text field called `text`.
|
||||
|
||||
Create a new Role called `Users`, and give Create and Read access to the `Messages` collection, and Read access to the `Directus Users` system collection. Create a new user with this role. Make note of the password you set.
|
||||
|
||||
### Create an HTML Boilerplate
|
||||
|
||||
Create an `index.html` file and open it in your code editor:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<form id="login">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password">
|
||||
<input type="submit">
|
||||
</form>
|
||||
|
||||
<ol></ol>
|
||||
|
||||
<form id="new">
|
||||
<label for="message">Message</label>
|
||||
<input type="text" id="text">
|
||||
<input type="submit">
|
||||
</form>
|
||||
|
||||
<script>
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
The first form will handle user login and the second will handle new message submissions. The empty `<ol>` will be populated with messages.
|
||||
|
||||
Inside of the `<script>`, create a `url` variable being sure to replace `your-directus-url` with your project’s URL:
|
||||
|
||||
```js
|
||||
const url = 'wss://your-directus-url/websocket';
|
||||
let connection;
|
||||
```
|
||||
|
||||
The `connection` variable will later contain a WebSocket instance.
|
||||
|
||||
Finally, create event listeners which are triggered on the form submissions:
|
||||
|
||||
```js
|
||||
document.querySelector('#login').addEventListener('submit', function(event) {
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
document.querySelector('#new').addEventListener('submit', function(event) {
|
||||
event.preventDefault();
|
||||
});
|
||||
```
|
||||
|
||||
## Establish WebSocket Connection
|
||||
|
||||
Within the `#login` form submit event handler, extract the `email` and `password` values from the form:
|
||||
|
||||
```js
|
||||
const email = event.target.elements.email.value;
|
||||
const password = event.target.elements.password.value;
|
||||
```
|
||||
|
||||
Create a new WebSocket, which will immediately attempt connection:
|
||||
|
||||
```js
|
||||
connection = new WebSocket(url);
|
||||
```
|
||||
|
||||
On connection, you must [send an authentication message before the timeout](/guides/real-time/authentication). Add an event handler for the connection's `open` event:
|
||||
|
||||
```js
|
||||
connection.addEventListener('open', function() {
|
||||
connection.send(JSON.stringify({
|
||||
type: 'auth',
|
||||
email,
|
||||
password
|
||||
}));
|
||||
});
|
||||
```
|
||||
|
||||
## Subscribe To Messages
|
||||
|
||||
In a WebSocket connection, all data sent from the server will trigger the connection’s `message` event. Underneath the `open` event handler, add the following:
|
||||
|
||||
```js
|
||||
connection.addEventListener('message', function(message) {
|
||||
receiveMessage(message);
|
||||
});
|
||||
```
|
||||
|
||||
At the bottom of your `<script>`, create the `receiveMessage` function:
|
||||
|
||||
```js
|
||||
function receiveMessage(message) {
|
||||
const data = JSON.parse(message.data);
|
||||
};
|
||||
```
|
||||
|
||||
As soon as you have successfully authenticated, a message will be sent. When this happens, subscribe to updates on the `Messages` collection. Add this inside of the `receiveMessage` function:
|
||||
|
||||
```js
|
||||
if (data.type == 'auth' && data.status == 'ok') {
|
||||
connection.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
collection: 'messages',
|
||||
query: {
|
||||
fields: ['*', 'user_created.first_name'],
|
||||
sort: 'date_created'
|
||||
}
|
||||
}));
|
||||
};
|
||||
```
|
||||
|
||||
When a subscription is started, a message will be sent to confirm. Add this inside of the `receiveMessage` function:
|
||||
|
||||
```js
|
||||
if (data.type == 'subscription' && data.event == 'init') {
|
||||
console.log('subscription started');
|
||||
}
|
||||
```
|
||||
|
||||
*Open your `index.html` file in your browser, enter your user’s email and password, submit, and check the browser console for this console log.*
|
||||
|
||||
## Create New Messages
|
||||
Within the `#new` form submit event handler, send a new message to create the item in your Directus collection:
|
||||
|
||||
```js
|
||||
document.querySelector('#new').addEventListener('submit', function(event) {
|
||||
event.preventDefault();
|
||||
const text = event.target.elements.text.value; // [!code ++]
|
||||
connection.send(JSON.stringify({ // [!code ++]
|
||||
type: 'items', // [!code ++]
|
||||
collection: 'messages', // [!code ++]
|
||||
action: 'create', // [!code ++]
|
||||
data: { text } // [!code ++]
|
||||
})); // [!code ++]
|
||||
document.querySelector('#text').value = ''; // [!code ++]
|
||||
});
|
||||
```
|
||||
|
||||
*Refresh your browser, login, and submit a new message. Check the `Messages` collection in your Directus project and you should see a new item.*
|
||||
|
||||

|
||||
|
||||
## Display New Messages
|
||||
At the bottom of your `<script>`, create an `addMessageToList` function:
|
||||
|
||||
```js
|
||||
function addMessageToList(message) {
|
||||
const li = document.createElement('li');
|
||||
li.setAttribute('id', message.id);
|
||||
li.textContent = `${message.user_created.first_name}: ${message.text}`;
|
||||
document.querySelector('ol').appendChild(li);
|
||||
};
|
||||
```
|
||||
|
||||
In your `receiveMessage` function, listen for new `create` events on the `Messages` collection:
|
||||
|
||||
```js
|
||||
if (data.type == 'subscription' && data.event == 'create') {
|
||||
addMessageToList(data.data[0]);
|
||||
}
|
||||
```
|
||||
|
||||
*Refresh your browser, login, and submit a new message. The result should be shown on the page. Open a second browser and navigate to your index.html file, login and submit a message there and both pages should immediately update*
|
||||
|
||||

|
||||
|
||||
## Display Historical Messages
|
||||
|
||||
Replace the `console.log()` you created when the subscription is initialized:
|
||||
|
||||
```js
|
||||
if (data.type == 'subscription' && data.event == 'init') {
|
||||
console.log('subscription started'); // [!code --]
|
||||
for (const message of data.data) { // [!code ++]
|
||||
addMessageToList(message); // [!code ++]
|
||||
} // [!code ++]
|
||||
}
|
||||
```
|
||||
|
||||
Refresh your browser, login, and you should see the existing messages shown in your browser.
|
||||
|
||||
## Next Steps
|
||||
|
||||
This guide covers authentication, item creation, and subscription using WebSockets. You may consider:
|
||||
|
||||
1. Hiding the login form and only showing the new message form once authenticated.
|
||||
2. Handling reconnection logic if the client disconnects or a refresh token is needed.
|
||||
3. Locking down permissions so users can only see user first names.
|
||||
4. Allow for editing and deletion of messages by the author or by an admin.
|
||||
|
||||
## Full Code Sample
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<form id="login">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password">
|
||||
<input type="submit">
|
||||
</form>
|
||||
|
||||
<ol></ol>
|
||||
|
||||
<form id="new">
|
||||
<label for="message">Message</label>
|
||||
<input type="text" id="text">
|
||||
<input type="submit">
|
||||
</form>
|
||||
|
||||
<script>
|
||||
const url = 'wss://your-directus-url/websocket';
|
||||
let connection;
|
||||
|
||||
document.querySelector('#login').addEventListener('submit', function(event) {
|
||||
event.preventDefault();
|
||||
const email = event.target.elements.email.value;
|
||||
const password = event.target.elements.password.value;
|
||||
connection = new WebSocket(url);
|
||||
connection.addEventListener('open', function() {
|
||||
connection.send(JSON.stringify({
|
||||
type: 'auth',
|
||||
email,
|
||||
password
|
||||
}));
|
||||
});
|
||||
connection.addEventListener('message', function(message) {
|
||||
receiveMessage(message);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector('#new').addEventListener('submit', function(event) {
|
||||
event.preventDefault();
|
||||
const text = event.target.elements.text.value;
|
||||
connection.send(JSON.stringify({
|
||||
type: 'items',
|
||||
collection: 'messages',
|
||||
action: 'create',
|
||||
data: { text }
|
||||
}));
|
||||
document.querySelector('#text').value = '';
|
||||
});
|
||||
|
||||
function receiveMessage(message) {
|
||||
const data = JSON.parse(message.data);
|
||||
if (data.type == 'auth' && data.status == 'ok') {
|
||||
connection.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
collection: 'messages',
|
||||
query: {
|
||||
fields: ['*', 'user_created.first_name'],
|
||||
sort: 'date_created'
|
||||
}
|
||||
}));
|
||||
}
|
||||
if (data.type == 'subscription' && data.event == 'init') {
|
||||
for (const message of data.data) {
|
||||
addMessageToList(message);
|
||||
}
|
||||
}
|
||||
if (data.type == 'subscription' && data.event == 'create') {
|
||||
addMessageToList(data.data[0]);
|
||||
}
|
||||
};
|
||||
|
||||
function addMessageToList(message) {
|
||||
const li = document.createElement('li');
|
||||
li.setAttribute('id', message.id);
|
||||
li.textContent = `${message.user_created.first_name}: ${message.text}`;
|
||||
document.querySelector('ol').appendChild(li);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
416
docs/guides/real-time/chat/react.md
Normal file
416
docs/guides/real-time/chat/react.md
Normal file
@@ -0,0 +1,416 @@
|
||||
---
|
||||
contributors: Esther Agbaje
|
||||
description: Learn how to build a real-time multi-user chat with WebSockets and React.js.
|
||||
---
|
||||
|
||||
# Build a Multi-User Chat With React.js
|
||||
|
||||
In this guide, you will build a multi-user chat application with Directus’ WebSockets interface that authenticates users
|
||||
with an existing account, shows historical messages stored in Directus, allows users to send new messages, and
|
||||
immediately updates all connected chats.
|
||||
|
||||
## Before You Start
|
||||
|
||||
### Set Up Your Directus Project
|
||||
|
||||
You will need a Directus project. If you don’t already have one, the easiest way to get started is with our
|
||||
[managed Directus Cloud service](https://directus.cloud).
|
||||
|
||||
Create a new collection called `messages`, with `date_created` and `user_created` fields enabled in the _Optional System
|
||||
Fields_ pane on collection creation. Create a text field called `text`.
|
||||
|
||||
Create a new Role called `Users`. Give Create and Read access to the `Messages` collection, and Read access to the
|
||||
`Directus Users` system collection. Now, create a new user with this role and take note of the password you set.
|
||||
|
||||
### Create a React.js Boilerplate
|
||||
|
||||
```js
|
||||
function App() {
|
||||
return (
|
||||
<div className="App">
|
||||
<form>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input type="email" id="email" />
|
||||
<label htmlFor="password">Password</label>
|
||||
<input type="password" id="password" />
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
|
||||
<ol></ol>
|
||||
|
||||
<form>
|
||||
<label htmlFor="message">Message</label>
|
||||
<input type="text" id="message" />
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The first form will handle user login, the second will handle new message submissions while the empty `<ol>` will be
|
||||
populated with messages we will create shortly.
|
||||
|
||||
Create a `url` variable and be sure to replace `your-directus-url` with your project’s URL:
|
||||
|
||||
```js
|
||||
const url = 'wss://your-directus-url/websocket';
|
||||
```
|
||||
|
||||
Now, create a variable called `connectionRef` that has an initial null value. The `connectionRef` will later contain a
|
||||
WebSocket instance.
|
||||
|
||||
```js
|
||||
const connectionRef = useRef(null);
|
||||
```
|
||||
|
||||
## Set Up Form Submission Methods
|
||||
|
||||
Create the methods for form submissions:
|
||||
|
||||
```js
|
||||
const loginSubmit = (event) => {
|
||||
};
|
||||
|
||||
const messageSubmit = (event) => {
|
||||
};
|
||||
```
|
||||
|
||||
Ensure to call the `event.preventDefault()` in these methods to prevent the browser from refreshing the page upon
|
||||
submission of the form.
|
||||
|
||||
```js
|
||||
const loginSubmit = (event) => {
|
||||
event.preventDefault(); // [!code ++]
|
||||
};
|
||||
|
||||
const messageSubmit = (event) => {
|
||||
event.preventDefault(); // [!code ++]
|
||||
};
|
||||
```
|
||||
|
||||
## Establish WebSocket Connection
|
||||
|
||||
At the top of your component, create a piece of state to hold the `email` and `password` values of the login form:
|
||||
|
||||
```js
|
||||
const [formValue, setFormValue] = useState({ email: '', password: '' });
|
||||
```
|
||||
|
||||
Set up a `handleLoginChange` method that updates the value of the login input field as the user types.
|
||||
|
||||
```js
|
||||
const handleLoginChange = (event) => {
|
||||
setFormValue({ ...formValue, [event.target.name]: event.target.value });
|
||||
};
|
||||
```
|
||||
|
||||
Then, connect these values to the form input fields:
|
||||
|
||||
```js
|
||||
<form onSubmit={loginSubmit}>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input type="email" id="email" /> // [!code --]
|
||||
<input type="email" id="email" name="email" value={formValue.email} onChange={handleLoginChange} /> // [!code ++]
|
||||
<label htmlFor="password">Password</label>
|
||||
<input type="password" id="password" /> // [!code --]
|
||||
<input type="password" id="password" name="password" value={formValue.password} onChange={handleLoginChange} /> // [!code ++]
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
Within the `loginSubmit` method, create a new WebSocket, which will immediately attempt connection:
|
||||
|
||||
```js
|
||||
const loginSubmit = (event) => {
|
||||
connectionRef.current = new WebSocket(url); // [!code ++]
|
||||
};
|
||||
```
|
||||
|
||||
On connection, you must [send an authentication message before the timeout](/guides/real-time/authentication). Add an
|
||||
event handler for the connection's `open` event:
|
||||
|
||||
```js
|
||||
const loginSubmit = (event) => {
|
||||
connectionRef.current = new WebSocket(url);
|
||||
connectionRef.current.addEventListener('open', authenticate(formValue)); // [!code ++]
|
||||
};
|
||||
```
|
||||
|
||||
Then, create a new `authenticate` method:
|
||||
|
||||
```js
|
||||
const authenticate = (opts) => {
|
||||
const { email, password } = opts;
|
||||
connectionRef.current.send(JSON.stringify({ type: 'auth', email, password }));
|
||||
};
|
||||
```
|
||||
|
||||
### Subscribe to Messages
|
||||
|
||||
In a WebSocket connection, all data sent from the server will trigger the connection’s `message` event. Inside
|
||||
`loginSubmit`, add an event handler:
|
||||
|
||||
```js
|
||||
const loginSubmit = (event) => {
|
||||
connectionRef.current = new WebSocket(url);
|
||||
connectionRef.current.addEventListener('open', authenticate(formValue));
|
||||
connectionRef.current.addEventListener('message', (message) => receiveMessage(message)); // [!code ++]
|
||||
};
|
||||
```
|
||||
|
||||
Then, create a new `receiveMessage` method:
|
||||
|
||||
```js
|
||||
const receiveMessage = (message) => {
|
||||
const data = JSON.parse(message.data);
|
||||
};
|
||||
```
|
||||
|
||||
As soon as you have successfully authenticated, a message will be sent. When this happens, subscribe to updates on the
|
||||
`Messages` collection. Add this inside of the `receiveMessage` method:
|
||||
|
||||
```js
|
||||
const receiveMessage = (message) => {
|
||||
const data = JSON.parse(message.data);
|
||||
if (data.type === 'auth' && data.status === 'ok') { // [!code ++]
|
||||
connectionRef.current.send( // [!code ++]
|
||||
JSON.stringify({ // [!code ++]
|
||||
type: 'subscribe', // [!code ++]
|
||||
collection: 'messages', // [!code ++]
|
||||
query: { // [!code ++]
|
||||
fields: ['*', 'user_created.first_name'], // [!code ++]
|
||||
sort: 'date_created', // [!code ++]
|
||||
}, // [!code ++]
|
||||
}) // [!code ++]
|
||||
); // [!code ++]
|
||||
} // [!code ++]
|
||||
};
|
||||
```
|
||||
|
||||
When a subscription is started, a message will be sent to confirm. Add this inside of the `receiveMessage` method:
|
||||
|
||||
```js {15-17}
|
||||
const receiveMessage = (message) => {
|
||||
const data = JSON.parse(message.data);
|
||||
if (data.type === 'auth' && data.status === 'ok') {
|
||||
connectionRef.current.send(
|
||||
JSON.stringify({
|
||||
type: 'subscribe',
|
||||
collection: 'messages',
|
||||
query: {
|
||||
fields: ['*', 'user_created.first_name'],
|
||||
sort: 'date_created',
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
if (data.type === 'subscription' && data.event === 'init') { // [!code ++]
|
||||
console.log('subscription started'); // [!code ++]
|
||||
} // [!code ++]
|
||||
};
|
||||
```
|
||||
|
||||
Open your browser, enter your user’s email and password, and hit submit. Check the browser console. You should see
|
||||
“subscription started”
|
||||
|
||||
## Create New Messages
|
||||
|
||||
At the top of your component, set up two pieces of state to hold messages: one to keep track of new messages and another
|
||||
to store an array of previous message history.
|
||||
|
||||
```js
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [messageHistory, setMessageHistory] = useState([]);
|
||||
```
|
||||
|
||||
Create a `handleMessageChange` method that updates the value of the message input field as the user types.
|
||||
|
||||
```js
|
||||
const handleMessageChange = (event) => {
|
||||
setNewMessage(event.target.value);
|
||||
};
|
||||
```
|
||||
|
||||
Then, connect these values to the form input fields:
|
||||
|
||||
```js
|
||||
<form onSubmit={messageSubmit}>
|
||||
<label htmlFor="message">Message</label>
|
||||
<input type="text" id="message" /> // [!code --]
|
||||
<input type="text" id="message" name="message" value={newMessage} onChange={handleMessageChange} /> // [!code ++]
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
Within the `messageSubmit` method, send a new message to create the item in your Directus collection:
|
||||
|
||||
```js
|
||||
const messageSubmit = (event) => {
|
||||
connectionRef.current.send(
|
||||
JSON.stringify({
|
||||
type: 'items',
|
||||
collection: 'messages',
|
||||
action: 'create',
|
||||
data: { text: newMessage },
|
||||
})
|
||||
);
|
||||
setNewMessage('');
|
||||
};
|
||||
```
|
||||
|
||||
_Refresh your browser, login, and submit a new message. Check the `Messages` collection in your Directus project and you
|
||||
should see a new item._
|
||||
|
||||

|
||||
|
||||
## Display New Messages
|
||||
|
||||
In your `receiveMessage` function, listen for new `create` events on the `Messages` collection, and add them to
|
||||
`messageHistory`:
|
||||
|
||||
```js
|
||||
if (data.type === 'subscription' && data.event === 'create') {
|
||||
setMessageHistory((history) => [...history, data.data[0]]);
|
||||
}
|
||||
```
|
||||
|
||||
Update your `<ol>` to display items in the array by mapping over `messageHistory`
|
||||
|
||||
```js
|
||||
<ol>
|
||||
{messageHistory.map((message) => (
|
||||
<li key={message.id}>
|
||||
{message.user_created.first_name}: {message.text}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
```
|
||||
|
||||
_Refresh your browser, login, and submit a new message. The result should be shown on the page. Open a second browser
|
||||
and navigate to your index.html file, login and submit a message there and both pages should immediately update_
|
||||
|
||||

|
||||
|
||||
## Display Historical Messages
|
||||
|
||||
Replace the `console.log()` you created when the subscription is initialized:
|
||||
|
||||
```js
|
||||
if (data.type === 'subscription' && data.event === 'init') {
|
||||
console.log('subscription started'); // [!code --]
|
||||
for (const message of data.data) { // [!code ++]
|
||||
setMessageHistory((history) => [...history, message]); // [!code ++]
|
||||
} // [!code ++]
|
||||
}
|
||||
```
|
||||
|
||||
Refresh your browser, login, and you should see the existing messages shown in your browser.
|
||||
|
||||
## Next Steps
|
||||
|
||||
This guide covers authentication, item creation, and subscription using WebSockets. You may consider:
|
||||
|
||||
1. Hiding the login form and only showing the new message form once authenticated.
|
||||
2. Handling reconnection logic if the client disconnects or a refresh token is needed.
|
||||
3. Locking down permissions so users can only see user first names.
|
||||
4. Allow for editing and deletion of messages by the author or by an admin.
|
||||
|
||||
## Full Code Sample
|
||||
|
||||
```js
|
||||
import { useState, useRef } from 'react';
|
||||
|
||||
const url = 'wss://your-directus-url/websocket';
|
||||
|
||||
export default function App() {
|
||||
const [formValue, setFormValue] = useState({ email: '', password: '' });
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [messageHistory, setMessageHistory] = useState([]);
|
||||
|
||||
const connectionRef = useRef(null);
|
||||
|
||||
const authenticate = (opts) => {
|
||||
const { email, password } = opts;
|
||||
connectionRef.current.send(JSON.stringify({ type: 'auth', email, password }));
|
||||
};
|
||||
|
||||
const loginSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
connectionRef.current = new WebSocket(url);
|
||||
connectionRef.current.addEventListener('open', authenticate(formValue));
|
||||
connectionRef.current.addEventListener('message', (message) => receiveMessage)(message);
|
||||
};
|
||||
|
||||
const receiveMessage = (message) => {
|
||||
const data = JSON.parse(message.data);
|
||||
if (data.type == 'auth' && data.status == 'ok') {
|
||||
connectionRef.current.send(
|
||||
JSON.stringify({
|
||||
type: 'subscribe',
|
||||
collection: 'messages',
|
||||
query: {
|
||||
fields: ['*', 'user_created.first_name'],
|
||||
sort: 'date_created',
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
if (data.type === 'subscription' && data.event === 'init') {
|
||||
for (const message of data.data) {
|
||||
setMessageHistory((history) => [...history, message]);
|
||||
}
|
||||
}
|
||||
if (data.type === 'subscription' && data.event === 'create') {
|
||||
setMessageHistory((history) => [...history, data.data[0]]);
|
||||
}
|
||||
};
|
||||
|
||||
const messageSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
connectionRef.current.send(
|
||||
JSON.stringify({
|
||||
type: 'items',
|
||||
collection: 'messages',
|
||||
action: 'create',
|
||||
data: { text: newMessage },
|
||||
})
|
||||
);
|
||||
setNewMessage('');
|
||||
};
|
||||
|
||||
const handleLoginChange = (event) => {
|
||||
setFormValue({ ...formValue, [event.target.name]: event.target.value });
|
||||
};
|
||||
|
||||
const handleMessageChange = (event) => {
|
||||
setNewMessage(event.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<form onSubmit={loginSubmit}>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input type="email" id="email" name="email" value={formValue.email} onChange={handleLoginChange} />
|
||||
<label htmlFor="password">Password</label>
|
||||
<input type="password" id="password" name="password" value={formValue.password} onChange={handleLoginChange} />
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
|
||||
<ol>
|
||||
{messageHistory.map((message) => (
|
||||
<li key={message.id}>
|
||||
{message.user_created.first_name}: {message.text}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<form onSubmit={messageSubmit}>
|
||||
<label htmlFor="message">Message</label>
|
||||
<input type="text" id="message" name="message" value={newMessage} onChange={handleMessageChange} />
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
399
docs/guides/real-time/chat/vue.md
Normal file
399
docs/guides/real-time/chat/vue.md
Normal file
@@ -0,0 +1,399 @@
|
||||
---
|
||||
contributors: Kevin Lewis
|
||||
description: Learn how to build a real-time multi-user chat with WebSockets and Vue.js.
|
||||
---
|
||||
|
||||
# Build a Multi-User Chat With Vue.js
|
||||
|
||||
In this guide, you will build a multi-user chat application with Directus’ WebSockets interface that authenticate users with an existing account, show historical messages stored in Directus, allow users to send new messages, and immediately update all connected chats.
|
||||
|
||||
## Before You Start
|
||||
|
||||
### Set Up Your Directus Project
|
||||
|
||||
You will need a Directus project. If you don’t already have one, the easiest way to get started is with our [managed Directus Cloud service](https://directus.cloud).
|
||||
|
||||
Create a new collection called `messages`, with `date_created` and `user_created` fields enabled in the *Optional System Fields* pane on collection creation. Create a text field called `text`.
|
||||
|
||||
Create a new Role called `Users`, and give Create and Read access to the `Messages` collection, and Read access to the `Directus Users` system collection. Create a new user with this role. Make note of the password you set.
|
||||
|
||||
### Create a Vue.js Boilerplate
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="app">
|
||||
<form>
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password">
|
||||
<input type="submit">
|
||||
</form>
|
||||
|
||||
<ol>
|
||||
</ol>
|
||||
|
||||
<form>
|
||||
<label for="message">Message</label>
|
||||
<input type="text" id="message">
|
||||
<input type="submit">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<script>
|
||||
const { createApp } = Vue;
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
```
|
||||
|
||||
The first form will handle user login and the second will handle new message submissions. The empty `<ol>` will be populated with messages.
|
||||
|
||||
Inside of Vue's `data` object, create a `url` property being sure to replace `your-directus-url` with your project’s URL:
|
||||
|
||||
```js
|
||||
data() {
|
||||
return {
|
||||
url: 'wss://your-directus-url/websocket', // [!code ++]
|
||||
connection: null, // [!code ++]
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
The `connection` property will later contain a WebSocket instance.
|
||||
|
||||
## Set Up Form Submission Methods
|
||||
|
||||
Create the methods for form submissions:
|
||||
|
||||
```js
|
||||
methods: {
|
||||
loginSubmit() { // [!code ++]
|
||||
}, // [!code ++]
|
||||
messageSubmit() { // [!code ++]
|
||||
} // [!code ++]
|
||||
}
|
||||
```
|
||||
|
||||
Then, ensure these methods are called on form submissions by using the `@submit.prevent` directive:
|
||||
|
||||
```html
|
||||
<!-- Login form -->
|
||||
<form> // [!code --]
|
||||
<form @submit.prevent="loginSubmit"> // [!code ++]
|
||||
<label for="email">Email</label>
|
||||
|
||||
<!-- Message form -->
|
||||
<form> // [!code --]
|
||||
<form @submit.prevent="messageSubmit"> // [!code ++]
|
||||
<label for="message">Message</label>
|
||||
```
|
||||
|
||||
## Establish WebSocket Connection
|
||||
|
||||
First, create a new `login` object in your Vue application's `data` object. It will contain form data:
|
||||
|
||||
```js
|
||||
data() {
|
||||
return {
|
||||
url: 'wss://your-directus-url/websocket',
|
||||
connection: null,
|
||||
form: {}, // [!code ++]
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
Then, bind the login form's inputs to `form`:
|
||||
|
||||
```html
|
||||
<form>
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email"> // [!code --]
|
||||
<input v-model="form.email" type="email" id="email"> // [!code ++]
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password"> // [!code --]
|
||||
<input v-model="form.password" type="password" id="password"> // [!code ++]
|
||||
<input type="submit">
|
||||
</form>
|
||||
```
|
||||
|
||||
Within the `loginSubmit` method, create a new WebSocket, which will immediately attempt connection:
|
||||
|
||||
```js
|
||||
loginSubmit() {
|
||||
this.connection = new WebSocket(this.url); // [!code ++]
|
||||
},
|
||||
```
|
||||
|
||||
On connection, you must [send an authentication message before the timeout](/guides/real-time/authentication). Add an event handler for the connection's `open` event:
|
||||
|
||||
```js
|
||||
loginSubmit() {
|
||||
this.connection = new WebSocket(this.url);
|
||||
this.connection.addEventListener('open', this.authenticate(this.form)); // [!code ++]
|
||||
},
|
||||
```
|
||||
|
||||
Then, create a new `authenticate` method:
|
||||
|
||||
```js
|
||||
authenticate(opts) {
|
||||
const { email, password } = opts;
|
||||
this.connection.send(JSON.stringify({ type: 'auth', email, password }));
|
||||
},
|
||||
```
|
||||
|
||||
### Subscribe to messages
|
||||
|
||||
In a WebSocket connection, all data sent from the server will trigger the connection’s `message` event. Inside `loginSubmit`, add an event handler:
|
||||
|
||||
```js
|
||||
loginSubmit() {
|
||||
this.connection = new WebSocket(this.url);
|
||||
this.connection.addEventListener('open', this.authenticate(this.login));
|
||||
this.connection.addEventListener('message', message => this.receiveMessage(message)); // [!code ++]
|
||||
},
|
||||
```
|
||||
|
||||
Then, create a new `receiveMessage` method:
|
||||
|
||||
```js
|
||||
receiveMessage(message) {
|
||||
const data = JSON.parse(message.data);
|
||||
}
|
||||
```
|
||||
|
||||
As soon as you have successfully authenticated, a message will be sent. When this happens, subscribe to updates on the `Messages` collection. Add this inside of the `receiveMessage` method:
|
||||
|
||||
```js
|
||||
receiveMessage(message) {
|
||||
const data = JSON.parse(message.data);
|
||||
if (data.type == 'auth' && data.status == 'ok') { // [!code ++]
|
||||
connection.send(JSON.stringify({ // [!code ++]
|
||||
type: 'subscribe', // [!code ++]
|
||||
collection: 'messages', // [!code ++]
|
||||
query: { // [!code ++]
|
||||
fields: ['*', 'user_created.first_name'], // [!code ++]
|
||||
sort: 'date_created' // [!code ++]
|
||||
} // [!code ++]
|
||||
})); // [!code ++]
|
||||
} // [!code ++]
|
||||
}
|
||||
```
|
||||
|
||||
When a subscription is started, a message will be sent to confirm. Add this inside of the `receiveMessage` method:
|
||||
|
||||
```js
|
||||
receiveMessage(message) {
|
||||
const data = JSON.parse(message.data);
|
||||
if (data.type == 'auth' && data.status == 'ok') {
|
||||
this.connection.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
collection: 'messages',
|
||||
query: {
|
||||
fields: ['*', 'user_created.first_name'],
|
||||
sort: 'date_created'
|
||||
}
|
||||
})) ;
|
||||
}
|
||||
if (data.type == 'subscription' && data.event == 'init') { // [!code ++]
|
||||
console.log('subscription started'); // [!code ++]
|
||||
} // [!code ++]
|
||||
}
|
||||
```
|
||||
|
||||
*Open your `index.html` file in your browser, enter your user’s email and password, submit, and check the browser console for this console log.*
|
||||
|
||||
## Create New Messages
|
||||
|
||||
First, create a new `messages` object in your Vue application's `data` object with two properties: a `new` string to be bound to the input, and a `history` array to contain existing messages:
|
||||
|
||||
```js
|
||||
data() {
|
||||
return {
|
||||
url: 'wss://your-directus-url/websocket',
|
||||
connection: null,
|
||||
form: {},
|
||||
messages: { // [!code ++]
|
||||
new: '', // [!code ++]
|
||||
history: [] // [!code ++]
|
||||
} // [!code ++]
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
Then, bind the login form's inputs to `form` properties:
|
||||
|
||||
```html
|
||||
<form @submit.prevent="messageSubmit">
|
||||
<label for="message">Message</label>
|
||||
<input type="text" id="message"> // [!code --]
|
||||
<input v-model="messages.new" type="text" id="message"> // [!code ++]
|
||||
<input type="submit">
|
||||
</form>
|
||||
```
|
||||
|
||||
Within the `messageSubmit` method, send a new message to create the item in your Directus collection:
|
||||
|
||||
```js
|
||||
messageSubmit() {
|
||||
this.connection.send(JSON.stringify({ // [!code ++]
|
||||
type: 'items', // [!code ++]
|
||||
collection: 'messages', // [!code ++]
|
||||
action: 'create', // [!code ++]
|
||||
data: { text: this.messages.new } // [!code ++]
|
||||
})); // [!code ++]
|
||||
this.messages.new = ''; // [!code ++]
|
||||
}
|
||||
```
|
||||
|
||||
*Refresh your browser, login, and submit a new message. Check the `Messages` collection in your Directus project and you should see a new item.*
|
||||
|
||||

|
||||
|
||||
## Display New Messages
|
||||
|
||||
In your `receiveMessage` function, listen for new `create` events on the `Messages` collection, and add them to `messages.history`:
|
||||
|
||||
```js
|
||||
if (data.type == 'subscription' && data.event == 'create') {
|
||||
this.messages.history.push(data.data[0]);
|
||||
}
|
||||
```
|
||||
|
||||
Update your `<ol>` to display items in the array:
|
||||
|
||||
```html
|
||||
<ol>
|
||||
<li v-for="message in messages.history" :key="message.id"> // [!code ++]
|
||||
{{ message.user_created.first_name }}: {{ message.text }} // [!code ++]
|
||||
</li> // [!code ++]
|
||||
</ol>
|
||||
```
|
||||
|
||||
*Refresh your browser, login, and submit a new message. The result should be shown on the page. Open a second browser and navigate to your index.html file, login and submit a message there and both pages should immediately update*
|
||||
|
||||

|
||||
|
||||
## Display Historical Messages
|
||||
|
||||
Replace the `console.log()` you created when the subscription is initialized:
|
||||
|
||||
```js
|
||||
if (data.type == 'subscription' && data.event == 'init') {
|
||||
console.log('subscription started'); // [!code --]
|
||||
for (const message of data.data) { // [!code ++]
|
||||
this.messages.history.push(message); // [!code ++]
|
||||
} // [!code ++]
|
||||
}
|
||||
```
|
||||
|
||||
Refresh your browser, login, and you should see the existing messages shown in your browser.
|
||||
|
||||
## Next Steps
|
||||
|
||||
This guide covers authentication, item creation, and subscription using WebSockets. You may consider:
|
||||
|
||||
1. Hiding the login form and only showing the new message form once authenticated.
|
||||
2. Handling reconnection logic if the client disconnects or a refresh token is needed.
|
||||
3. Locking down permissions so users can only see user first names.
|
||||
4. Allow for editing and deletion of messages by the author or by an admin.
|
||||
|
||||
## Full Code Sample
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="app">
|
||||
<form @submit.prevent="loginSubmit">
|
||||
<label for="email">Email</label>
|
||||
<input v-model="form.email" type="email" id="email">
|
||||
<label for="password">Password</label>
|
||||
<input v-model="form.password" type="password" id="password">
|
||||
<input type="submit">
|
||||
</form>
|
||||
|
||||
<ol>
|
||||
<li v-for="message in messages.history" :key="message.id">
|
||||
{{ message.user_created.first_name }}: {{ message.text }}
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<form @submit.prevent="messageSubmit">
|
||||
<label for="message">Message</label>
|
||||
<input v-model="messages.new" type="text" id="message">
|
||||
<input type="submit">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<script>
|
||||
const { createApp } = Vue
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
url: 'wss://your-directus-url/websocket',
|
||||
connection: null,
|
||||
form: {},
|
||||
messages: {
|
||||
new: '',
|
||||
history: []
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loginSubmit() {
|
||||
this.connection = new WebSocket(this.url);
|
||||
this.connection.addEventListener('open', this.authenticate(this.form));
|
||||
this.connection.addEventListener('message', message => this.receiveMessage(message));
|
||||
},
|
||||
messageSubmit() {
|
||||
this.connection.send(JSON.stringify({
|
||||
type: 'items',
|
||||
collection: 'messages',
|
||||
action: 'create',
|
||||
data: { text: this.messages.new }
|
||||
}));
|
||||
this.messages.new = '';
|
||||
},
|
||||
authenticate(opts) {
|
||||
const { email, password } = opts;
|
||||
this.connection.send(JSON.stringify({ type: 'auth', email, password }));
|
||||
},
|
||||
receiveMessage(message) {
|
||||
const data = JSON.parse(message.data);
|
||||
if (data.type == 'auth' && data.status == 'ok') {
|
||||
this.connection.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
collection: 'messages',
|
||||
query: {
|
||||
fields: ['*', 'user_created.first_name'],
|
||||
sort: 'date_created'
|
||||
}
|
||||
}));
|
||||
}
|
||||
if (data.type == 'subscription' && data.event == 'init') {
|
||||
for (const message of data.data) {
|
||||
this.messages.history.push(message);
|
||||
}
|
||||
}
|
||||
if (data.type == 'subscription' && data.event == 'create') {
|
||||
this.messages.history.push(data.data[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}).mount('#app')
|
||||
</script>
|
||||
</body>
|
||||
```
|
||||
91
docs/guides/real-time/getting-started/graphql.md
Normal file
91
docs/guides/real-time/getting-started/graphql.md
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
contributors: Esther Agbaje
|
||||
description: "Learn how to get started with Directus' GraphQL interface."
|
||||
---
|
||||
|
||||
|
||||
# Getting Started With GraphQL Subscriptions
|
||||
|
||||
You can connect to a Directus project and get updates on data held in a collection in real-time.
|
||||
|
||||
This guide will show you how to get started with subscribing to data using GraphQL. GraphQL is framework-agnostic, so
|
||||
you can apply the same set of steps in your stack of choice.
|
||||
|
||||
> GraphQL Subscriptions are read-only. This means you can't run `create`, `update`, or `delete` operations over a
|
||||
> connection. However, you can still use standard GraphQL queries to achieve this.
|
||||
|
||||
## Before You Begin
|
||||
|
||||
You will need a Directus project. If you don’t already have one, the easiest way to get started is with our
|
||||
[managed Directus Cloud service](https://directus.cloud). You can also self-host Directus, ensuring the
|
||||
`WEBSOCKETS_ENABLED` environment variable is set to `true`.
|
||||
|
||||
Create a new collection called `messages`, with a `date_created` field enabled in the _Optional System Fields_ pane on
|
||||
collection creation. Create the required field such as an input field called `text`.
|
||||
|
||||
If it doesn’t already exist, create a user with a role that can execute read and create operations on the collection.
|
||||
|
||||
Finally in the Directus Data Studio, create a static access token for the user, copy it, and save the user profile.
|
||||
|
||||
## Create a Connection
|
||||
|
||||
Establish a WebSocket connection between the client and server using `createClient` from `graphql-ws`. To authenticate,
|
||||
enter both `your-directus-url` and the `token` generated earlier.
|
||||
|
||||
```js
|
||||
import { createClient } from 'graphql-ws';
|
||||
|
||||
const client = createClient({
|
||||
url: 'ws://your-directus-url/graphql',
|
||||
keepAlive: 30000,
|
||||
connectionParams: async () => {
|
||||
return { access_token: 'MY_TOKEN' };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
This immediately creates a connection and ensures that only authorized clients can access the resources and real-time
|
||||
data updates.
|
||||
|
||||
[Learn more about WebSocket authentication here.](/guides/real-time/authentication)
|
||||
|
||||
## Create a Subscription
|
||||
|
||||
After subscribing to collections over your connection, you will receive real-time data changes of those collections. To subscribe to a
|
||||
`messages` collection, the query would look like this:
|
||||
|
||||
```js
|
||||
client.subscribe(
|
||||
{
|
||||
query: `
|
||||
subscription {
|
||||
messages_mutated {
|
||||
key
|
||||
event
|
||||
data {
|
||||
text
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
next: ({ data }) => {
|
||||
console.log(data);
|
||||
},
|
||||
error: (err) => {
|
||||
console.log(err);
|
||||
},
|
||||
complete: () => {},
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
> `next`, `error` and `complete` are subscription handlers required by `graphql-ws`
|
||||
|
||||
Go ahead to add new data to your `messages` collection, you should immediately receive updates on your frontend.
|
||||
|
||||
## In Summary
|
||||
|
||||
In this guide, you have successfully established a connection and created your first subscription.
|
||||
|
||||
[Learn more about subscriptions with GraphQL with Directus.](/guides/real-time/subscriptions/graphql)
|
||||
23
docs/guides/real-time/getting-started/index.md
Normal file
23
docs/guides/real-time/getting-started/index.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
description: "Learn how to get started with Directus' real-time functionality."
|
||||
---
|
||||
|
||||
|
||||
# Getting Started With Real-Time
|
||||
|
||||
Directus' WebSockets and GraphQL Subscription interfaces allow for real-time updates across clients. You can execute
|
||||
CRUD operations, as well as subscribe to changes in a collection.
|
||||
|
||||
For most users, we recommend using WebSockets. If you already use GraphQL or want to explore it, then try out GraphQL Subscriptions.
|
||||
|
||||
<Card
|
||||
title="WebSockets"
|
||||
h="2"
|
||||
text="Learn how to use Directus' WebSockets Interface."
|
||||
url="/guides/real-time/getting-started/websockets" />
|
||||
|
||||
<Card
|
||||
title="GraphQL Subscriptions"
|
||||
h="2"
|
||||
text="Learn how to use GraphQL Subscriptions."
|
||||
url="/guides/real-time/getting-started/graphql" />
|
||||
231
docs/guides/real-time/getting-started/websockets.md
Normal file
231
docs/guides/real-time/getting-started/websockets.md
Normal file
@@ -0,0 +1,231 @@
|
||||
---
|
||||
contributors: Kevin Lewis
|
||||
description: "Learn how to get started with Directus' WebSockets interface."
|
||||
---
|
||||
|
||||
# Getting Started With WebSockets
|
||||
|
||||
You can connect to a Directus project using a WebSocket interface and get updates on data held in a collection in real-time.
|
||||
|
||||
This guide will show you how to get started with Directus' WebSockets interface and JavaScript. WebSockets are language-agnostic, so you can apply the same set of steps in your stack of choice.
|
||||
|
||||
## Before You Begin
|
||||
|
||||
You will need a Directus project. If you don’t already have one, the easiest way to get started is with our [managed Directus Cloud service](https://directus.cloud). You can also self-host Directus, ensuring the `WEBSOCKETS_ENABLED` environment variable is set to `true`.
|
||||
|
||||
Create a new collection called `messages`, with a `date_created` field enabled in the *Optional System Fields* pane on collection creation. Create a text field called `text` and a second called `user`.
|
||||
|
||||
If it doesn’t already exist, create a user with a role that can execute read and create operations on the collection.
|
||||
|
||||
Finally in the Directus Data Studio, create a static access token for the user, copy it, and save the user profile.
|
||||
|
||||
Create an `index.html` file and open it in your code editor. Add the following boilerplate code:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<script>
|
||||
const url = 'wss://your-directus-url/websocket';
|
||||
const access_token = 'your-access-token';
|
||||
const collection = 'messages';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Make sure to replace `your-directus-url` and `your-access-token` with your project and user details.
|
||||
|
||||
## Create a Connection
|
||||
At the bottom of your `<script>`, add the following code to establish a new WebSocket connection:
|
||||
|
||||
```js
|
||||
const connection = new WebSocket(url);
|
||||
```
|
||||
|
||||
To add some feedback, add the following event handlers below your `connection` variable:
|
||||
|
||||
```js
|
||||
connection.addEventListener('open', function() {
|
||||
console.log({ event: 'onopen' });
|
||||
});
|
||||
|
||||
connection.addEventListener('message', function(message) {
|
||||
const { data } = JSON.parse(message);
|
||||
console.log({ event: 'onmessage', data });
|
||||
});
|
||||
|
||||
connection.addEventListener('close', function() {
|
||||
console.log({ event: 'onclose' });
|
||||
});
|
||||
|
||||
connection.addEventListener('error', function(error) {
|
||||
console.log({ event: 'onerror', error });
|
||||
});
|
||||
```
|
||||
|
||||
Open `index.html` in your browser and open the Developer Tools. You should see the `onopen` event logged in the console.
|
||||
|
||||
## Authenticate Your Connection
|
||||
Once a connection is opened, you have to send a message to authenticate your session. If you don't, you'll receive a message indicating there was an authentication failure.
|
||||
|
||||
```js
|
||||
connection.addEventListener('open', function() {
|
||||
console.log({ event: 'onopen' });
|
||||
connection.send(JSON.stringify({ // [!code ++]
|
||||
type: 'auth', // [!code ++]
|
||||
access_token // [!code ++]
|
||||
})); // [!code ++]
|
||||
});
|
||||
```
|
||||
|
||||
You should immediately receive a message in return to confirm. The connection is now authenticated and will remain open, ready to send and receive data.
|
||||
|
||||
[Learn more about WebSocket authentication here.](/guides/real-time/authentication)
|
||||
|
||||
## Create a Subscription
|
||||
After subscribing to collections over your connection, you will receive new messages whenever items in the collection are created, updated, or deleted.
|
||||
|
||||
At the bottom of your `<script>`, create a new function which subscribes to a collection:
|
||||
|
||||
```js
|
||||
function subscribe() {
|
||||
connection.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
collection: 'messages',
|
||||
query: { fields: ['*'] }
|
||||
}));
|
||||
};
|
||||
```
|
||||
|
||||
Save your file, refresh your browser, and open your browser console. Run this function by typing:
|
||||
|
||||
```js
|
||||
subscribe();
|
||||
```
|
||||
|
||||
You will receive a message in response to confirm the subscription has been initialized. Then, new messages will be sent when there’s an update on the collection.
|
||||
|
||||
## Create Item
|
||||
Create a new function that sends a message over the connection with a `create` action:
|
||||
|
||||
```js
|
||||
function createItem(text, user) {
|
||||
connection.send(JSON.stringify({
|
||||
type: 'items',
|
||||
collection: 'messages',
|
||||
action: 'create',
|
||||
data: { text, user }
|
||||
}));
|
||||
};
|
||||
```
|
||||
|
||||
Save your file, refresh your browser, and open your browser console. Create a few new items by using your new function directly in the console:
|
||||
|
||||
```js
|
||||
createItem('Hello World!', 'Ben');
|
||||
createItem('Hello Universe!', 'Rijk');
|
||||
createItem('Hello Everyone Everywhere All At Once!', 'Kevin');
|
||||
```
|
||||
|
||||
Every time you create an item, you will receive a message in response with the new item as created in your Directus collection.
|
||||
|
||||

|
||||
|
||||
|
||||
## Get Latest Item
|
||||
You can use your connection to perform all CRUD actions by using `type: 'items'` in the payload and including the respective `action`. Create a new function for reading the latest message:
|
||||
|
||||
```js
|
||||
function readLatestItem() {
|
||||
connection.send(JSON.stringify({
|
||||
type: 'items',
|
||||
collection: 'messages',
|
||||
action: 'read',
|
||||
query: { limit: 1, sort: '-date_created' }
|
||||
}));
|
||||
};
|
||||
```
|
||||
|
||||
Send the message over the connection by entering `readLatestItem()` your browser console. You will receive a message with the result of your query on the collection.
|
||||
|
||||
## Pings To Keep Connection Active
|
||||
You may have noticed that, periodically, you will receive a message with a type of `ping`. This serves two purposes:
|
||||
|
||||
1. To act as a periodic message to stop your connection from closing due to inactivity. This may be required by your application technology stack.
|
||||
2. To verify that the connection is still active.
|
||||
|
||||
On Directus Cloud, this feature is enabled. If you are self-hosting, you can alter this behavior with the `WEBSOCKETS_HEARTBEAT_ENABLED` and `WEBSOCKETS_HEARTBEAT_PERIOD` environment variables.
|
||||
|
||||
You may wish to exclude these messages from your application logic.
|
||||
|
||||
## In Summary
|
||||
|
||||
In this guide, you have successfully created a new WebSocket connection, authenticated yourself, and performed CRUD operations over the connection. You have also created your first subscription.
|
||||
|
||||
[Learn more about subscriptions with WebSockets with Directus.](/guides/real-time/subscriptions/websockets)
|
||||
|
||||
## Full Code Sample
|
||||
```html
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<script>
|
||||
const url = 'wss://your-directus-url/websocket';
|
||||
const access_token = 'your-access-token';
|
||||
const collection = 'messages';
|
||||
|
||||
const connection = new WebSocket(url);
|
||||
|
||||
connection.addEventListener('open', function() {
|
||||
console.log({ event: 'onopen' })
|
||||
connection.send(JSON.stringify({
|
||||
type: 'auth',
|
||||
access_token
|
||||
}));
|
||||
});
|
||||
|
||||
connection.addEventListener('message', function(message) {
|
||||
const data = JSON.parse(message.data);
|
||||
console.log({ event: 'onmessage', data });
|
||||
});
|
||||
|
||||
connection.addEventListener('close', function() {
|
||||
console.log({ event: 'onclose' });
|
||||
});
|
||||
|
||||
connection.addEventListener('error', function(error) {
|
||||
console.log({ event: 'onerror', error });
|
||||
});
|
||||
|
||||
function subscribe() {
|
||||
connection.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
collection: 'messages',
|
||||
query: {
|
||||
fields: ['*']
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
function createItem(text, user) {
|
||||
connection.send(JSON.stringify({
|
||||
type: 'items',
|
||||
collection: 'messages',
|
||||
action: 'create',
|
||||
data: { text, user }
|
||||
}));
|
||||
};
|
||||
|
||||
function readLatestItem() {
|
||||
connection.send(JSON.stringify({
|
||||
type: 'items',
|
||||
collection: 'messages',
|
||||
action: 'read',
|
||||
query: { limit: 1, sort: '-date_created' }
|
||||
}));
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
300
docs/guides/real-time/live-poll.md
Normal file
300
docs/guides/real-time/live-poll.md
Normal file
@@ -0,0 +1,300 @@
|
||||
---
|
||||
description: Learn how to build a live-updating pie chart with results from a poll.
|
||||
contributors: Kevin Lewis
|
||||
---
|
||||
|
||||
# Build a Live Poll Result Chart With JavaScript
|
||||
|
||||
In this guide, you will build a live-updating chart based on votes to a multiple-choice poll. New entries will be added using a Directus REST API, and results will be accessed using a WebSockets connection. The chart will be created and updated using [Chart.js](https://www.chartjs.org).
|
||||
|
||||
## Before You Start
|
||||
|
||||
You will need a Directus project. If you don’t already have one, the easiest way to get started is with our [managed Directus Cloud service](https://directus.cloud).
|
||||
|
||||
Create a new collection called `votes`, with a dropdown selection field called `choice`. Create two choices - one with the value of `dogs` and one with `cats`.
|
||||
|
||||
In the `Public` role, give Create access to the `votes` collection.
|
||||
|
||||
Create a new Role called `Results`, and make sure the `Results` role has Read access to the `votes` collection. Create a new user with this role, generate an access token, and make note of it.
|
||||
|
||||
## Create The Vote Page
|
||||
Create a `vote.html` file and open it in your code editor. Add the following:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="options">
|
||||
<button id="cat">Cats</button>
|
||||
<button id="dog">Dogs</button>
|
||||
</div>
|
||||
<p></p>
|
||||
<script>
|
||||
const directusUrl = 'https://your-directus-url';
|
||||
document.querySelector('#cat').addEventListener('click', vote('cats'))
|
||||
document.querySelector('#dog').addEventListener('click', vote('dogs'))
|
||||
|
||||
async function vote(choice) {
|
||||
await fetch(`${directusUrl}/items/votes`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ choice }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
This uses the Directus REST API to create a new item in the `votes` collection when the a button is pressed. Make sure to replace `your-directus-url` with your project’s URL.
|
||||
|
||||
*Load `vote.html` in your browser and click a button. Check your Directus project and you should see a new item in the `votes` collection.*
|
||||
|
||||

|
||||
|
||||
At the bottom of your `vote` function, add some user feedback that the vote was cast:
|
||||
|
||||
```js
|
||||
document.body.innerHTML = 'Vote cast'
|
||||
```
|
||||
|
||||
*Refresh your browser and try casting a vote. The page should be replaced with a success message once the vote has taken place.*
|
||||
|
||||
## Create The Results Page
|
||||
|
||||
Also create a `results.html` file and open it in your editor. Add the following:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<div style="display: flex; justify-content: center; height: 80vh">
|
||||
<canvas id="chart"></canvas>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
const socket = new WebSocket('wss://your-directus-url/websocket')
|
||||
const access_token = 'your-access-token'
|
||||
|
||||
socket.addEventListener('open', function () {
|
||||
console.log({ event: 'onopen' });
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'auth',
|
||||
access_token,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
socket.addEventListener('message', function (message) {
|
||||
const data = JSON.parse(message.data);
|
||||
if (data.type == 'auth' && data.status == 'ok') {
|
||||
}
|
||||
if (data.type == 'subscription' && data.event == 'init') {
|
||||
}
|
||||
if(data.type == 'subscription' && data.event == 'create') {
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
This boilerplate creates a placeholder element for our chart, and includes Chart.js from a CDN. It also sets up our WebSocket methods - for more information check out our [Getting Started With WebSockets](/guides/real-time/getting-started/websockets.md) guide.
|
||||
|
||||
Make sure to replace `your-directus-url` with your project’s URL, and `your-access-token` with your token.
|
||||
|
||||
### Set Up Chart
|
||||
|
||||
At the bottom of your `<script>`, initialize a pie chart which will have two segments:
|
||||
|
||||
```js
|
||||
const ctx = document.getElementById('chart');
|
||||
const chart = new Chart(ctx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: '# of Votes',
|
||||
data: [],
|
||||
backgroundColor: ['#4f46e5', '#f472b6']
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Load `results.html` in your browser. Don’t worry that nothing is displayed yet - the chart has no data so it can’t render.
|
||||
|
||||
### Add Existing Votes On Load
|
||||
|
||||
Once authenticated, immediately subscribe to the `votes` collection:
|
||||
|
||||
```js
|
||||
if (data.type == 'auth' && data.status == 'ok') {
|
||||
socket.send(JSON.stringify({ // [!code ++]
|
||||
type: 'subscribe', // [!code ++]
|
||||
collection: 'votes', // [!code ++]
|
||||
query: { // [!code ++]
|
||||
aggregate: { count: 'choice' }, // [!code ++]
|
||||
groupBy: ['choice'], // [!code ++]
|
||||
} // [!code ++]
|
||||
})); // [!code ++]
|
||||
}
|
||||
```
|
||||
|
||||
The `query` groups all items by their `choice` value, and are then counted by the aggregation. The result is a payload that shows how many of each choice exist in the collection.
|
||||
|
||||
A message is sent over the connection when a connection is initialized with data from the existing collection. Use this data to edit the chart’s dataset and update it:
|
||||
|
||||
```js
|
||||
if (data.type == 'subscription' && data.event == 'init') {
|
||||
for (const item of data.data) { // [!code ++]
|
||||
chart.data.labels.push(item.choice); // [!code ++]
|
||||
chart.data.datasets[0].data.push(item.count.choice); // [!code ++]
|
||||
} // [!code ++]
|
||||
chart.update(); // [!code ++]
|
||||
}
|
||||
```
|
||||
|
||||
Refresh the page, and you should see the chart update with the initial values.
|
||||
|
||||

|
||||
|
||||
### Update Chart With New Votes
|
||||
|
||||
When a new vote is cast, update the chart’s dataset and update it:
|
||||
|
||||
```js
|
||||
if (data.type == 'subscription' && data.event == 'create') {
|
||||
const vote = data.data[0]; // [!code ++]
|
||||
const itemToUpdate = chart.data.labels.indexOf(vote.choice); // [!code ++]
|
||||
if (itemToUpdate !== -1) { // [!code ++]
|
||||
chart.data.datasets[0].data[itemToUpdate]++; // [!code ++]
|
||||
} else { // [!code ++]
|
||||
chart.data.labels.push(vote.choice); // [!code ++]
|
||||
chart.data.datasets[0].data.push(1); // [!code ++]
|
||||
} // [!code ++]
|
||||
chart.update(); // [!code ++]
|
||||
}
|
||||
```
|
||||
|
||||
This code finds which index position the choice is in, increases it by 1, and updates the chart.
|
||||
|
||||
Open both `vote.html` and `results.html` in your browser. Make a vote and see the chart update.
|
||||
|
||||
## Next Steps
|
||||
There are many ways to improve the project built in this guide:
|
||||
|
||||
1. Accept more data - such as voter name or contact details.
|
||||
2. Dynamically generate the vote form based on the field options.
|
||||
3. Disallow multiple votes by storing completion states.
|
||||
4. Create other chart types.
|
||||
|
||||
## Full Code Sample
|
||||
|
||||
### `vote.html`
|
||||
```html
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="options">
|
||||
<button id="cat">Cats</button>
|
||||
<button id="dog">Dogs</button>
|
||||
</div>
|
||||
<p></p>
|
||||
<script>
|
||||
const directusUrl = 'https://your-directus-url';
|
||||
document.querySelector('#cat').addEventListener('click', vote('cats'))
|
||||
document.querySelector('#dog').addEventListener('click', vote('dogs'))
|
||||
|
||||
async function vote(choice) {
|
||||
await fetch(`${directusUrl}/items/votes`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ choice }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### `results.html`
|
||||
```html
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<div style="display: flex; justify-content: center; height: 80vh">
|
||||
<canvas id="chart"></canvas>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
const socket = new WebSocket('wss://your-directus-url/websocket');
|
||||
const access_token = 'your-access-token';
|
||||
|
||||
socket.addEventListener('open', function () {
|
||||
console.log({ event: 'onopen' });
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'auth',
|
||||
access_token,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
socket.addEventListener('message', function (message) {
|
||||
const data = JSON.parse(message.data);
|
||||
if (data.type == 'auth' && data.status == 'ok') {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
collection: 'votes',
|
||||
query: {
|
||||
aggregate: { count: 'choice' },
|
||||
groupBy: ['choice'],
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if (data.type == 'subscription' && data.event == 'init') {
|
||||
for (const item of data.data) {
|
||||
chart.data.labels.push(item.choice);
|
||||
chart.data.datasets[0].data.push(item.count.choice);
|
||||
}
|
||||
chart.update();
|
||||
}
|
||||
|
||||
if (data.type == 'subscription' && data.event == 'create') {
|
||||
const vote = data.data[0];
|
||||
const itemToUpdate = chart.data.labels.indexOf(vote.choice);
|
||||
if (itemToUpdate !== -1) {
|
||||
chart.data.datasets[0].data[itemToUpdate]++;
|
||||
} else {
|
||||
chart.data.labels.push(vote.choice);
|
||||
chart.data.datasets[0].data.push(1);
|
||||
}
|
||||
chart.update();
|
||||
}
|
||||
});
|
||||
|
||||
const ctx = document.getElementById('chart');
|
||||
const chart = new Chart(ctx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: '# of Votes',
|
||||
data: [],
|
||||
backgroundColor: ['#4f46e5', '#f472b6']
|
||||
}]
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
150
docs/guides/real-time/operations.md
Normal file
150
docs/guides/real-time/operations.md
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
description: "Learn how to execute CRUD operations over Directus' WebSockets interface"
|
||||
contributors: Kevin Lewis
|
||||
---
|
||||
|
||||
# WebSockets Operations
|
||||
|
||||
You can execute CRUD operations over Directus' WebSockets interface.
|
||||
|
||||
This guide assumes you already know [how to establish, authenticate, and send messages](/guides/real-time/getting-started/websockets) over a WebSocket connection.
|
||||
|
||||
:::info GraphQL
|
||||
|
||||
The GraphQL Subscriptions specification does not support CRUD operations. This guide is only suitable for WebSockets connections not using GraphQL.
|
||||
|
||||
:::
|
||||
|
||||
## Read Items
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "items",
|
||||
"action": "read",
|
||||
"collection": "your_collection_name",
|
||||
"id": "single_item_id"
|
||||
}
|
||||
```
|
||||
|
||||
In return, you will receive a message with the specified item:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "items",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Read Multiple Items
|
||||
|
||||
Instead of using an `id` property, you can use an `ids` property with an array of item IDs you'd like to return, or omit it to return all items in the specified collection. When returning multiple items, `data` will be an array of objects.
|
||||
|
||||
## Create Items
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "items",
|
||||
"action": "create",
|
||||
"collection": "your_collection_name",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
In return, you will receive a message with the newly-created item:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "items",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Create Multiple Items
|
||||
|
||||
Instead of using an object as the value of `data`, you can provide an array of objects to create multiple items at once. The returned payload will also contain an array.
|
||||
|
||||
## Update Items
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "items",
|
||||
"action": "update",
|
||||
"collection": "your_collection_name",
|
||||
"id": "single_item_id",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Regardless of how many items are updated, the `data` in the returned object will always be an array.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "subscription",
|
||||
"event": "update",
|
||||
"data": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### Update Multiple Items
|
||||
|
||||
Instead of using an `id` property, you can use an `ids` property with an array of item IDs to update multiple items at a time.
|
||||
|
||||
## Delete Items
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "items",
|
||||
"action": "delete",
|
||||
"collection": "your_collection_name",
|
||||
"id": "single_item_id"
|
||||
}
|
||||
```
|
||||
|
||||
Regardless of how many items are updated, the `data` in the returned data will always be an array containing all IDs from deleted items:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "items",
|
||||
"event": "delete",
|
||||
"data": ["single_item_id", "single_item_id_2"]
|
||||
}
|
||||
```
|
||||
|
||||
## Delete Multiple Items
|
||||
|
||||
Instead of using an `id` property, you can use an `ids` property with an array of item IDs to delete multiple items at a time.
|
||||
|
||||
Instead of using an `id` property, you can also use delete items based on a provided `query` property. To delete all items, provide an empty query object.
|
||||
|
||||
## Operations With Queries
|
||||
|
||||
For non-delete operations, all fields that the user has access to are returned by default. You can add an optional `query` property along with any of the [global query parameters](/reference/query) to change the returned data.
|
||||
|
||||
When running a delete operation, the items matching the `query` property will be deleted.
|
||||
|
||||
## Use UIDs To Better Understand Responses
|
||||
|
||||
All messages sent over WebSockets can optionally include a `uid` property with an arbitrary string and will be echoed in the response. This allows you to identify which request a given response is related to. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "items",
|
||||
"action": "read",
|
||||
"collection": "your_collection_name",
|
||||
"query": {
|
||||
"sort": "date_created"
|
||||
},
|
||||
"uid": "sorted_latest_first"
|
||||
}
|
||||
```
|
||||
|
||||
The response will include the same `uid`:
|
||||
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "items",
|
||||
"data": { ... },
|
||||
"uid": "sorted_latest_first"
|
||||
}
|
||||
```
|
||||
83
docs/guides/real-time/subscriptions/graphql.md
Normal file
83
docs/guides/real-time/subscriptions/graphql.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
contributors: Esther Agbaje
|
||||
description: "Learn how to get started with Directus' GraphQL subscriptons."
|
||||
---
|
||||
|
||||
# GraphQL Subscriptions
|
||||
|
||||
GraphQL subscriptions provide live updates that are delivered in real-time whenever an item is created, updated or
|
||||
deleted in your collection.
|
||||
|
||||
This guide assumes you already know [how to establish and authenticate](/guides/real-time/getting-started/graphql) over
|
||||
a GraphQL connection.
|
||||
|
||||
## Subscribe To Changes In A Collection
|
||||
|
||||
Send the following query, `<collection>_mutated` over your WebSocket connection to subscribe to changes. If you want to
|
||||
subscribe to a `messages` collection, the query would look like this:
|
||||
|
||||
```graphql
|
||||
subscription {
|
||||
messages_mutated {
|
||||
key
|
||||
event
|
||||
data {
|
||||
id
|
||||
text
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In return, this query will subscribe to changes in the messages collection and return the `id` and `text` fields of the
|
||||
message added.
|
||||
|
||||
Refer to the [Fields Query Parameter](/reference/query.html#fields) docs for more information on specifying what data
|
||||
should be returned.
|
||||
|
||||
## Handling Collection Changes
|
||||
|
||||
When a change happens to an item in a collection with an active subscription, it will emit a message
|
||||
|
||||
```json
|
||||
{
|
||||
"messages_mutated": {
|
||||
"key": "1",
|
||||
"event": "create",
|
||||
"data": {
|
||||
"id": "1",
|
||||
"text": "Hello world!",
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
An event will be either `create`, `update`, or `delete`. If the event is `create` or `update`, the payload will
|
||||
contain the full item objects (or specific fields, if specified). If the event is `delete`, just the `key` will be filled the other requested fields will be `null`.
|
||||
|
||||
## Working With Specific CRUD Operations
|
||||
|
||||
Using the `event` argument you can filter for specific `create`,
|
||||
`update`, and `delete` events. Here's an example of how to do this:
|
||||
|
||||
```graphql
|
||||
subscription {
|
||||
messages_mutated(event: create) {
|
||||
key
|
||||
data {
|
||||
text
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Unsubscribing From Changes
|
||||
|
||||
To unsubscribe from a subscription, use the `dispose` method. Here's an example:
|
||||
|
||||
```js
|
||||
client.dispose();
|
||||
```
|
||||
|
||||
Calling `dispose` sends a message to the server to unsubscribe from the specified subscription. This will stop receiving
|
||||
any further updates for that subscription.
|
||||
20
docs/guides/real-time/subscriptions/index.md
Normal file
20
docs/guides/real-time/subscriptions/index.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
description: "Learn how to get started with Directus real-time subscriptons."
|
||||
---
|
||||
|
||||
# Real-Time Subscriptions
|
||||
|
||||
WebSocket and GraphQL subscriptions allows for real-time notification of item creations, edits, and deletions in a
|
||||
collection.
|
||||
|
||||
<Card
|
||||
title="WebSockets"
|
||||
h="2"
|
||||
text="Learn how to subscribe to changes using WebSockets."
|
||||
url="/guides/real-time/subscriptions/websockets" />
|
||||
|
||||
<Card
|
||||
title="GraphQL Subscriptions"
|
||||
h="2"
|
||||
text="Learn how to implement GraphQL Subscriptions in Directus."
|
||||
url="/guides/real-time/subscriptions/graphql" />
|
||||
109
docs/guides/real-time/subscriptions/websockets.md
Normal file
109
docs/guides/real-time/subscriptions/websockets.md
Normal file
@@ -0,0 +1,109 @@
|
||||
---
|
||||
contributors: Kevin Lewis
|
||||
description: "Learn how to get started with Directus' WebSockets subscriptons."
|
||||
---
|
||||
|
||||
# WebSockets Subscriptions
|
||||
|
||||
WebSocket subscriptions allows for real-time notification of item creations, edits, and deletions in a collection.
|
||||
|
||||
This guide assumes you already know [how to establish, authenticate, and send messages](/guides/real-time/getting-started/websockets) over a WebSocket connection.
|
||||
|
||||
## Subscribe To Changes In A Collection
|
||||
|
||||
Send the following message over your WebSocket connection to start a subscription:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "subscribe",
|
||||
"collection": "messages"
|
||||
}
|
||||
```
|
||||
|
||||
In return, you will receive a message to confirm that your subscription has been initialized:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "subscription",
|
||||
"event": "init"
|
||||
}
|
||||
```
|
||||
|
||||
## Handling Collection Changes
|
||||
|
||||
When a change happens to an item in a collection with an active subscription, it will emit a message
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "subscription",
|
||||
"event": "create",
|
||||
"data": [...]
|
||||
}
|
||||
```
|
||||
|
||||
The `event` will be one of `create`, `update`, or `delete`. If the event is `create` or `update`, the `data` will contain the full item objects (or specific fields, if specified). If the event is `delete`, just the `id` will be returned.
|
||||
|
||||
## Working With Specific CRUD Operations
|
||||
|
||||
Using the optional `event` argument you can filter for specific `create`, `update`, and `delete` events.
|
||||
|
||||
Here's an example of how to do this:
|
||||
```json
|
||||
{
|
||||
"type": "subscribe",
|
||||
"collection": "messages",
|
||||
"event": "create"
|
||||
}
|
||||
```
|
||||
|
||||
## Specifying Fields To Return
|
||||
|
||||
If you only want to return specific fields on subscription events, add the `query.fields` property when initializing the subscription:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "subscribe",
|
||||
"collection": "messages",
|
||||
"query": { "fields": ["text"] }
|
||||
}
|
||||
```
|
||||
|
||||
Refer to the [Fields Query Parameter](/reference/query.html#fields) docs for more information on specifying what data should be returned.
|
||||
|
||||
## Using UIDs
|
||||
|
||||
You can have multiple ongoing CRUD operations and subscriptions at a time. When doing so, it is highly recommended to add an additional `uid` property to your request, which will be included in related item change events.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "subscribe",
|
||||
"collection": "messages",
|
||||
"uid": "any-string-value"
|
||||
}
|
||||
```
|
||||
|
||||
When you receive responses, the same `uid` will be included as a property:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "subscription",
|
||||
"event": "create",
|
||||
"data": [...],
|
||||
"uid": "any-string-value"
|
||||
}
|
||||
```
|
||||
|
||||
Use a new `uid` for every subscription, and you can easily tell which subscription an event is related to.
|
||||
|
||||
## Unsubscribing From Changes
|
||||
|
||||
To stop change events being sent from a specific subscription, send the following message:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "unsubscribe",
|
||||
"uid": "identifier"
|
||||
}
|
||||
```
|
||||
|
||||
You can also omit `uid` to stop all subscriptions at once.
|
||||
7126
docs/package-lock.json
generated
Normal file
7126
docs/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
docs/public/icons/js.svg
Normal file
4
docs/public/icons/js.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 630 630">
|
||||
<rect width="630" height="630" fill="#f7df1e"/>
|
||||
<path d="m423.2 492.19c12.69 20.72 29.2 35.95 58.4 35.95 24.53 0 40.2-12.26 40.2-29.2 0-20.3-16.1-27.49-43.1-39.3l-14.8-6.35c-42.72-18.2-71.1-41-71.1-89.2 0-44.4 33.83-78.2 86.7-78.2 37.64 0 64.7 13.1 84.2 47.4l-46.1 29.6c-10.15-18.2-21.1-25.37-38.1-25.37-17.34 0-28.33 11-28.33 25.37 0 17.76 11 24.95 36.4 35.95l14.8 6.34c50.3 21.57 78.7 43.56 78.7 93 0 53.3-41.87 82.5-98.1 82.5-54.98 0-90.5-26.2-107.88-60.54zm-209.13 5.13c9.3 16.5 17.76 30.45 38.1 30.45 19.45 0 31.72-7.61 31.72-37.2v-201.3h59.2v202.1c0 61.3-35.94 89.2-88.4 89.2-47.4 0-74.85-24.53-88.81-54.075z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 687 B |
1
docs/public/icons/react.svg
Normal file
1
docs/public/icons/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="2500" viewBox="175.7 78 490.6 436.9" width="2194" xmlns="http://www.w3.org/2000/svg"><g fill="#61dafb"><path d="m666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9v-22.3c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6v-22.3c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zm-101.4 106.7c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24s9.5 15.8 14.4 23.4zm73.9-208.1c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6s22.9-35.6 58.3-50.6c8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zm53.8 142.9c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6z"/><circle cx="420.9" cy="296.5" r="45.7"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
2
docs/public/icons/vue.svg
Normal file
2
docs/public/icons/vue.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg version="1.1" viewBox="0 0 261.76 226.69" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(1.3333 0 0 -1.3333 -76.311 313.34)"><g transform="translate(178.06 235.01)"><path d="m0 0-22.669-39.264-22.669 39.264h-75.491l98.16-170.02 98.16 170.02z" fill="#41b883"/></g><g transform="translate(178.06 235.01)"><path d="m0 0-22.669-39.264-22.669 39.264h-36.227l58.896-102.01 58.896 102.01z" fill="#34495e"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 467 B |
@@ -1062,3 +1062,32 @@ Allows you to configure hard technical limits, to prevent abuse and optimize for
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------- | ------------- |
|
||||
| `RELATIONAL_BATCH_SIZE` | How many rows are read into memory at a time when constructing nested relational datasets | 25000 |
|
||||
| `EXPORT_BATCH_SIZE` | How many rows are read into memory at a time when constructing exports | 5000 |
|
||||
|
||||
## WebSockets
|
||||
|
||||
| Variable | Description | Default Value |
|
||||
|--------------------|--------------------------------------------|---------------|
|
||||
| `WEBSOCKETS_ENABLED` | Whether or not to enable all WebSocket functionality. | `false` |
|
||||
| `WEBSOCKETS_HEARTBEAT_ENABLED` | Whether or not to enable the heartbeat ping signal. | `true` |
|
||||
| `WEBSOCKETS_HEARTBEAT_PERIOD` | The period in seconds at which to send the ping. This period doubles as the timeout used for closing an unresponsive connection. | 30 |
|
||||
|
||||
### REST
|
||||
|
||||
| Variable | Description | Default Value |
|
||||
|--------------------|--------------------------------------------|---------------|
|
||||
| `WEBSOCKETS_REST_ENABLED` | Whether or not to enable the REST message handlers. | `true` |
|
||||
| `WEBSOCKETS_REST_PATH` | The URL path at which the WebSocket REST endpoint will be available. | `/websocket` |
|
||||
| `WEBSOCKETS_REST_CONN_LIMIT` | How many simultaneous connections are allowed. | `Infinity` |
|
||||
| `WEBSOCKETS_REST_AUTH` | What method of authentication to require for this connection. | `handshake` |
|
||||
| `WEBSOCKETS_REST_AUTH_TIMEOUT` | The amount of time in seconds to wait before closing an unauthenticated connection. | 30 |
|
||||
|
||||
### GraphQL
|
||||
|
||||
| Variable | Description | Default Value |
|
||||
|--------------------|--------------------------------------------|---------------|
|
||||
| `WEBSOCKETS_GRAPHQL_ENABLED` | Whether or not to enable the GraphQL Subscriptions. | `true` |
|
||||
| `WEBSOCKETS_GRAPHQL_PATH` | The URL path at which the WebSocket GraphQL endpoint will be available. | `/graphql` |
|
||||
| `WEBSOCKETS_GRAPHQL_CONN_LIMIT` | How many simultaneous connections are allowed. | `Infinity` |
|
||||
| `WEBSOCKETS_GRAPHQL_AUTH` | What method of authentication to require for this connection. | `handshake` |
|
||||
| `WEBSOCKETS_GRAPHQL_AUTH_TIMEOUT` | The amount of time in seconds to wait before closing an unauthenticated connection. | 30 |
|
||||
|
||||
|
||||
61
pnpm-lock.yaml
generated
61
pnpm-lock.yaml
generated
@@ -185,6 +185,9 @@ importers:
|
||||
graphql-compose:
|
||||
specifier: 9.0.10
|
||||
version: 9.0.10(graphql@16.6.0)
|
||||
graphql-ws:
|
||||
specifier: 5.12.0
|
||||
version: 5.12.0(graphql@16.6.0)
|
||||
helmet:
|
||||
specifier: 7.0.0
|
||||
version: 7.0.0
|
||||
@@ -326,6 +329,15 @@ importers:
|
||||
wellknown:
|
||||
specifier: 0.5.0
|
||||
version: 0.5.0
|
||||
ws:
|
||||
specifier: 8.12.1
|
||||
version: 8.12.1
|
||||
zod:
|
||||
specifier: 3.21.4
|
||||
version: 3.21.4
|
||||
zod-validation-error:
|
||||
specifier: 1.0.1
|
||||
version: 1.0.1(zod@3.21.4)
|
||||
optionalDependencies:
|
||||
'@keyv/redis':
|
||||
specifier: 2.5.8
|
||||
@@ -469,6 +481,9 @@ importers:
|
||||
'@types/wellknown':
|
||||
specifier: 0.5.4
|
||||
version: 0.5.4
|
||||
'@types/ws':
|
||||
specifier: 8.5.4
|
||||
version: 8.5.4
|
||||
'@vitest/coverage-c8':
|
||||
specifier: 0.31.1
|
||||
version: 0.31.1(vitest@0.31.1)
|
||||
@@ -1613,6 +1628,9 @@ importers:
|
||||
'@types/uuid':
|
||||
specifier: 9.0.1
|
||||
version: 9.0.1
|
||||
'@types/ws':
|
||||
specifier: 8.5.4
|
||||
version: 8.5.4
|
||||
autocannon:
|
||||
specifier: 7.11.0
|
||||
version: 7.11.0
|
||||
@@ -1622,6 +1640,9 @@ importers:
|
||||
globby:
|
||||
specifier: 11.1.0
|
||||
version: 11.1.0
|
||||
graphql-ws:
|
||||
specifier: 5.12.0
|
||||
version: 5.12.0(graphql@16.6.0)
|
||||
jest:
|
||||
specifier: 29.5.0
|
||||
version: 29.5.0
|
||||
@@ -1658,6 +1679,9 @@ importers:
|
||||
uuid:
|
||||
specifier: 9.0.0
|
||||
version: 9.0.0
|
||||
ws:
|
||||
specifier: 8.12.1
|
||||
version: 8.12.1
|
||||
|
||||
packages:
|
||||
|
||||
@@ -7085,7 +7109,7 @@ packages:
|
||||
ts-dedent: 2.2.0
|
||||
util-deprecate: 1.0.2
|
||||
watchpack: 2.4.0
|
||||
ws: 8.13.0
|
||||
ws: 8.12.1
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- encoding
|
||||
@@ -8037,6 +8061,12 @@ packages:
|
||||
resolution: {integrity: sha512-zcsf4oHeEcvpvWhDOB1+qNK04oFJtsmv7Otb1Vy8w8GAqQkgRrVkI/C76PdwtpiT216aM1SbhkKgKWzGLhHKng==}
|
||||
dev: true
|
||||
|
||||
/@types/ws@8.5.4:
|
||||
resolution: {integrity: sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==}
|
||||
dependencies:
|
||||
'@types/node': 18.16.12
|
||||
dev: true
|
||||
|
||||
/@types/yargs-parser@21.0.0:
|
||||
resolution: {integrity: sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==}
|
||||
dev: true
|
||||
@@ -12357,6 +12387,14 @@ packages:
|
||||
graphql: 16.6.0
|
||||
dev: false
|
||||
|
||||
/graphql-ws@5.12.0(graphql@16.6.0):
|
||||
resolution: {integrity: sha512-PA3ImUp8utrpEjoxBMhvxsjkStvFEdU0E1gEBREt8HZIWkxOUymwJBhFnBL7t/iHhUq1GVPeZevPinkZFENxTw==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
graphql: '>=0.11 <=16'
|
||||
dependencies:
|
||||
graphql: 16.6.0
|
||||
|
||||
/graphql@16.6.0:
|
||||
resolution: {integrity: sha512-KPIBPDlW7NxrbT/eh4qPXz5FiFdL5UbaA0XUNz2Rp3Z3hqBSkbj0GVjwFDztsWVauZUWsbKHgMg++sk8UX0bkw==}
|
||||
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
|
||||
@@ -20817,6 +20855,18 @@ packages:
|
||||
optional: true
|
||||
dev: true
|
||||
|
||||
/ws@8.12.1:
|
||||
resolution: {integrity: sha512-1qo+M9Ba+xNhPB+YTWUlK6M17brTut5EXbcBaMRN5pH5dFrXz7lzz1ChFSUq3bOUl8yEvSenhHmYUNJxFzdJew==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
/ws@8.13.0:
|
||||
resolution: {integrity: sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -20991,6 +21041,15 @@ packages:
|
||||
resolution: {integrity: sha512-V4R94t3ifk9AURym6OskbKcnowzgp5Z88tkoL/NF67vyryNxC62u6mx5F1Ux4oh4+YN7FFmKYEyWy6m5kfPH6g==}
|
||||
dev: true
|
||||
|
||||
/zod-validation-error@1.0.1(zod@3.21.4):
|
||||
resolution: {integrity: sha512-QRk2AtHLJg8sCZAbEjXSs7E0n4/mSdX5caoh6eOUvDSdcIQz03i0xoNN1Qx6UZT+ADVHRK6+ZXRtldzW6nnltA==}
|
||||
engines: {node: ^14.17 || >=16.0.0}
|
||||
peerDependencies:
|
||||
zod: ^3.18.0
|
||||
dependencies:
|
||||
zod: 3.21.4
|
||||
dev: false
|
||||
|
||||
/zod@3.21.4:
|
||||
resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ const directusConfig = {
|
||||
ASSETS_TRANSFORM_MAX_CONCURRENT: '2',
|
||||
MAX_BATCH_MUTATION: '100', // Must be in multiples of 10 for tests
|
||||
ACCESS_TOKEN_TTL: '25d', // should be larger than 24.86 days to test Expires value larger than 32-bit signed integer
|
||||
WEBSOCKETS_ENABLED: 'true',
|
||||
...directusAuthConfig,
|
||||
...directusStorageConfig,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import request, { Response } from 'supertest';
|
||||
import { jsonToGraphQLQuery } from 'json-to-graphql-query';
|
||||
import { EnumType, jsonToGraphQLQuery } from 'json-to-graphql-query';
|
||||
import { WebSocket } from 'ws';
|
||||
import { createClient } from 'graphql-ws';
|
||||
import {
|
||||
WebSocketOptions,
|
||||
WebSocketOptionsGql,
|
||||
WebSocketResponse,
|
||||
WebSocketSubscriptionOptions,
|
||||
WebSocketSubscriptionOptionsGql,
|
||||
WebSocketUID,
|
||||
} from './types';
|
||||
|
||||
export function processGraphQLJson(jsonQuery: any) {
|
||||
return jsonToGraphQLQuery(jsonQuery);
|
||||
@@ -24,3 +34,419 @@ export async function requestGraphQL(
|
||||
|
||||
return await req;
|
||||
}
|
||||
|
||||
export function createWebSocketConn(host: string, config?: WebSocketOptions) {
|
||||
const defaults = { waitTimeout: 5000 };
|
||||
const parsedHost = host.split('//').slice(1).join('/');
|
||||
|
||||
const conn = new WebSocket(
|
||||
`ws://${parsedHost}/${config?.path ?? 'websocket'}${config?.queryString ? `?${config.queryString}` : ''}`,
|
||||
config?.client
|
||||
);
|
||||
|
||||
let connectionAuthCompleted = false;
|
||||
const messages: Record<WebSocketUID, any[]> = {};
|
||||
const messagesDefault: WebSocketResponse[] = [];
|
||||
let readIndexDefault = 0;
|
||||
const readIndexes: Record<WebSocketUID, number> = {};
|
||||
|
||||
const waitForState = (
|
||||
state: WebSocket['readyState'],
|
||||
options?: {
|
||||
waitTimeout?: number;
|
||||
}
|
||||
) => {
|
||||
const startMs = Date.now();
|
||||
|
||||
const promise = () => {
|
||||
return new Promise(function (resolve, reject) {
|
||||
setTimeout(function () {
|
||||
if (
|
||||
conn.readyState === state &&
|
||||
(conn.readyState !== conn.OPEN || !config?.auth || (config.auth && connectionAuthCompleted))
|
||||
) {
|
||||
return resolve(true);
|
||||
} else if (Date.now() < startMs + (options?.waitTimeout ?? config?.waitTimeout ?? defaults.waitTimeout)) {
|
||||
return promise().then(resolve, reject);
|
||||
} else {
|
||||
let stateName = '';
|
||||
|
||||
switch (state) {
|
||||
case WebSocket.CONNECTING:
|
||||
stateName = 'CONNECTING';
|
||||
break;
|
||||
case WebSocket.OPEN:
|
||||
stateName = 'OPEN';
|
||||
break;
|
||||
case WebSocket.CLOSING:
|
||||
stateName = 'CLOSING';
|
||||
break;
|
||||
case WebSocket.CLOSED:
|
||||
stateName = 'CLOSED';
|
||||
break;
|
||||
default:
|
||||
stateName = 'INVALID';
|
||||
break;
|
||||
}
|
||||
|
||||
conn.terminate();
|
||||
return reject(new Error(`WebSocket failed to achieve the ${stateName} state`));
|
||||
}
|
||||
}, 5);
|
||||
});
|
||||
};
|
||||
|
||||
return promise();
|
||||
};
|
||||
|
||||
const getMessages = async (
|
||||
messageCount: number,
|
||||
options?: {
|
||||
waitTimeout?: number;
|
||||
targetState?: WebSocket['readyState'];
|
||||
uid?: WebSocketUID;
|
||||
startIndex?: number;
|
||||
}
|
||||
): Promise<WebSocketResponse[] | undefined> => {
|
||||
const targetMessages = options?.uid ? messages[options.uid] ?? (messages[options.uid] = []) : messagesDefault;
|
||||
let startMessageIndex: number;
|
||||
|
||||
if (options?.startIndex) {
|
||||
startMessageIndex = options.startIndex;
|
||||
} else if (options?.uid) {
|
||||
startMessageIndex = readIndexes[options.uid] ?? 0;
|
||||
} else {
|
||||
startMessageIndex = readIndexDefault;
|
||||
}
|
||||
|
||||
const endMessageIndex = startMessageIndex + messageCount;
|
||||
|
||||
if (options?.uid) {
|
||||
readIndexes[String(options.uid)] = endMessageIndex;
|
||||
} else {
|
||||
readIndexDefault = endMessageIndex;
|
||||
}
|
||||
|
||||
await waitForState(options?.targetState ?? WebSocket.OPEN);
|
||||
const startMs = Date.now();
|
||||
|
||||
const promise = (): Promise<WebSocketResponse[] | undefined> => {
|
||||
return new Promise(function (resolve, reject) {
|
||||
setTimeout(function () {
|
||||
if (targetMessages.length >= endMessageIndex) {
|
||||
resolve(targetMessages.slice(startMessageIndex, endMessageIndex));
|
||||
} else if (Date.now() < startMs + (options?.waitTimeout ?? config?.waitTimeout ?? defaults.waitTimeout)) {
|
||||
return promise().then(resolve, reject);
|
||||
} else {
|
||||
conn.terminate();
|
||||
|
||||
return reject(
|
||||
new Error(
|
||||
`Missing message${options?.uid ? ` for "${String(options.uid)}"` : ''} (received ${
|
||||
targetMessages.length - startMessageIndex
|
||||
}/${messageCount})`
|
||||
)
|
||||
);
|
||||
}
|
||||
}, 5);
|
||||
});
|
||||
};
|
||||
|
||||
return promise();
|
||||
};
|
||||
|
||||
const getMessageCount = (uid?: WebSocketUID) => {
|
||||
if (uid) {
|
||||
return messages[uid]?.length ?? 0;
|
||||
} else {
|
||||
return messagesDefault.length;
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = async (
|
||||
message: Record<string, any>,
|
||||
options?: {
|
||||
uid?: WebSocketUID;
|
||||
callback?: () => void;
|
||||
}
|
||||
) => {
|
||||
await waitForState(WebSocket.OPEN);
|
||||
conn.send(JSON.stringify(message), options?.callback);
|
||||
};
|
||||
|
||||
const subscribe = async (options: WebSocketSubscriptionOptions) => {
|
||||
if (options.uid && !messages[options.uid]) messages[options.uid] = [];
|
||||
sendMessage({ type: 'subscribe', ...options });
|
||||
let response;
|
||||
let error;
|
||||
|
||||
try {
|
||||
response = await getMessages(1, { uid: options.uid });
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
if (error || !response || response[0].status === 'error') {
|
||||
throw new Error(`Unable to subscribe to "${options.collection}"${options.uid ? ` for "${options.uid}"` : ''}`);
|
||||
}
|
||||
|
||||
return response[0];
|
||||
};
|
||||
|
||||
const unsubscribe = async (uid?: WebSocketUID) => {
|
||||
await sendMessage({ type: 'unsubscribe', uid });
|
||||
let response;
|
||||
let error;
|
||||
|
||||
try {
|
||||
response = await getMessages(1, { uid });
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
if (error || !response || response[0].status === 'error') {
|
||||
throw new Error(`Unable to unsubscribe${uid ? ` to "${uid}"` : ''}`);
|
||||
}
|
||||
};
|
||||
|
||||
conn.on('open', () => {
|
||||
if (config?.auth) {
|
||||
if ('email' in config.auth) {
|
||||
conn.send(JSON.stringify({ type: 'auth', email: config.auth.email, password: config.auth.password }));
|
||||
} else if ('access_token' in config.auth) {
|
||||
conn.send(JSON.stringify({ type: 'auth', access_token: config.auth.access_token }));
|
||||
} else if ('refresh_token' in config.auth) {
|
||||
conn.send(JSON.stringify({ type: 'auth', refresh_token: config.auth.refresh_token }));
|
||||
}
|
||||
}
|
||||
|
||||
conn.on('message', (data) => {
|
||||
const message: WebSocketResponse = JSON.parse(data.toString());
|
||||
|
||||
if (config?.respondToPing !== false && message.type === 'ping') {
|
||||
conn.send(JSON.stringify({ type: 'pong' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (config?.auth && !connectionAuthCompleted && message.type === 'auth') {
|
||||
connectionAuthCompleted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const targetMessages = message.uid ? messages[message.uid] ?? (messages[message.uid] = []) : messagesDefault;
|
||||
targetMessages.push(message);
|
||||
});
|
||||
});
|
||||
|
||||
conn.on('error', () => {
|
||||
return;
|
||||
});
|
||||
|
||||
return { conn, waitForState, getMessages, getMessageCount, sendMessage, subscribe, unsubscribe };
|
||||
}
|
||||
|
||||
export function createWebSocketGql(host: string, config?: WebSocketOptionsGql) {
|
||||
const defaults = { waitTimeout: 5000 };
|
||||
const parsedHost = host.split('//').slice(1).join('/');
|
||||
let conn: WebSocket | null;
|
||||
let isConnReady = false;
|
||||
let authParams;
|
||||
|
||||
if (config?.auth && 'access_token' in config.auth) {
|
||||
authParams = { access_token: config.auth.access_token };
|
||||
}
|
||||
|
||||
const client = createClient({
|
||||
webSocketImpl: WebSocket,
|
||||
connectionParams: authParams,
|
||||
...config?.client,
|
||||
disablePong: !config?.respondToPing,
|
||||
url: `ws://${parsedHost}/${config?.path ?? 'graphql'}${config?.queryString ? `?${config.queryString}` : ''}`,
|
||||
on: {
|
||||
closed: () => {
|
||||
conn = null;
|
||||
},
|
||||
opened: (socket) => {
|
||||
config?.client?.on?.opened?.(socket);
|
||||
conn = socket as WebSocket;
|
||||
|
||||
conn.on('message', (data) => {
|
||||
const message: WebSocketResponse = JSON.parse(data.toString());
|
||||
|
||||
if (message.type === 'connection_ack') {
|
||||
isConnReady = true;
|
||||
}
|
||||
|
||||
if (config?.respondToPing !== false && message.type === 'ping') {
|
||||
conn?.send(JSON.stringify({ type: 'pong' }));
|
||||
return;
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const messages: Record<string, any[]> = {};
|
||||
const messagesDefault: any[] = [];
|
||||
let readIndexDefault = 0;
|
||||
const readIndexes: Record<WebSocketUID, number> = {};
|
||||
const unsubscriptions: Record<string, () => void> = {};
|
||||
let unsubscriptionDefault: () => void;
|
||||
|
||||
const waitForState = (
|
||||
state: WebSocket['readyState'],
|
||||
options?: {
|
||||
waitTimeout?: number;
|
||||
}
|
||||
) => {
|
||||
const startMs = Date.now();
|
||||
|
||||
const promise = () => {
|
||||
return new Promise(function (resolve, reject) {
|
||||
setTimeout(function () {
|
||||
if (isConnReady && conn && conn.readyState === state) {
|
||||
return resolve(true);
|
||||
} else if (Date.now() < startMs + (options?.waitTimeout ?? config?.waitTimeout ?? defaults.waitTimeout)) {
|
||||
return promise().then(resolve, reject);
|
||||
} else {
|
||||
let stateName = '';
|
||||
|
||||
switch (state) {
|
||||
case WebSocket.CONNECTING:
|
||||
stateName = 'CONNECTING';
|
||||
break;
|
||||
case WebSocket.OPEN:
|
||||
stateName = 'OPEN';
|
||||
break;
|
||||
case WebSocket.CLOSING:
|
||||
stateName = 'CLOSING';
|
||||
break;
|
||||
case WebSocket.CLOSED:
|
||||
stateName = 'CLOSED';
|
||||
break;
|
||||
default:
|
||||
stateName = 'INVALID';
|
||||
break;
|
||||
}
|
||||
|
||||
conn?.terminate();
|
||||
return reject(new Error(`WebSocket failed to achieve the ${stateName} state`));
|
||||
}
|
||||
}, 5);
|
||||
});
|
||||
};
|
||||
|
||||
return promise();
|
||||
};
|
||||
|
||||
const getMessages = async (
|
||||
messageCount: number,
|
||||
options?: {
|
||||
waitTimeout?: number;
|
||||
targetState?: WebSocket['readyState'];
|
||||
uid?: WebSocketUID;
|
||||
startIndex?: number;
|
||||
}
|
||||
): Promise<WebSocketResponse[] | undefined> => {
|
||||
const targetMessages = options?.uid ? messages[options.uid] ?? (messages[options.uid] = []) : messagesDefault;
|
||||
let startMessageIndex: number;
|
||||
|
||||
if (options?.startIndex) {
|
||||
startMessageIndex = options.startIndex;
|
||||
} else if (options?.uid) {
|
||||
startMessageIndex = readIndexes[options.uid] ?? 0;
|
||||
} else {
|
||||
startMessageIndex = readIndexDefault;
|
||||
}
|
||||
|
||||
const endMessageIndex = startMessageIndex + messageCount;
|
||||
|
||||
if (options?.uid) {
|
||||
readIndexes[String(options.uid)] = endMessageIndex;
|
||||
} else {
|
||||
readIndexDefault = endMessageIndex;
|
||||
}
|
||||
|
||||
await waitForState(options?.targetState ?? WebSocket.OPEN);
|
||||
const startMs = Date.now();
|
||||
|
||||
const promise = (): Promise<WebSocketResponse[] | undefined> => {
|
||||
return new Promise(function (resolve, reject) {
|
||||
setTimeout(function () {
|
||||
if (targetMessages.length >= endMessageIndex) {
|
||||
resolve(targetMessages.slice(startMessageIndex, endMessageIndex));
|
||||
} else if (Date.now() < startMs + (options?.waitTimeout ?? config?.waitTimeout ?? defaults.waitTimeout)) {
|
||||
return promise().then(resolve, reject);
|
||||
} else {
|
||||
conn?.terminate();
|
||||
|
||||
return reject(
|
||||
new Error(
|
||||
`Missing message${options?.uid ? ` for "${String(options.uid)}"` : ''} (received ${
|
||||
targetMessages.length - startMessageIndex
|
||||
}/${messageCount})`
|
||||
)
|
||||
);
|
||||
}
|
||||
}, 5);
|
||||
});
|
||||
};
|
||||
|
||||
return promise();
|
||||
};
|
||||
|
||||
const getMessageCount = (uid?: WebSocketUID) => {
|
||||
if (uid) {
|
||||
return messages[uid]?.length ?? 0;
|
||||
} else {
|
||||
return messagesDefault.length;
|
||||
}
|
||||
};
|
||||
|
||||
const subscribe = async (options: WebSocketSubscriptionOptionsGql) => {
|
||||
const targetMessages = options.uid ? messages[options.uid] ?? (messages[options.uid] = []) : messagesDefault;
|
||||
const subscriptionKey = `${options.collection}_mutated`;
|
||||
|
||||
const onNext = (data: any) => {
|
||||
targetMessages.push(data);
|
||||
};
|
||||
|
||||
const args = options.event ? { __args: { event: new EnumType(options.event) } } : false;
|
||||
|
||||
const unsubscribe = client.subscribe(
|
||||
{
|
||||
query: processGraphQLJson({
|
||||
subscription: { [subscriptionKey]: { ...args, ...options.jsonQuery } },
|
||||
}),
|
||||
},
|
||||
{
|
||||
next: onNext,
|
||||
error: () => {
|
||||
return;
|
||||
},
|
||||
complete: () => {
|
||||
return;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (options.uid) {
|
||||
unsubscriptions[options.uid] = unsubscribe;
|
||||
} else {
|
||||
unsubscriptionDefault = unsubscribe;
|
||||
}
|
||||
|
||||
await waitForState(WebSocket.OPEN);
|
||||
return subscriptionKey;
|
||||
};
|
||||
|
||||
const unsubscribe = (uid?: WebSocketUID) => {
|
||||
if (uid) {
|
||||
unsubscriptions[uid]?.();
|
||||
} else if (unsubscriptionDefault) {
|
||||
unsubscriptionDefault();
|
||||
}
|
||||
};
|
||||
|
||||
return { client, getMessages, getMessageCount, subscribe, unsubscribe, waitForState };
|
||||
}
|
||||
|
||||
@@ -1 +1,99 @@
|
||||
import { Query } from '@directus/types';
|
||||
import { ClientOptions } from 'ws';
|
||||
import { ClientOptions as ClientOptionsGql } from 'graphql-ws';
|
||||
|
||||
export type PrimaryKeyType = 'integer' | 'uuid' | 'string';
|
||||
export type WebSocketAuthMethod = 'public' | 'handshake' | 'strict';
|
||||
export type WebSocketUID = string | number;
|
||||
export type WebSocketResponse = {
|
||||
type: string;
|
||||
status?: string;
|
||||
uid?: WebSocketUID;
|
||||
event?: string;
|
||||
[field: string]: any;
|
||||
};
|
||||
export type WebSocketDefaultOptions = {
|
||||
/**
|
||||
* Authenticate once websocket connection is opened
|
||||
*/
|
||||
auth?: { email: string; password: string } | { access_token: string } | { refresh_token: string };
|
||||
|
||||
/**
|
||||
* Path of endpoint
|
||||
*/
|
||||
path?: string;
|
||||
|
||||
/**
|
||||
* Query string appended to URL
|
||||
*/
|
||||
queryString?: string;
|
||||
|
||||
/**
|
||||
* To disable response to pings
|
||||
*/
|
||||
respondToPing?: boolean;
|
||||
|
||||
/**
|
||||
* Timeout before erroring
|
||||
*/
|
||||
waitTimeout?: number;
|
||||
};
|
||||
export type WebSocketOptions = WebSocketDefaultOptions & {
|
||||
/**
|
||||
* Client options to be passed to ws
|
||||
*/
|
||||
client?: ClientOptions;
|
||||
};
|
||||
export type WebSocketOptionsGql = WebSocketDefaultOptions & {
|
||||
/**
|
||||
* Client options to be passed to graphql-ws
|
||||
*/
|
||||
client?: ClientOptionsGql;
|
||||
};
|
||||
export type WebSocketSubscriptionOptions = {
|
||||
/**
|
||||
* Collection to subscribe
|
||||
*/
|
||||
collection: string;
|
||||
|
||||
/**
|
||||
* Primary key of item
|
||||
*/
|
||||
item?: string | number;
|
||||
|
||||
/**
|
||||
* Query options
|
||||
*/
|
||||
query?: Query;
|
||||
|
||||
/**
|
||||
* Unique ID
|
||||
*/
|
||||
uid?: WebSocketUID;
|
||||
|
||||
/**
|
||||
* Event to subscribe
|
||||
*/
|
||||
event?: 'create' | 'update' | 'delete';
|
||||
};
|
||||
export type WebSocketSubscriptionOptionsGql = {
|
||||
/**
|
||||
* Collection to subscribe
|
||||
*/
|
||||
collection: string;
|
||||
|
||||
/**
|
||||
* GraphQL JSONQuery options
|
||||
*/
|
||||
jsonQuery: any;
|
||||
|
||||
/**
|
||||
* Unique ID
|
||||
*/
|
||||
uid?: WebSocketUID;
|
||||
|
||||
/**
|
||||
* Event to subscribe
|
||||
*/
|
||||
event?: 'create' | 'update' | 'delete';
|
||||
};
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
"@types/seedrandom": "3.0.5",
|
||||
"@types/supertest": "2.0.12",
|
||||
"@types/uuid": "9.0.1",
|
||||
"@types/ws": "8.5.4",
|
||||
"autocannon": "7.11.0",
|
||||
"axios": "1.4.0",
|
||||
"globby": "11.1.0",
|
||||
"graphql-ws": "5.12.0",
|
||||
"jest": "29.5.0",
|
||||
"jest-environment-node": "29.5.0",
|
||||
"js-yaml": "4.1.0",
|
||||
@@ -24,6 +26,7 @@
|
||||
"lodash": "4.17.21",
|
||||
"seedrandom": "3.0.5",
|
||||
"supertest": "6.3.3",
|
||||
"ws": "8.12.1",
|
||||
"ts-jest": "29.1.0",
|
||||
"typescript": "5.0.4",
|
||||
"uuid": "9.0.0"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getUrl } from '@common/config';
|
||||
import * as common from '@common/index';
|
||||
import request from 'supertest';
|
||||
import vendors from '@common/get-dbs-to-test';
|
||||
import { requestGraphQL } from '@common/index';
|
||||
import { createWebSocketConn, requestGraphQL } from '@common/index';
|
||||
|
||||
describe('/auth', () => {
|
||||
describe('POST /login', () => {
|
||||
@@ -33,6 +33,17 @@ describe('/auth', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const ws = createWebSocketConn(getUrl(vendor));
|
||||
|
||||
await ws.sendMessage({
|
||||
type: 'auth',
|
||||
email: common.USER[userKey].EMAIL,
|
||||
password: common.USER[userKey].PASSWORD,
|
||||
});
|
||||
|
||||
const wsMessages = await ws.getMessages(1);
|
||||
ws.conn.close();
|
||||
|
||||
// Assert
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
@@ -55,6 +66,16 @@ describe('/auth', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(wsMessages?.length).toBe(1);
|
||||
|
||||
expect(wsMessages![0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'auth',
|
||||
status: 'ok',
|
||||
refresh_token: expect.any(String),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -62,7 +83,7 @@ describe('/auth', () => {
|
||||
});
|
||||
|
||||
describe('when incorrect credentials are provided', () => {
|
||||
describe('returns code: UNAUTHORIZED for incorrect password', () => {
|
||||
describe('returns code: INVALID_CREDENTIALS for incorrect password', () => {
|
||||
common.TEST_USERS.forEach((userKey) => {
|
||||
describe(common.USER[userKey].NAME, () => {
|
||||
it.each(vendors)('%s', async (vendor) => {
|
||||
@@ -92,6 +113,17 @@ describe('/auth', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const ws = createWebSocketConn(getUrl(vendor));
|
||||
|
||||
await ws.sendMessage({
|
||||
type: 'auth',
|
||||
email: common.USER[userKey].EMAIL,
|
||||
password: common.USER[userKey].PASSWORD + 'typo',
|
||||
});
|
||||
|
||||
const wsMessages = await ws.getMessages(1);
|
||||
ws.conn.close();
|
||||
|
||||
// Assert
|
||||
expect(response.body).toMatchObject({
|
||||
errors: [
|
||||
@@ -114,12 +146,23 @@ describe('/auth', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(wsMessages?.length).toBe(1);
|
||||
|
||||
expect(wsMessages![0]).toMatchObject({
|
||||
type: 'auth',
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'AUTH_FAILED',
|
||||
message: 'Authentication handshake failed.',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('returns code: UNAUTHORIZED for unregistered email', () => {
|
||||
describe('returns code: INVALID_CREDENTIALS for unregistered email', () => {
|
||||
common.TEST_USERS.forEach((userKey) => {
|
||||
describe(common.USER[userKey].NAME, () => {
|
||||
it.each(vendors)('%s', async (vendor) => {
|
||||
@@ -149,6 +192,17 @@ describe('/auth', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const ws = createWebSocketConn(getUrl(vendor));
|
||||
|
||||
await ws.sendMessage({
|
||||
type: 'auth',
|
||||
email: 'test@fake.com',
|
||||
password: common.USER[userKey].PASSWORD,
|
||||
});
|
||||
|
||||
const wsMessages = await ws.getMessages(1);
|
||||
ws.conn.close();
|
||||
|
||||
// Assert
|
||||
expect(response.body).toMatchObject({
|
||||
errors: [
|
||||
@@ -171,6 +225,17 @@ describe('/auth', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(wsMessages?.length).toBe(1);
|
||||
|
||||
expect(wsMessages![0]).toMatchObject({
|
||||
type: 'auth',
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'AUTH_FAILED',
|
||||
message: 'Authentication handshake failed.',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -206,6 +271,11 @@ describe('/auth', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const ws = createWebSocketConn(getUrl(vendor));
|
||||
await ws.sendMessage({ type: 'auth', email: 'invalidEmail', password: common.USER[userKey].PASSWORD });
|
||||
const wsMessages = await ws.getMessages(1, { targetState: ws.conn.CLOSED });
|
||||
ws.conn.close();
|
||||
|
||||
// Assert
|
||||
expect(response.body).toMatchObject({
|
||||
errors: [
|
||||
@@ -228,6 +298,17 @@ describe('/auth', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(wsMessages?.length).toBe(1);
|
||||
|
||||
expect(wsMessages![0]).toMatchObject({
|
||||
type: 'auth',
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'AUTH_FAILED',
|
||||
message: 'Authentication handshake failed.',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -252,7 +333,7 @@ describe('/auth', () => {
|
||||
mutation: {
|
||||
[mutationKey]: {
|
||||
__args: {
|
||||
email: 'invalidEmail',
|
||||
email: common.USER[userKey].EMAIL,
|
||||
},
|
||||
access_token: true,
|
||||
expires: true,
|
||||
@@ -261,6 +342,11 @@ describe('/auth', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const ws = createWebSocketConn(getUrl(vendor));
|
||||
await ws.sendMessage({ type: 'auth', email: common.USER[userKey].EMAIL });
|
||||
const wsMessages = await ws.getMessages(1, { targetState: ws.conn.CLOSED });
|
||||
ws.conn.close();
|
||||
|
||||
// Assert
|
||||
expect(response.body).toMatchObject({
|
||||
errors: [
|
||||
@@ -283,6 +369,17 @@ describe('/auth', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(wsMessages?.length).toBe(1);
|
||||
|
||||
expect(wsMessages![0]).toMatchObject({
|
||||
type: 'auth',
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'AUTH_FAILED',
|
||||
message: 'Authentication handshake failed.',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1210,12 +1210,55 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
states2[i].country_id = createCountry(pkType);
|
||||
}
|
||||
|
||||
const ws = common.createWebSocketConn(getUrl(vendor), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
await ws.subscribe({ collection: localCollectionCountries, uid: localCollectionCountries });
|
||||
await ws.subscribe({ collection: localCollectionStates, uid: localCollectionStates });
|
||||
|
||||
const wsGql = common.createWebSocketGql(getUrl(vendor), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
const subscriptionKeyCountries = await wsGql.subscribe({
|
||||
collection: localCollectionCountries,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
uid: localCollectionCountries,
|
||||
});
|
||||
|
||||
const subscriptionKeyStates = await wsGql.subscribe({
|
||||
collection: localCollectionStates,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
country_id: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
uid: localCollectionStates,
|
||||
});
|
||||
|
||||
// Action
|
||||
const response = await request(getUrl(vendor))
|
||||
.post(`/items/${localCollectionStates}`)
|
||||
.send(states)
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
|
||||
const wsMessagesCountries = await ws.getMessages(count, { uid: localCollectionCountries });
|
||||
const wsMessagesStates = await ws.getMessages(count, { uid: localCollectionStates });
|
||||
const wsGqlMessagesCountries = await wsGql.getMessages(count, { uid: localCollectionCountries });
|
||||
const wsGqlMessagesStates = await wsGql.getMessages(count, { uid: localCollectionStates });
|
||||
|
||||
const mutationKey = `create_${localCollectionStates}_items`;
|
||||
|
||||
const gqlResponse = await requestGraphQL(getUrl(vendor), false, common.USER.ADMIN.TOKEN, {
|
||||
@@ -1229,11 +1272,88 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
},
|
||||
});
|
||||
|
||||
const wsMessagesGqlCountries = await ws.getMessages(count, { uid: localCollectionCountries });
|
||||
const wsMessagesGqlStates = await ws.getMessages(count, { uid: localCollectionStates });
|
||||
ws.conn.close();
|
||||
const wsGqlMessagesGqlCountries = await wsGql.getMessages(count, { uid: localCollectionCountries });
|
||||
const wsGqlMessagesGqlStates = await wsGql.getMessages(count, { uid: localCollectionStates });
|
||||
wsGql.client.dispose();
|
||||
|
||||
// Assert
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.data.length).toBe(count);
|
||||
expect(gqlResponse.statusCode).toBe(200);
|
||||
expect(gqlResponse.body.data[mutationKey].length).toEqual(count);
|
||||
|
||||
for (const { messagesCountries, messagesStates } of [
|
||||
{ messagesCountries: wsMessagesCountries, messagesStates: wsMessagesStates },
|
||||
{ messagesCountries: wsMessagesGqlCountries, messagesStates: wsMessagesGqlStates },
|
||||
]) {
|
||||
expect(messagesCountries?.length).toBe(count);
|
||||
expect(messagesStates?.length).toBe(count);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
expect(messagesCountries![i]).toMatchObject({
|
||||
type: 'subscription',
|
||||
event: 'create',
|
||||
data: [
|
||||
{
|
||||
id: expect.anything(),
|
||||
name: expect.any(String),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(messagesStates![i]).toMatchObject({
|
||||
type: 'subscription',
|
||||
event: 'create',
|
||||
data: [
|
||||
{
|
||||
id: expect.anything(),
|
||||
name: expect.any(String),
|
||||
country_id: expect.anything(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const { messagesCountries, messagesStates } of [
|
||||
{ messagesCountries: wsGqlMessagesCountries, messagesStates: wsGqlMessagesStates },
|
||||
{ messagesCountries: wsGqlMessagesGqlCountries, messagesStates: wsGqlMessagesGqlStates },
|
||||
]) {
|
||||
expect(messagesCountries?.length).toBe(count);
|
||||
expect(messagesStates?.length).toBe(count);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
expect(messagesCountries![i]).toEqual({
|
||||
data: {
|
||||
[subscriptionKeyCountries]: {
|
||||
event: 'create',
|
||||
data: {
|
||||
id: expect.anything(),
|
||||
name: expect.any(String),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(messagesStates![i]).toEqual({
|
||||
data: {
|
||||
[subscriptionKeyStates]: {
|
||||
event: 'create',
|
||||
data: {
|
||||
id: expect.anything(),
|
||||
name: expect.any(String),
|
||||
country_id: {
|
||||
id: expect.anything(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
120000
|
||||
);
|
||||
@@ -1256,6 +1376,40 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
states2[i].country_id = createCountry(pkType);
|
||||
}
|
||||
|
||||
const ws = common.createWebSocketConn(getUrl(vendor), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
await ws.subscribe({ collection: localCollectionCountries, uid: localCollectionCountries });
|
||||
await ws.subscribe({ collection: localCollectionStates, uid: localCollectionStates });
|
||||
|
||||
const wsGql = common.createWebSocketGql(getUrl(vendor), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
await wsGql.subscribe({
|
||||
collection: localCollectionCountries,
|
||||
jsonQuery: {
|
||||
id: true,
|
||||
name: true,
|
||||
event: true,
|
||||
},
|
||||
uid: localCollectionCountries,
|
||||
});
|
||||
|
||||
await wsGql.subscribe({
|
||||
collection: localCollectionStates,
|
||||
jsonQuery: {
|
||||
id: true,
|
||||
name: true,
|
||||
country_id: {
|
||||
id: true,
|
||||
},
|
||||
event: true,
|
||||
},
|
||||
uid: localCollectionStates,
|
||||
});
|
||||
|
||||
// Action
|
||||
const response = await request(getUrl(vendor))
|
||||
.post(`/items/${localCollectionStates}`)
|
||||
@@ -1275,6 +1429,9 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
},
|
||||
});
|
||||
|
||||
ws.conn.close();
|
||||
wsGql.client.dispose();
|
||||
|
||||
// Assert
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body.errors).toBeDefined();
|
||||
@@ -1289,6 +1446,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
expect(gqlResponse.body.errors[0].message).toBe(
|
||||
`Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.`
|
||||
);
|
||||
|
||||
expect(ws.getMessageCount(localCollectionCountries)).toBe(1);
|
||||
expect(ws.getMessageCount(localCollectionStates)).toBe(1);
|
||||
expect(wsGql.getMessageCount(localCollectionCountries)).toBe(0);
|
||||
expect(wsGql.getMessageCount(localCollectionStates)).toBe(0);
|
||||
},
|
||||
120000
|
||||
);
|
||||
@@ -1495,6 +1657,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
const stateIDs = [];
|
||||
const stateIDs2 = [];
|
||||
const newCountry = createCountry(pkType);
|
||||
const newCountry2 = createCountry(pkType);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const state: any = createState(pkType);
|
||||
@@ -1506,12 +1669,55 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
stateIDs2.push((await CreateItem(vendor, { collection: localCollectionStates, item: state2 })).id);
|
||||
}
|
||||
|
||||
const ws = common.createWebSocketConn(getUrl(vendor), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
await ws.subscribe({ collection: localCollectionCountries, uid: localCollectionCountries });
|
||||
await ws.subscribe({ collection: localCollectionStates, uid: localCollectionStates });
|
||||
|
||||
const wsGql = common.createWebSocketGql(getUrl(vendor), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
const subscriptionKeyCountries = await wsGql.subscribe({
|
||||
collection: localCollectionCountries,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
uid: localCollectionCountries,
|
||||
});
|
||||
|
||||
const subscriptionKeyStates = await wsGql.subscribe({
|
||||
collection: localCollectionStates,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
country_id: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
uid: localCollectionStates,
|
||||
});
|
||||
|
||||
// Action
|
||||
const response = await request(getUrl(vendor))
|
||||
.patch(`/items/${localCollectionStates}`)
|
||||
.send({ keys: stateIDs, data: { country_id: newCountry } })
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
|
||||
const wsMessagesCountries = await ws.getMessages(1, { uid: localCollectionCountries });
|
||||
const wsMessagesStates = await ws.getMessages(1, { uid: localCollectionStates });
|
||||
const wsGqlMessagesCountries = await wsGql.getMessages(1, { uid: localCollectionCountries });
|
||||
const wsGqlMessagesStates = await wsGql.getMessages(count - 1, { uid: localCollectionStates });
|
||||
|
||||
const mutationKey = `update_${localCollectionStates}_items`;
|
||||
|
||||
const gqlResponse = await requestGraphQL(getUrl(vendor), false, common.USER.ADMIN.TOKEN, {
|
||||
@@ -1519,19 +1725,94 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
[mutationKey]: {
|
||||
__args: {
|
||||
ids: stateIDs2,
|
||||
data: { country_id: newCountry },
|
||||
data: { country_id: newCountry2 },
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wsMessagesGqlCountries = await ws.getMessages(1, { uid: localCollectionCountries });
|
||||
const wsMessagesGqlStates = await ws.getMessages(1, { uid: localCollectionStates });
|
||||
ws.conn.close();
|
||||
const wsGqlMessagesGqlCountries = await wsGql.getMessages(1, { uid: localCollectionCountries });
|
||||
const wsGqlMessagesGqlStates = await wsGql.getMessages(count - 1, { uid: localCollectionStates });
|
||||
wsGql.client.dispose();
|
||||
|
||||
// Assert
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.data.length).toBe(count);
|
||||
|
||||
expect(gqlResponse.statusCode).toBe(200);
|
||||
expect(gqlResponse.body.data[mutationKey].length).toEqual(count);
|
||||
|
||||
for (const { messagesCountries, messagesStates } of [
|
||||
{ messagesCountries: wsMessagesCountries, messagesStates: wsMessagesStates },
|
||||
{ messagesCountries: wsMessagesGqlCountries, messagesStates: wsMessagesGqlStates },
|
||||
]) {
|
||||
expect(messagesCountries?.length).toBe(1);
|
||||
expect(messagesStates?.length).toBe(1);
|
||||
|
||||
expect(messagesCountries![0]).toMatchObject({
|
||||
type: 'subscription',
|
||||
event: 'create',
|
||||
data: [
|
||||
{
|
||||
id: expect.anything(),
|
||||
name: expect.anything(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(messagesStates![0]).toMatchObject({
|
||||
type: 'subscription',
|
||||
event: 'update',
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expect.anything(),
|
||||
name: expect.any(String),
|
||||
country_id: expect.anything(),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
for (const { messagesCountries, messagesStates } of [
|
||||
{ messagesCountries: wsGqlMessagesCountries, messagesStates: wsGqlMessagesStates },
|
||||
{ messagesCountries: wsGqlMessagesGqlCountries, messagesStates: wsGqlMessagesGqlStates },
|
||||
]) {
|
||||
expect(messagesCountries?.length).toBe(1);
|
||||
expect(messagesStates?.length).toBe(count - 1);
|
||||
|
||||
expect(messagesCountries![0]).toEqual({
|
||||
data: {
|
||||
[subscriptionKeyCountries]: {
|
||||
event: 'create',
|
||||
data: {
|
||||
id: expect.anything(),
|
||||
name: expect.any(String),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (let i = 0; i < count - 1; i++) {
|
||||
expect(messagesStates![i]).toEqual({
|
||||
data: {
|
||||
[subscriptionKeyStates]: {
|
||||
event: 'update',
|
||||
data: {
|
||||
id: expect.anything(),
|
||||
name: expect.any(String),
|
||||
country_id: {
|
||||
id: expect.anything(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
120000
|
||||
);
|
||||
@@ -1557,6 +1838,44 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
stateIDs2.push((await CreateItem(vendor, { collection: localCollectionStates, item: state2 })).id);
|
||||
}
|
||||
|
||||
const ws = common.createWebSocketConn(getUrl(vendor), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
await ws.subscribe({ collection: localCollectionCountries, uid: localCollectionCountries });
|
||||
await ws.subscribe({ collection: localCollectionStates, uid: localCollectionStates });
|
||||
|
||||
const wsGql = common.createWebSocketGql(getUrl(vendor), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
await wsGql.subscribe({
|
||||
collection: localCollectionCountries,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
uid: localCollectionCountries,
|
||||
});
|
||||
|
||||
await wsGql.subscribe({
|
||||
collection: localCollectionStates,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
country_id: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
uid: localCollectionStates,
|
||||
});
|
||||
|
||||
// Action
|
||||
const response = await request(getUrl(vendor))
|
||||
.patch(`/items/${localCollectionStates}`)
|
||||
@@ -1577,6 +1896,9 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
},
|
||||
});
|
||||
|
||||
ws.conn.close();
|
||||
wsGql.client.dispose();
|
||||
|
||||
// Assert
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body.errors).toBeDefined();
|
||||
@@ -1591,6 +1913,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
expect(gqlResponse.body.errors[0].message).toBe(
|
||||
`Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.`
|
||||
);
|
||||
|
||||
expect(ws.getMessageCount(localCollectionCountries)).toBe(1);
|
||||
expect(ws.getMessageCount(localCollectionStates)).toBe(1);
|
||||
expect(wsGql.getMessageCount(localCollectionCountries)).toBe(0);
|
||||
expect(wsGql.getMessageCount(localCollectionStates)).toBe(0);
|
||||
},
|
||||
120000
|
||||
);
|
||||
|
||||
@@ -161,7 +161,21 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
item: createArtist(pkType),
|
||||
});
|
||||
|
||||
const body = { name: 'Tommy Cash' };
|
||||
const body = { name: 'updated' };
|
||||
const ws = common.createWebSocketConn(getUrl(vendor), { auth: { access_token: common.USER.ADMIN.TOKEN } });
|
||||
await ws.subscribe({ collection: localCollectionArtists });
|
||||
const wsGql = common.createWebSocketGql(getUrl(vendor), { auth: { access_token: common.USER.ADMIN.TOKEN } });
|
||||
|
||||
const subscriptionKey = await wsGql.subscribe({
|
||||
collection: localCollectionArtists,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Action
|
||||
const response = await request(getUrl(vendor))
|
||||
@@ -169,6 +183,9 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
.send(body)
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
|
||||
const wsMessages = await ws.getMessages(1);
|
||||
const wsGqlMessages = await wsGql.getMessages(1);
|
||||
|
||||
const mutationKey = `update_${localCollectionArtists}_item`;
|
||||
|
||||
const gqlResponse = await requestGraphQL(getUrl(vendor), false, common.USER.ADMIN.TOKEN, {
|
||||
@@ -177,7 +194,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
__args: {
|
||||
id: insertedArtist.id,
|
||||
data: {
|
||||
name: 'updated',
|
||||
name: 'updated2',
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
@@ -186,20 +203,63 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
},
|
||||
});
|
||||
|
||||
const wsMessagesGql = await ws.getMessages(1);
|
||||
ws.conn.close();
|
||||
const wsGqlMessagesGql = await wsGql.getMessages(1);
|
||||
wsGql.client.dispose();
|
||||
|
||||
// Assert
|
||||
expect(response.statusCode).toEqual(200);
|
||||
|
||||
expect(response.body.data).toMatchObject({
|
||||
id: insertedArtist.id,
|
||||
name: 'Tommy Cash',
|
||||
name: 'updated',
|
||||
});
|
||||
|
||||
expect(gqlResponse.statusCode).toBe(200);
|
||||
|
||||
expect(gqlResponse.body.data[mutationKey]).toEqual({
|
||||
id: String(insertedArtist.id),
|
||||
name: 'updated',
|
||||
name: 'updated2',
|
||||
});
|
||||
|
||||
for (const { messages, name } of [
|
||||
{ messages: wsMessages, name: 'updated' },
|
||||
{ messages: wsMessagesGql, name: 'updated2' },
|
||||
]) {
|
||||
expect(messages?.length).toBe(1);
|
||||
|
||||
expect(messages![0]).toMatchObject({
|
||||
type: 'subscription',
|
||||
event: 'update',
|
||||
data: [
|
||||
{
|
||||
id: insertedArtist.id,
|
||||
name,
|
||||
company: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
for (const { messages, name } of [
|
||||
{ messages: wsGqlMessages, name: 'updated' },
|
||||
{ messages: wsGqlMessagesGql, name: 'updated2' },
|
||||
]) {
|
||||
expect(messages?.length).toBe(1);
|
||||
|
||||
expect(messages![0]).toEqual({
|
||||
data: {
|
||||
[subscriptionKey]: {
|
||||
event: 'update',
|
||||
data: {
|
||||
id: String(insertedArtist.id),
|
||||
name,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -218,11 +278,26 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
item: createArtist(pkType),
|
||||
});
|
||||
|
||||
const ws = common.createWebSocketConn(getUrl(vendor), { auth: { access_token: common.USER.ADMIN.TOKEN } });
|
||||
await ws.subscribe({ collection: localCollectionArtists });
|
||||
const wsGql = common.createWebSocketGql(getUrl(vendor), { auth: { access_token: common.USER.ADMIN.TOKEN } });
|
||||
|
||||
const subscriptionKey = await wsGql.subscribe({
|
||||
collection: localCollectionArtists,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
key: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Action
|
||||
const response = await request(getUrl(vendor))
|
||||
.delete(`/items/${localCollectionArtists}/${insertedArtist.id}`)
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
|
||||
const wsMessages = await ws.getMessages(1);
|
||||
const wsGqlMessages = await wsGql.getMessages(1);
|
||||
|
||||
const mutationKey = `delete_${localCollectionArtists}_item`;
|
||||
|
||||
await requestGraphQL(getUrl(vendor), false, common.USER.ADMIN.TOKEN, {
|
||||
@@ -236,6 +311,9 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
},
|
||||
});
|
||||
|
||||
const wsMessagesGql = await ws.getMessages(1);
|
||||
const wsGqlMessagesGql = await wsGql.getMessages(1);
|
||||
|
||||
const gqlResponse = await requestGraphQL(getUrl(vendor), false, common.USER.ADMIN.TOKEN, {
|
||||
query: {
|
||||
[localCollectionArtists]: {
|
||||
@@ -251,12 +329,44 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
},
|
||||
});
|
||||
|
||||
ws.conn.close();
|
||||
wsGql.client.dispose();
|
||||
|
||||
// Assert
|
||||
expect(response.statusCode).toEqual(204);
|
||||
expect(response.body.data).toBe(undefined);
|
||||
|
||||
expect(gqlResponse.statusCode).toBe(200);
|
||||
expect(gqlResponse.body.data[localCollectionArtists].length).toEqual(0);
|
||||
|
||||
for (const { messages, id } of [
|
||||
{ messages: wsMessages, id: insertedArtist.id },
|
||||
{ messages: wsMessagesGql, id: insertedArtist2.id },
|
||||
]) {
|
||||
expect(messages?.length).toBe(1);
|
||||
|
||||
expect(messages![0]).toMatchObject({
|
||||
type: 'subscription',
|
||||
event: 'delete',
|
||||
data: [String(id)],
|
||||
});
|
||||
}
|
||||
|
||||
for (const { messages, id } of [
|
||||
{ messages: wsGqlMessages, id: insertedArtist.id },
|
||||
{ messages: wsGqlMessagesGql, id: insertedArtist2.id },
|
||||
]) {
|
||||
expect(messages?.length).toBe(1);
|
||||
|
||||
expect(messages![0]).toEqual({
|
||||
data: {
|
||||
[subscriptionKey]: {
|
||||
event: 'delete',
|
||||
key: String(id),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -332,12 +442,33 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
const artist2 = createArtist(pkType);
|
||||
artist2.name = 'one-' + artist2.name;
|
||||
|
||||
const ws = common.createWebSocketConn(getUrl(vendor), { auth: { access_token: common.USER.ADMIN.TOKEN } });
|
||||
await ws.subscribe({ collection: localCollectionArtists });
|
||||
|
||||
const wsGql = common.createWebSocketGql(getUrl(vendor), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
const subscriptionKey = await wsGql.subscribe({
|
||||
collection: localCollectionArtists,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Action
|
||||
const response = await request(getUrl(vendor))
|
||||
.post(`/items/${localCollectionArtists}`)
|
||||
.send(artist)
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
|
||||
const wsMessages = await ws.getMessages(1);
|
||||
const wsGqlMessages = await wsGql.getMessages(1);
|
||||
|
||||
const mutationKey = `create_${localCollectionArtists}_item`;
|
||||
|
||||
const gqlResponse = await requestGraphQL(getUrl(vendor), false, common.USER.ADMIN.TOKEN, {
|
||||
@@ -351,12 +482,55 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
},
|
||||
});
|
||||
|
||||
const wsMessagesGql = await ws.getMessages(1);
|
||||
ws.conn.close();
|
||||
const wsGqlMessagesGql = await wsGql.getMessages(1);
|
||||
wsGql.client.dispose();
|
||||
|
||||
// Assert
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(response.body.data).toMatchObject({ name: artist.name });
|
||||
|
||||
expect(gqlResponse.statusCode).toEqual(200);
|
||||
expect(gqlResponse.body.data[mutationKey]).toMatchObject({ name: artist2.name });
|
||||
|
||||
for (const { messages, name } of [
|
||||
{ messages: wsMessages, name: artist.name },
|
||||
{ messages: wsMessagesGql, name: artist2.name },
|
||||
]) {
|
||||
expect(messages?.length).toBe(1);
|
||||
|
||||
expect(messages![0]).toMatchObject({
|
||||
type: 'subscription',
|
||||
event: 'create',
|
||||
data: [
|
||||
{
|
||||
id: expect.anything(),
|
||||
name,
|
||||
company: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
for (const { messages, name } of [
|
||||
{ messages: wsGqlMessages, name: artist.name },
|
||||
{ messages: wsGqlMessagesGql, name: artist2.name },
|
||||
]) {
|
||||
expect(messages?.length).toBe(1);
|
||||
|
||||
expect(messages![0]).toEqual({
|
||||
data: {
|
||||
[subscriptionKey]: {
|
||||
event: 'create',
|
||||
data: {
|
||||
id: expect.anything(),
|
||||
name,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -379,12 +553,33 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
artists2.push(artist2);
|
||||
}
|
||||
|
||||
const ws = common.createWebSocketConn(getUrl(vendor), { auth: { access_token: common.USER.ADMIN.TOKEN } });
|
||||
await ws.subscribe({ collection: localCollectionArtists });
|
||||
|
||||
const wsGql = common.createWebSocketGql(getUrl(vendor), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
const subscriptionKey = await wsGql.subscribe({
|
||||
collection: localCollectionArtists,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Action
|
||||
const response = await request(getUrl(vendor))
|
||||
.post(`/items/${localCollectionArtists}`)
|
||||
.send(artists)
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
|
||||
const wsMessages = await ws.getMessages(artistsCount);
|
||||
const wsGqlMessages = await wsGql.getMessages(artistsCount);
|
||||
|
||||
const mutationKey = `create_${localCollectionArtists}_items`;
|
||||
|
||||
const gqlResponse = await requestGraphQL(getUrl(vendor), false, common.USER.ADMIN.TOKEN, {
|
||||
@@ -398,12 +593,63 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
},
|
||||
});
|
||||
|
||||
const wsMessagesGql = await ws.getMessages(artistsCount);
|
||||
ws.conn.close();
|
||||
const wsGqlMessagesGql = await wsGql.getMessages(artistsCount);
|
||||
wsGql.client.dispose();
|
||||
|
||||
// Assert
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(response.body.data.length).toBe(artistsCount);
|
||||
|
||||
expect(gqlResponse.statusCode).toEqual(200);
|
||||
expect(gqlResponse.body.data[mutationKey].length).toBe(artistsCount);
|
||||
|
||||
for (const { messages, list } of [
|
||||
{ messages: wsMessages, list: artists },
|
||||
{ messages: wsMessagesGql, list: artists2 },
|
||||
]) {
|
||||
expect(messages?.length).toBe(artistsCount);
|
||||
|
||||
expect(messages).toEqual(
|
||||
expect.arrayContaining(
|
||||
list.map(({ name }) => {
|
||||
return {
|
||||
type: 'subscription',
|
||||
event: 'create',
|
||||
data: [
|
||||
{
|
||||
id: expect.anything(),
|
||||
name,
|
||||
company: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
for (const { messages, list } of [
|
||||
{ messages: wsGqlMessages, list: artists },
|
||||
{ messages: wsGqlMessagesGql, list: artists2 },
|
||||
]) {
|
||||
expect(messages?.length).toBe(artistsCount);
|
||||
|
||||
for (let i = 0; i < artistsCount; i++) {
|
||||
expect(messages![i]).toEqual({
|
||||
data: {
|
||||
[subscriptionKey]: {
|
||||
event: 'create',
|
||||
data: {
|
||||
id: expect.anything(),
|
||||
name: list[i].name,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -460,15 +706,33 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
|
||||
const body = {
|
||||
keys: keys,
|
||||
data: { name: 'Johnny Cash' },
|
||||
data: { name: 'updated' },
|
||||
};
|
||||
|
||||
const ws = common.createWebSocketConn(getUrl(vendor), { auth: { access_token: common.USER.ADMIN.TOKEN } });
|
||||
await ws.subscribe({ collection: localCollectionArtists });
|
||||
const wsGql = common.createWebSocketGql(getUrl(vendor), { auth: { access_token: common.USER.ADMIN.TOKEN } });
|
||||
|
||||
const subscriptionKey = await wsGql.subscribe({
|
||||
collection: localCollectionArtists,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Action
|
||||
const response = await request(getUrl(vendor))
|
||||
.patch(`/items/${localCollectionArtists}?fields=name`)
|
||||
.send(body)
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
|
||||
const wsMessages = await ws.getMessages(1);
|
||||
const wsGqlMessages = await wsGql.getMessages(artistsCount);
|
||||
|
||||
const mutationKey = `update_${localCollectionArtists}_items`;
|
||||
|
||||
const gqlResponse = await requestGraphQL(getUrl(vendor), false, common.USER.ADMIN.TOKEN, {
|
||||
@@ -477,7 +741,7 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
__args: {
|
||||
ids: keys,
|
||||
data: {
|
||||
name: 'updated',
|
||||
name: 'updated2',
|
||||
},
|
||||
},
|
||||
name: true,
|
||||
@@ -485,12 +749,17 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
},
|
||||
});
|
||||
|
||||
const wsMessagesGql = await ws.getMessages(1);
|
||||
ws.conn.close();
|
||||
const wsGqlMessagesGql = await wsGql.getMessages(artistsCount);
|
||||
wsGql.client.dispose();
|
||||
|
||||
// Assert
|
||||
expect(response.statusCode).toEqual(200);
|
||||
|
||||
for (let row = 0; row < response.body.data.length; row++) {
|
||||
expect(response.body.data[row]).toMatchObject({
|
||||
name: 'Johnny Cash',
|
||||
name: 'updated',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -500,11 +769,55 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
|
||||
for (let row = 0; row < gqlResponse.body.data[mutationKey].length; row++) {
|
||||
expect(gqlResponse.body.data[mutationKey][row]).toMatchObject({
|
||||
name: 'updated',
|
||||
name: 'updated2',
|
||||
});
|
||||
}
|
||||
|
||||
expect(gqlResponse.body.data[mutationKey].length).toBe(keys.length);
|
||||
|
||||
for (const { messages, name } of [
|
||||
{ messages: wsMessages, name: 'updated' },
|
||||
{ messages: wsMessagesGql, name: 'updated2' },
|
||||
]) {
|
||||
expect(messages?.length).toBe(1);
|
||||
|
||||
expect(messages![0]).toMatchObject({
|
||||
type: 'subscription',
|
||||
event: 'update',
|
||||
data: keys.map((key) => {
|
||||
return {
|
||||
id: key,
|
||||
name,
|
||||
company: null,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
for (const { messages, name } of [
|
||||
{ messages: wsGqlMessages, name: 'updated' },
|
||||
{ messages: wsGqlMessagesGql, name: 'updated2' },
|
||||
]) {
|
||||
expect(messages?.length).toBe(artistsCount);
|
||||
|
||||
expect(messages).toEqual(
|
||||
expect.arrayContaining(
|
||||
keys.map((id) => {
|
||||
return {
|
||||
data: {
|
||||
[subscriptionKey]: {
|
||||
event: 'update',
|
||||
data: {
|
||||
id: String(id),
|
||||
name,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -528,12 +841,27 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
const insertedArtists2 = await CreateItem(vendor, { collection: localCollectionArtists, item: artists2 });
|
||||
const keys2 = Object.values(insertedArtists2 ?? []).map((item: any) => item.id);
|
||||
|
||||
const ws = common.createWebSocketConn(getUrl(vendor), { auth: { access_token: common.USER.ADMIN.TOKEN } });
|
||||
await ws.subscribe({ collection: localCollectionArtists });
|
||||
const wsGql = common.createWebSocketGql(getUrl(vendor), { auth: { access_token: common.USER.ADMIN.TOKEN } });
|
||||
|
||||
const subscriptionKey = await wsGql.subscribe({
|
||||
collection: localCollectionArtists,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
key: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Action
|
||||
const response = await request(getUrl(vendor))
|
||||
.delete(`/items/${localCollectionArtists}`)
|
||||
.send(keys)
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
|
||||
const wsMessages = await ws.getMessages(1);
|
||||
const wsGqlMessages = await wsGql.getMessages(artistsCount);
|
||||
|
||||
const mutationKey = `delete_${localCollectionArtists}_items`;
|
||||
|
||||
await requestGraphQL(getUrl(vendor), false, common.USER.ADMIN.TOKEN, {
|
||||
@@ -547,6 +875,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
},
|
||||
});
|
||||
|
||||
const wsMessagesGql = await ws.getMessages(1);
|
||||
ws.conn.close();
|
||||
const wsGqlMessagesGql = await wsGql.getMessages(artistsCount);
|
||||
wsGql.client.dispose();
|
||||
|
||||
const gqlResponse = await requestGraphQL(getUrl(vendor), false, common.USER.ADMIN.TOKEN, {
|
||||
query: {
|
||||
[localCollectionArtists]: {
|
||||
@@ -568,6 +901,37 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
|
||||
expect(gqlResponse.statusCode).toBe(200);
|
||||
expect(gqlResponse.body.data[localCollectionArtists].length).toEqual(0);
|
||||
|
||||
for (const { messages, ids } of [
|
||||
{ messages: wsMessages, ids: keys },
|
||||
{ messages: wsMessagesGql, ids: keys2.map((key) => String(key)) },
|
||||
]) {
|
||||
expect(messages?.length).toBe(1);
|
||||
|
||||
expect(messages![0]).toMatchObject({
|
||||
type: 'subscription',
|
||||
event: 'delete',
|
||||
data: ids,
|
||||
});
|
||||
}
|
||||
|
||||
for (const { messages, id } of [
|
||||
{ messages: wsGqlMessages, id: keys },
|
||||
{ messages: wsGqlMessagesGql, id: keys2.map((key) => String(key)) },
|
||||
]) {
|
||||
expect(messages?.length).toBe(artistsCount);
|
||||
|
||||
for (let i = 0; i < artistsCount; i++) {
|
||||
expect(messages![i]).toEqual({
|
||||
data: {
|
||||
[subscriptionKey]: {
|
||||
event: 'delete',
|
||||
key: String(id[i]),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1927,12 +1927,55 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
.fill(0)
|
||||
.map(() => createState(pkType));
|
||||
|
||||
const ws = common.createWebSocketConn(getUrl(vendor), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
await ws.subscribe({ collection: localCollectionCountries, uid: localCollectionCountries });
|
||||
await ws.subscribe({ collection: localCollectionStates, uid: localCollectionStates });
|
||||
|
||||
const wsGql = common.createWebSocketGql(getUrl(vendor), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
const subscriptionKeyCountries = await wsGql.subscribe({
|
||||
collection: localCollectionCountries,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
uid: localCollectionCountries,
|
||||
});
|
||||
|
||||
const subscriptionKeyStates = await wsGql.subscribe({
|
||||
collection: localCollectionStates,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
country_id: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
uid: localCollectionStates,
|
||||
});
|
||||
|
||||
// Action
|
||||
const response = await request(getUrl(vendor))
|
||||
.post(`/items/${localCollectionCountries}`)
|
||||
.send(country)
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
|
||||
const wsMessagesCountries = await ws.getMessages(1, { uid: localCollectionCountries });
|
||||
const wsMessagesStates = await ws.getMessages(1, { uid: localCollectionStates });
|
||||
const wsGqlMessagesCountries = await wsGql.getMessages(1, { uid: localCollectionCountries });
|
||||
const wsGqlMessagesStates = await wsGql.getMessages(1, { uid: localCollectionStates });
|
||||
|
||||
const mutationKey = `create_${localCollectionCountries}_item`;
|
||||
|
||||
const gqlResponse = await requestGraphQL(getUrl(vendor), false, common.USER.ADMIN.TOKEN, {
|
||||
@@ -1949,12 +1992,85 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
},
|
||||
});
|
||||
|
||||
const wsMessagesGqlCountries = await ws.getMessages(1, { uid: localCollectionCountries });
|
||||
const wsMessagesGqlStates = await ws.getMessages(1, { uid: localCollectionStates });
|
||||
ws.conn.close();
|
||||
const wsGqlMessagesGqlCountries = await wsGql.getMessages(1, { uid: localCollectionCountries });
|
||||
const wsGqlMessagesGqlStates = await wsGql.getMessages(1, { uid: localCollectionStates });
|
||||
wsGql.client.dispose();
|
||||
|
||||
// Assert
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.data.states.length).toBe(countNested);
|
||||
|
||||
expect(gqlResponse.statusCode).toBe(200);
|
||||
expect(gqlResponse.body.data[mutationKey].states.length).toEqual(countNested);
|
||||
|
||||
for (const { messagesCountries, messagesStates } of [
|
||||
{ messagesCountries: wsMessagesCountries, messagesStates: wsMessagesStates },
|
||||
{ messagesCountries: wsMessagesGqlCountries, messagesStates: wsMessagesGqlStates },
|
||||
]) {
|
||||
expect(messagesCountries?.length).toBe(1);
|
||||
expect(messagesStates?.length).toBe(1);
|
||||
|
||||
expect(messagesCountries![0]).toMatchObject({
|
||||
type: 'subscription',
|
||||
event: 'create',
|
||||
data: [
|
||||
{
|
||||
id: expect.anything(),
|
||||
name: expect.any(String),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(messagesStates![0]).toMatchObject({
|
||||
type: 'subscription',
|
||||
event: 'create',
|
||||
data: [
|
||||
{
|
||||
id: expect.anything(),
|
||||
name: expect.any(String),
|
||||
country_id: expect.anything(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
for (const { messagesCountries, messagesStates } of [
|
||||
{ messagesCountries: wsGqlMessagesCountries, messagesStates: wsGqlMessagesStates },
|
||||
{ messagesCountries: wsGqlMessagesGqlCountries, messagesStates: wsGqlMessagesGqlStates },
|
||||
]) {
|
||||
expect(messagesCountries?.length).toBe(1);
|
||||
expect(messagesStates?.length).toBe(1);
|
||||
|
||||
expect(messagesCountries![0]).toEqual({
|
||||
data: {
|
||||
[subscriptionKeyCountries]: {
|
||||
event: 'create',
|
||||
data: {
|
||||
id: expect.anything(),
|
||||
name: expect.any(String),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(messagesStates![0]).toEqual({
|
||||
data: {
|
||||
[subscriptionKeyStates]: {
|
||||
event: 'create',
|
||||
data: {
|
||||
id: expect.anything(),
|
||||
name: expect.any(String),
|
||||
country_id: {
|
||||
id: expect.anything(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
120000
|
||||
);
|
||||
@@ -1983,6 +2099,44 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
.fill(0)
|
||||
.map(() => createState(pkType));
|
||||
|
||||
const ws = common.createWebSocketConn(getUrl(vendor), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
await ws.subscribe({ collection: localCollectionCountries, uid: localCollectionCountries });
|
||||
await ws.subscribe({ collection: localCollectionStates, uid: localCollectionStates });
|
||||
|
||||
const wsGql = common.createWebSocketGql(getUrl(vendor), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
await wsGql.subscribe({
|
||||
collection: localCollectionCountries,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
uid: localCollectionCountries,
|
||||
});
|
||||
|
||||
await wsGql.subscribe({
|
||||
collection: localCollectionStates,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
country_id: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
uid: localCollectionStates,
|
||||
});
|
||||
|
||||
// Action
|
||||
const response = await request(getUrl(vendor))
|
||||
.post(`/items/${localCollectionCountries}`)
|
||||
@@ -2005,6 +2159,9 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
},
|
||||
});
|
||||
|
||||
ws.conn.close();
|
||||
wsGql.client.dispose();
|
||||
|
||||
// Assert
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body.errors).toBeDefined();
|
||||
@@ -2019,6 +2176,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
expect(gqlResponse.body.errors[0].message).toBe(
|
||||
`Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.`
|
||||
);
|
||||
|
||||
expect(ws.getMessageCount(localCollectionCountries)).toBe(1);
|
||||
expect(ws.getMessageCount(localCollectionStates)).toBe(1);
|
||||
expect(wsGql.getMessageCount(localCollectionCountries)).toBe(0);
|
||||
expect(wsGql.getMessageCount(localCollectionStates)).toBe(0);
|
||||
},
|
||||
120000
|
||||
);
|
||||
@@ -2050,12 +2212,55 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
.map(() => createState(pkType));
|
||||
}
|
||||
|
||||
const ws = common.createWebSocketConn(getUrl(vendor), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
await ws.subscribe({ collection: localCollectionCountries, uid: localCollectionCountries });
|
||||
await ws.subscribe({ collection: localCollectionStates, uid: localCollectionStates });
|
||||
|
||||
const wsGql = common.createWebSocketGql(getUrl(vendor), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
const subscriptionKeyCountries = await wsGql.subscribe({
|
||||
collection: localCollectionCountries,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
uid: localCollectionCountries,
|
||||
});
|
||||
|
||||
const subscriptionKeyStates = await wsGql.subscribe({
|
||||
collection: localCollectionStates,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
country_id: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
uid: localCollectionStates,
|
||||
});
|
||||
|
||||
// Action
|
||||
const response = await request(getUrl(vendor))
|
||||
.post(`/items/${localCollectionCountries}`)
|
||||
.send(countries)
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
|
||||
const wsMessagesCountries = await ws.getMessages(count, { uid: localCollectionCountries });
|
||||
const wsMessagesStates = await ws.getMessages(count, { uid: localCollectionStates });
|
||||
const wsGqlMessagesCountries = await wsGql.getMessages(count, { uid: localCollectionCountries });
|
||||
const wsGqlMessagesStates = await wsGql.getMessages(count, { uid: localCollectionStates });
|
||||
|
||||
const mutationKey = `create_${localCollectionCountries}_items`;
|
||||
|
||||
const gqlResponse = await requestGraphQL(getUrl(vendor), false, common.USER.ADMIN.TOKEN, {
|
||||
@@ -2069,12 +2274,89 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
},
|
||||
});
|
||||
|
||||
const wsMessagesGqlCountries = await ws.getMessages(count, { uid: localCollectionCountries });
|
||||
const wsMessagesGqlStates = await ws.getMessages(count, { uid: localCollectionStates });
|
||||
ws.conn.close();
|
||||
const wsGqlMessagesGqlCountries = await wsGql.getMessages(count, { uid: localCollectionCountries });
|
||||
const wsGqlMessagesGqlStates = await wsGql.getMessages(count, { uid: localCollectionStates });
|
||||
wsGql.client.dispose();
|
||||
|
||||
// Assert
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.data.length).toBe(count);
|
||||
|
||||
expect(gqlResponse.statusCode).toBe(200);
|
||||
expect(gqlResponse.body.data[mutationKey].length).toEqual(count);
|
||||
|
||||
for (const { messagesCountries, messagesStates } of [
|
||||
{ messagesCountries: wsMessagesCountries, messagesStates: wsMessagesStates },
|
||||
{ messagesCountries: wsMessagesGqlCountries, messagesStates: wsMessagesGqlStates },
|
||||
]) {
|
||||
expect(messagesCountries?.length).toBe(count);
|
||||
expect(messagesStates?.length).toBe(count);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
expect(messagesCountries![i]).toMatchObject({
|
||||
type: 'subscription',
|
||||
event: 'create',
|
||||
data: [
|
||||
{
|
||||
id: expect.anything(),
|
||||
name: expect.any(String),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(messagesStates![i]).toMatchObject({
|
||||
type: 'subscription',
|
||||
event: 'create',
|
||||
data: [
|
||||
{
|
||||
id: expect.anything(),
|
||||
name: expect.any(String),
|
||||
country_id: expect.anything(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const { messagesCountries, messagesStates } of [
|
||||
{ messagesCountries: wsGqlMessagesCountries, messagesStates: wsGqlMessagesStates },
|
||||
{ messagesCountries: wsGqlMessagesGqlCountries, messagesStates: wsGqlMessagesGqlStates },
|
||||
]) {
|
||||
expect(messagesCountries?.length).toBe(count);
|
||||
expect(messagesStates?.length).toBe(count);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
expect(messagesCountries![i]).toEqual({
|
||||
data: {
|
||||
[subscriptionKeyCountries]: {
|
||||
event: 'create',
|
||||
data: {
|
||||
id: expect.anything(),
|
||||
name: expect.any(String),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(messagesStates![i]).toEqual({
|
||||
data: {
|
||||
[subscriptionKeyStates]: {
|
||||
event: 'create',
|
||||
data: {
|
||||
id: expect.anything(),
|
||||
name: expect.any(String),
|
||||
country_id: {
|
||||
id: expect.anything(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
120000
|
||||
);
|
||||
@@ -2110,6 +2392,44 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
.map(() => createState(pkType));
|
||||
}
|
||||
|
||||
const ws = common.createWebSocketConn(getUrl(vendor), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
await ws.subscribe({ collection: localCollectionCountries, uid: localCollectionCountries });
|
||||
await ws.subscribe({ collection: localCollectionStates, uid: localCollectionStates });
|
||||
|
||||
const wsGql = common.createWebSocketGql(getUrl(vendor), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
await wsGql.subscribe({
|
||||
collection: localCollectionCountries,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
uid: localCollectionCountries,
|
||||
});
|
||||
|
||||
await wsGql.subscribe({
|
||||
collection: localCollectionStates,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
country_id: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
uid: localCollectionStates,
|
||||
});
|
||||
|
||||
// Action
|
||||
const response = await request(getUrl(vendor))
|
||||
.post(`/items/${localCollectionCountries}`)
|
||||
@@ -2129,6 +2449,9 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
},
|
||||
});
|
||||
|
||||
ws.conn.close();
|
||||
wsGql.client.dispose();
|
||||
|
||||
// Assert
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body.errors).toBeDefined();
|
||||
@@ -2143,6 +2466,11 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
expect(gqlResponse.body.errors[0].message).toBe(
|
||||
`Exceeded max batch mutation limit of ${config.envs[vendor].MAX_BATCH_MUTATION}.`
|
||||
);
|
||||
|
||||
expect(ws.getMessageCount(localCollectionCountries)).toBe(1);
|
||||
expect(ws.getMessageCount(localCollectionStates)).toBe(1);
|
||||
expect(wsGql.getMessageCount(localCollectionCountries)).toBe(0);
|
||||
expect(wsGql.getMessageCount(localCollectionStates)).toBe(0);
|
||||
},
|
||||
120000
|
||||
);
|
||||
@@ -2203,17 +2531,23 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
create: Array(countCreate)
|
||||
.fill(0)
|
||||
.map(() => createState(pkType)),
|
||||
update: states.slice(0, countUpdate),
|
||||
update: states.slice(0, countUpdate).map((state: State) => {
|
||||
state.name = 'updated';
|
||||
return state;
|
||||
}),
|
||||
delete: states.slice(-countDelete).map((state: State) => state.id),
|
||||
};
|
||||
}
|
||||
|
||||
for (const country of countries2) {
|
||||
country.states = [
|
||||
...country.states,
|
||||
...Array(countCreate)
|
||||
.fill(0)
|
||||
.map(() => createState(pkType)),
|
||||
...country.states.map((state: State) => {
|
||||
state.name = 'updated';
|
||||
return state;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2306,17 +2640,23 @@ describe.each(common.PRIMARY_KEY_TYPES)('/items', (pkType) => {
|
||||
create: Array(countCreate)
|
||||
.fill(0)
|
||||
.map(() => createState(pkType)),
|
||||
update: states.slice(0, countUpdate),
|
||||
update: states.slice(0, countUpdate).map((state: State) => {
|
||||
state.name = 'updated';
|
||||
return state;
|
||||
}),
|
||||
delete: states.slice(-countDelete).map((state: State) => state.id),
|
||||
};
|
||||
}
|
||||
|
||||
for (const country of countries2) {
|
||||
country.states = [
|
||||
...country.states,
|
||||
...Array(countCreate)
|
||||
.fill(0)
|
||||
.map(() => createState(pkType)),
|
||||
...country.states.map((state: State) => {
|
||||
state.name = 'updated';
|
||||
return state;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ exports.list = {
|
||||
{ testFilePath: '/schema/timezone/timezone.test.ts' },
|
||||
{ testFilePath: '/schema/timezone/timezone-changed-node-tz-america.test.ts' },
|
||||
{ testFilePath: '/schema/timezone/timezone-changed-node-tz-asia.test.ts' },
|
||||
{ testFilePath: '/websocket/auth.test.ts' },
|
||||
{ testFilePath: '/websocket/general.test.ts' },
|
||||
{ testFilePath: '/flows/schedule-hook.test.ts' },
|
||||
{ testFilePath: '/logger/redact.test.ts' },
|
||||
{ testFilePath: '/routes/permissions/cache-purge.test.ts' },
|
||||
|
||||
458
tests/blackbox/websocket/auth.test.ts
Normal file
458
tests/blackbox/websocket/auth.test.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
import config, { getUrl, paths } from '@common/config';
|
||||
import vendors from '@common/get-dbs-to-test';
|
||||
import * as common from '@common/index';
|
||||
import request from 'supertest';
|
||||
import { awaitDirectusConnection } from '@utils/await-connection';
|
||||
import { sleep } from '@utils/sleep';
|
||||
import { ChildProcess, spawn } from 'child_process';
|
||||
import knex, { Knex } from 'knex';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
describe('WebSocket Auth Tests', () => {
|
||||
const authMethods: common.WebSocketAuthMethod[] = ['public', 'handshake', 'strict'];
|
||||
const authenticationTimeoutSeconds = 1;
|
||||
const slightDelay = 100;
|
||||
const pathREST = 'wsRest';
|
||||
|
||||
describe.each(authMethods)('Authentication type: %s', (authMethod) => {
|
||||
const databases = new Map<string, Knex>();
|
||||
const directusInstances = {} as { [vendor: string]: ChildProcess };
|
||||
const env = cloneDeep(config.envs);
|
||||
|
||||
for (const vendor of vendors) {
|
||||
env[vendor].WEBSOCKETS_REST_AUTH = authMethod;
|
||||
env[vendor].WEBSOCKETS_REST_AUTH_TIMEOUT = String(authenticationTimeoutSeconds);
|
||||
env[vendor].WEBSOCKETS_REST_PATH = `/${pathREST}`;
|
||||
env[vendor].PORT = String(Number(env[vendor]!.PORT) + 500);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const promises = [];
|
||||
|
||||
for (const vendor of vendors) {
|
||||
databases.set(vendor, knex(config.knexConfig[vendor]!));
|
||||
|
||||
const server = spawn('node', [paths.cli, 'start'], { cwd: paths.cwd, env: env[vendor] });
|
||||
directusInstances[vendor] = server;
|
||||
|
||||
promises.push(awaitDirectusConnection(Number(env[vendor].PORT)));
|
||||
}
|
||||
|
||||
// Give the server some time to start
|
||||
await Promise.all(promises);
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
for (const [vendor, connection] of databases) {
|
||||
directusInstances[vendor]!.kill();
|
||||
|
||||
await connection.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
describe('connects without authentication', () => {
|
||||
common.TEST_USERS.forEach((userKey) => {
|
||||
describe(common.USER[userKey].NAME, () => {
|
||||
it.each(vendors)('%s', async (vendor) => {
|
||||
// Action
|
||||
const ws = common.createWebSocketConn(getUrl(vendor, env), {
|
||||
path: pathREST,
|
||||
});
|
||||
|
||||
let error;
|
||||
|
||||
try {
|
||||
switch (authMethod) {
|
||||
case 'public':
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
await sleep(authenticationTimeoutSeconds * 1000 + slightDelay);
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
break;
|
||||
case 'handshake':
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
await sleep(authenticationTimeoutSeconds * 1000 + slightDelay);
|
||||
await ws.waitForState(ws.conn.CLOSED);
|
||||
break;
|
||||
case 'strict':
|
||||
await ws.waitForState(ws.conn.CLOSED);
|
||||
await sleep(authenticationTimeoutSeconds * 1000);
|
||||
await ws.waitForState(ws.conn.CLOSED);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
ws.conn.close();
|
||||
|
||||
// Assert
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('connects with email authentication', () => {
|
||||
common.TEST_USERS.forEach((userKey) => {
|
||||
describe(common.USER[userKey].NAME, () => {
|
||||
it.each(vendors)('%s', async (vendor) => {
|
||||
// Action
|
||||
const ws = common.createWebSocketConn(getUrl(vendor, env), {
|
||||
path: pathREST,
|
||||
auth: { email: common.USER[userKey].EMAIL, password: common.USER[userKey].PASSWORD },
|
||||
});
|
||||
|
||||
let error;
|
||||
|
||||
try {
|
||||
switch (authMethod) {
|
||||
case 'public':
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
await sleep(authenticationTimeoutSeconds * 1000 + slightDelay);
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
break;
|
||||
case 'handshake':
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
await sleep(authenticationTimeoutSeconds * 1000 + slightDelay);
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
break;
|
||||
case 'strict':
|
||||
await ws.waitForState(ws.conn.CLOSED);
|
||||
await sleep(authenticationTimeoutSeconds * 1000);
|
||||
await ws.waitForState(ws.conn.CLOSED);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
ws.conn.close();
|
||||
|
||||
// Assert
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('connects with access token authentication', () => {
|
||||
common.TEST_USERS.forEach((userKey) => {
|
||||
describe(common.USER[userKey].NAME, () => {
|
||||
it.each(vendors)('%s', async (vendor) => {
|
||||
// Setup
|
||||
const { access_token } = (
|
||||
await request(getUrl(vendor))
|
||||
.post('/auth/login')
|
||||
.send({ email: common.USER[userKey].EMAIL, password: common.USER[userKey].PASSWORD })
|
||||
).body.data;
|
||||
|
||||
// Action
|
||||
const ws = common.createWebSocketConn(getUrl(vendor, env), {
|
||||
path: pathREST,
|
||||
auth: { access_token },
|
||||
});
|
||||
|
||||
let error;
|
||||
|
||||
try {
|
||||
switch (authMethod) {
|
||||
case 'public':
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
await sleep(authenticationTimeoutSeconds * 1000 + slightDelay);
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
break;
|
||||
case 'handshake':
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
await sleep(authenticationTimeoutSeconds * 1000 + slightDelay);
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
break;
|
||||
case 'strict':
|
||||
await ws.waitForState(ws.conn.CLOSED);
|
||||
await sleep(authenticationTimeoutSeconds * 1000);
|
||||
await ws.waitForState(ws.conn.CLOSED);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
ws.conn.close();
|
||||
|
||||
// Assert
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('connects with static access token authentication', () => {
|
||||
common.TEST_USERS.forEach((userKey) => {
|
||||
describe(common.USER[userKey].NAME, () => {
|
||||
it.each(vendors)('%s', async (vendor) => {
|
||||
// Action
|
||||
const ws = common.createWebSocketConn(getUrl(vendor, env), {
|
||||
path: pathREST,
|
||||
auth: { access_token: common.USER[userKey].TOKEN },
|
||||
});
|
||||
|
||||
let error;
|
||||
|
||||
try {
|
||||
switch (authMethod) {
|
||||
case 'public':
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
await sleep(authenticationTimeoutSeconds * 1000 + slightDelay);
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
break;
|
||||
case 'handshake':
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
await sleep(authenticationTimeoutSeconds * 1000 + slightDelay);
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
break;
|
||||
case 'strict':
|
||||
await ws.waitForState(ws.conn.CLOSED);
|
||||
await sleep(authenticationTimeoutSeconds * 1000);
|
||||
await ws.waitForState(ws.conn.CLOSED);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
ws.conn.close();
|
||||
|
||||
// Assert
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('connects with access token in query string', () => {
|
||||
common.TEST_USERS.forEach((userKey) => {
|
||||
describe(common.USER[userKey].NAME, () => {
|
||||
it.each(vendors)('%s', async (vendor) => {
|
||||
// Setup
|
||||
const { access_token } = (
|
||||
await request(getUrl(vendor))
|
||||
.post('/auth/login')
|
||||
.send({ email: common.USER[userKey].EMAIL, password: common.USER[userKey].PASSWORD })
|
||||
).body.data;
|
||||
|
||||
// Action
|
||||
const ws = common.createWebSocketConn(getUrl(vendor, env), {
|
||||
path: pathREST,
|
||||
queryString: `access_token=${access_token}`,
|
||||
});
|
||||
|
||||
let error;
|
||||
|
||||
try {
|
||||
switch (authMethod) {
|
||||
case 'public':
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
await sleep(authenticationTimeoutSeconds * 1000 + slightDelay);
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
break;
|
||||
case 'handshake':
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
await sleep(authenticationTimeoutSeconds * 1000 + slightDelay);
|
||||
await ws.waitForState(ws.conn.CLOSED);
|
||||
break;
|
||||
case 'strict':
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
await sleep(authenticationTimeoutSeconds * 1000 + slightDelay);
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
ws.conn.close();
|
||||
|
||||
// Assert
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('connects with static access token in query string', () => {
|
||||
common.TEST_USERS.forEach((userKey) => {
|
||||
describe(common.USER[userKey].NAME, () => {
|
||||
it.each(vendors)('%s', async (vendor) => {
|
||||
// Action
|
||||
const ws = common.createWebSocketConn(getUrl(vendor, env), {
|
||||
path: pathREST,
|
||||
queryString: `access_token=${common.USER[userKey].TOKEN}`,
|
||||
});
|
||||
|
||||
let error;
|
||||
|
||||
try {
|
||||
switch (authMethod) {
|
||||
case 'public':
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
await sleep(authenticationTimeoutSeconds * 1000 + slightDelay);
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
break;
|
||||
case 'handshake':
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
await sleep(authenticationTimeoutSeconds * 1000 + slightDelay);
|
||||
await ws.waitForState(ws.conn.CLOSED);
|
||||
break;
|
||||
case 'strict':
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
await sleep(authenticationTimeoutSeconds * 1000 + slightDelay);
|
||||
await ws.waitForState(ws.conn.OPEN);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
ws.conn.close();
|
||||
|
||||
// Assert
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('pings without authentication', () => {
|
||||
common.TEST_USERS.forEach((userKey) => {
|
||||
describe(common.USER[userKey].NAME, () => {
|
||||
it.each(vendors)('%s', async (vendor) => {
|
||||
// Action
|
||||
const ws = common.createWebSocketConn(getUrl(vendor, env), {
|
||||
path: pathREST,
|
||||
respondToPing: false,
|
||||
});
|
||||
|
||||
let wsMessages: common.WebSocketResponse[] | undefined;
|
||||
let error;
|
||||
|
||||
try {
|
||||
await ws.sendMessage({ type: 'ping' });
|
||||
wsMessages = await ws.getMessages(1);
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
ws.conn.close();
|
||||
|
||||
// Assert
|
||||
switch (authMethod) {
|
||||
case 'public':
|
||||
expect(wsMessages?.length).toBe(1);
|
||||
|
||||
expect(wsMessages![0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'pong',
|
||||
})
|
||||
);
|
||||
|
||||
break;
|
||||
case 'handshake':
|
||||
case 'strict':
|
||||
expect(error).toBeDefined();
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('pings with access token authentication', () => {
|
||||
common.TEST_USERS.forEach((userKey) => {
|
||||
describe(common.USER[userKey].NAME, () => {
|
||||
it.each(vendors)('%s', async (vendor) => {
|
||||
// Action
|
||||
const ws = common.createWebSocketConn(getUrl(vendor, env), {
|
||||
path: pathREST,
|
||||
auth: { access_token: common.USER[userKey].TOKEN },
|
||||
respondToPing: false,
|
||||
});
|
||||
|
||||
let wsMessages: common.WebSocketResponse[] | undefined;
|
||||
let error;
|
||||
|
||||
try {
|
||||
await ws.sendMessage({ type: 'ping' });
|
||||
wsMessages = await ws.getMessages(1);
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
ws.conn.close();
|
||||
|
||||
// Assert
|
||||
switch (authMethod) {
|
||||
case 'public':
|
||||
case 'handshake':
|
||||
expect(wsMessages?.length).toBe(1);
|
||||
|
||||
expect(wsMessages![0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'pong',
|
||||
})
|
||||
);
|
||||
|
||||
break;
|
||||
case 'strict':
|
||||
expect(error).toBeDefined();
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('pings with access token in query string', () => {
|
||||
common.TEST_USERS.forEach((userKey) => {
|
||||
describe(common.USER[userKey].NAME, () => {
|
||||
it.each(vendors)('%s', async (vendor) => {
|
||||
// Action
|
||||
const ws = common.createWebSocketConn(getUrl(vendor, env), {
|
||||
path: pathREST,
|
||||
queryString: `access_token=${common.USER[userKey].TOKEN}`,
|
||||
respondToPing: false,
|
||||
});
|
||||
|
||||
let wsMessages: common.WebSocketResponse[] | undefined;
|
||||
let error;
|
||||
|
||||
try {
|
||||
await ws.sendMessage({ type: 'ping' });
|
||||
wsMessages = await ws.getMessages(1);
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
ws.conn.close();
|
||||
|
||||
// Assert
|
||||
switch (authMethod) {
|
||||
case 'public':
|
||||
case 'strict':
|
||||
expect(wsMessages?.length).toBe(1);
|
||||
|
||||
expect(wsMessages![0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'pong',
|
||||
})
|
||||
);
|
||||
|
||||
break;
|
||||
case 'handshake':
|
||||
expect(error).toBeDefined();
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
40
tests/blackbox/websocket/general.seed.ts
Normal file
40
tests/blackbox/websocket/general.seed.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import vendors from '@common/get-dbs-to-test';
|
||||
import { CreateCollection, CreateField, DeleteCollection, PRIMARY_KEY_TYPES } from '@common/index';
|
||||
|
||||
export const collectionFirst = 'test_ws_general_first';
|
||||
|
||||
export type First = {
|
||||
id?: number | string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export const seedDBStructure = () => {
|
||||
it.each(vendors)(
|
||||
'%s',
|
||||
async (vendor) => {
|
||||
for (const pkType of PRIMARY_KEY_TYPES) {
|
||||
try {
|
||||
const localCollectionFirst = `${collectionFirst}_${pkType}`;
|
||||
|
||||
await DeleteCollection(vendor, { collection: localCollectionFirst });
|
||||
|
||||
await CreateCollection(vendor, {
|
||||
collection: localCollectionFirst,
|
||||
primaryKeyType: pkType,
|
||||
});
|
||||
|
||||
await CreateField(vendor, {
|
||||
collection: localCollectionFirst,
|
||||
field: 'name',
|
||||
type: 'string',
|
||||
});
|
||||
|
||||
expect(true).toBeTruthy();
|
||||
} catch (error) {
|
||||
expect(error).toBeFalsy();
|
||||
}
|
||||
}
|
||||
},
|
||||
300000
|
||||
);
|
||||
};
|
||||
504
tests/blackbox/websocket/general.test.ts
Normal file
504
tests/blackbox/websocket/general.test.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
import config, { Env, getUrl, paths } from '@common/config';
|
||||
import vendors from '@common/get-dbs-to-test';
|
||||
import * as common from '@common/index';
|
||||
import request from 'supertest';
|
||||
import { awaitDirectusConnection } from '@utils/await-connection';
|
||||
import { ChildProcess, spawn } from 'child_process';
|
||||
import knex, { Knex } from 'knex';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { collectionFirst } from './general.seed';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { sleep } from '@utils/sleep';
|
||||
|
||||
describe('WebSocket General Tests', () => {
|
||||
const databases = new Map<string, Knex>();
|
||||
const directusInstances = {} as { [vendor: string]: ChildProcess[] };
|
||||
const envs = {} as { [vendor: string]: Env[] };
|
||||
|
||||
beforeAll(async () => {
|
||||
const promises = [];
|
||||
|
||||
for (const vendor of vendors) {
|
||||
databases.set(vendor, knex(config.knexConfig[vendor]!));
|
||||
|
||||
const env1 = cloneDeep(config.envs);
|
||||
env1[vendor].MESSENGER_STORE = 'redis';
|
||||
env1[vendor].MESSENGER_NAMESPACE = `directus-ws-${vendor}`;
|
||||
env1[vendor].MESSENGER_REDIS = `redis://localhost:6108/4`;
|
||||
|
||||
const env2 = cloneDeep(env1);
|
||||
|
||||
const newServerPort1 = Number(env1[vendor]!.PORT) + 250;
|
||||
const newServerPort2 = Number(env2[vendor]!.PORT) + 300;
|
||||
|
||||
env1[vendor]!.PORT = String(newServerPort1);
|
||||
env2[vendor]!.PORT = String(newServerPort2);
|
||||
|
||||
const server1 = spawn('node', [paths.cli, 'start'], { cwd: paths.cwd, env: env1[vendor] });
|
||||
const server2 = spawn('node', [paths.cli, 'start'], { cwd: paths.cwd, env: env2[vendor] });
|
||||
|
||||
directusInstances[vendor] = [server1, server2];
|
||||
envs[vendor] = [env1, env2];
|
||||
|
||||
promises.push(awaitDirectusConnection(newServerPort1));
|
||||
promises.push(awaitDirectusConnection(newServerPort2));
|
||||
}
|
||||
|
||||
// Give the server some time to start
|
||||
await Promise.all(promises);
|
||||
}, 180000);
|
||||
|
||||
afterAll(async () => {
|
||||
for (const [vendor, connection] of databases) {
|
||||
for (const instance of directusInstances[vendor]!) {
|
||||
instance.kill();
|
||||
}
|
||||
|
||||
await connection.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
describe.each(common.PRIMARY_KEY_TYPES)('Primary key type: %s', (pkType) => {
|
||||
const localCollectionFirst = `${collectionFirst}_${pkType}`;
|
||||
|
||||
describe('Test subscriptions', () => {
|
||||
it.each(vendors)(
|
||||
'%s',
|
||||
async (vendor) => {
|
||||
// Setup
|
||||
const uids = [undefined, 1, 'two'];
|
||||
const env1 = envs[vendor][0];
|
||||
const env2 = envs[vendor][1];
|
||||
|
||||
const ws = common.createWebSocketConn(getUrl(vendor, env1), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
const ws2 = common.createWebSocketConn(getUrl(vendor, env2), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
const wsGql = common.createWebSocketGql(getUrl(vendor, env1), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
const wsGql2 = common.createWebSocketGql(getUrl(vendor, env2), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
const messageList = [];
|
||||
const messageList2 = [];
|
||||
const messageListGql = [];
|
||||
const messageListGql2 = [];
|
||||
let subscriptionKey = '';
|
||||
|
||||
// Action
|
||||
for (const uid of uids) {
|
||||
await ws.subscribe({ collection: localCollectionFirst, uid });
|
||||
await ws2.subscribe({ collection: localCollectionFirst, uid });
|
||||
|
||||
subscriptionKey = await wsGql.subscribe({
|
||||
collection: localCollectionFirst,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
uid,
|
||||
});
|
||||
|
||||
await wsGql2.subscribe({
|
||||
collection: localCollectionFirst,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
uid,
|
||||
});
|
||||
}
|
||||
|
||||
const insertedName = uuid();
|
||||
|
||||
const insertedId = (
|
||||
await request(getUrl(vendor, env1))
|
||||
.post(`/items/${localCollectionFirst}`)
|
||||
.send({ id: pkType === 'string' ? uuid() : undefined, name: insertedName })
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`)
|
||||
).body.data.id;
|
||||
|
||||
for (const uid of uids) {
|
||||
messageList.push(await ws.getMessages(1, { uid }));
|
||||
messageList2.push(await ws2.getMessages(1, { uid }));
|
||||
messageListGql.push(await wsGql.getMessages(1, { uid }));
|
||||
messageListGql2.push(await wsGql2.getMessages(1, { uid }));
|
||||
}
|
||||
|
||||
ws.conn.close();
|
||||
ws2.conn.close();
|
||||
await wsGql.client.dispose();
|
||||
await wsGql2.client.dispose();
|
||||
|
||||
// Assert
|
||||
for (let i = 0; i < messageList.length; i++) {
|
||||
const wsMessages = messageList[i];
|
||||
expect(wsMessages?.length).toBe(1);
|
||||
|
||||
expect(wsMessages![0]).toEqual({
|
||||
type: 'subscription',
|
||||
event: 'create',
|
||||
data: [{ id: insertedId, name: insertedName }],
|
||||
uid: uids[i] === undefined ? undefined : String(uids[i]),
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < messageListGql.length; i++) {
|
||||
const wsMessages = messageListGql[i];
|
||||
expect(wsMessages?.length).toBe(1);
|
||||
|
||||
expect(wsMessages![0]).toEqual({
|
||||
data: {
|
||||
[subscriptionKey]: {
|
||||
event: 'create',
|
||||
data: {
|
||||
id: String(insertedId),
|
||||
name: insertedName,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
expect(messageList).toEqual(messageList2);
|
||||
expect(messageListGql).toEqual(messageListGql2);
|
||||
},
|
||||
100000
|
||||
);
|
||||
});
|
||||
|
||||
describe('Test unsubscriptions', () => {
|
||||
it.each(vendors)(
|
||||
'%s',
|
||||
async (vendor) => {
|
||||
// Setup
|
||||
const uids = [undefined, 1, 'two'];
|
||||
const env1 = envs[vendor][0];
|
||||
const env2 = envs[vendor][1];
|
||||
|
||||
const ws = common.createWebSocketConn(getUrl(vendor, env1), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
const ws2 = common.createWebSocketConn(getUrl(vendor, env2), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
const wsGql = common.createWebSocketGql(getUrl(vendor, env1), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
const wsGql2 = common.createWebSocketGql(getUrl(vendor, env2), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
// Action
|
||||
for (const uid of uids) {
|
||||
await ws.subscribe({ collection: localCollectionFirst, uid });
|
||||
await ws2.subscribe({ collection: localCollectionFirst, uid });
|
||||
|
||||
await wsGql.subscribe({
|
||||
collection: localCollectionFirst,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
uid,
|
||||
});
|
||||
|
||||
await wsGql2.subscribe({
|
||||
collection: localCollectionFirst,
|
||||
jsonQuery: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
uid,
|
||||
});
|
||||
|
||||
await ws.unsubscribe(uid);
|
||||
await ws2.unsubscribe(uid);
|
||||
wsGql.unsubscribe(uid);
|
||||
wsGql2.unsubscribe(uid);
|
||||
}
|
||||
|
||||
await request(getUrl(vendor, env1))
|
||||
.post(`/items/${localCollectionFirst}`)
|
||||
.send({ id: pkType === 'string' ? uuid() : undefined, name: uuid() })
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
|
||||
ws.conn.close();
|
||||
ws2.conn.close();
|
||||
await wsGql.client.dispose();
|
||||
await wsGql2.client.dispose();
|
||||
|
||||
// Assert
|
||||
for (const uid of uids) {
|
||||
expect(ws.getMessageCount(uid)).toBe(2);
|
||||
expect(ws2.getMessageCount(uid)).toBe(2);
|
||||
expect(wsGql.getMessageCount(uid)).toBe(0);
|
||||
expect(wsGql2.getMessageCount(uid)).toBe(0);
|
||||
}
|
||||
},
|
||||
100000
|
||||
);
|
||||
});
|
||||
|
||||
describe('Test event filtering', () => {
|
||||
it.each(vendors)(
|
||||
'%s',
|
||||
async (vendor) => {
|
||||
// Setup
|
||||
const eventUids = [undefined, 'create', 'update', 'delete'];
|
||||
const env = envs[vendor][0];
|
||||
|
||||
const ws = common.createWebSocketConn(getUrl(vendor, env), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
const wsGql = common.createWebSocketGql(getUrl(vendor, env), {
|
||||
auth: { access_token: common.USER.ADMIN.TOKEN },
|
||||
});
|
||||
|
||||
let messageList: common.WebSocketResponse[] = [];
|
||||
const messageListFiltered: Record<string, any> = {};
|
||||
let messageListGql: common.WebSocketResponse[] = [];
|
||||
const messageListGqlFiltered: Record<string, any> = {};
|
||||
|
||||
let subscriptionKey = '';
|
||||
|
||||
// Action
|
||||
for (const uid of eventUids) {
|
||||
await ws.subscribe({
|
||||
collection: localCollectionFirst,
|
||||
uid,
|
||||
event: uid as common.WebSocketSubscriptionOptions['event'],
|
||||
});
|
||||
|
||||
const gqlQuery =
|
||||
uid === 'delete'
|
||||
? {
|
||||
event: true,
|
||||
key: true,
|
||||
}
|
||||
: {
|
||||
event: true,
|
||||
data: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
};
|
||||
|
||||
subscriptionKey = await wsGql.subscribe({
|
||||
collection: localCollectionFirst,
|
||||
jsonQuery: gqlQuery,
|
||||
uid,
|
||||
event: uid as common.WebSocketSubscriptionOptions['event'],
|
||||
});
|
||||
}
|
||||
|
||||
const insertedName = uuid();
|
||||
const updatedName = `updated_${uuid()}`;
|
||||
|
||||
const insertedId = (
|
||||
await request(getUrl(vendor, env))
|
||||
.post(`/items/${localCollectionFirst}`)
|
||||
.send({ id: pkType === 'string' ? uuid() : undefined, name: insertedName })
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`)
|
||||
).body.data.id;
|
||||
|
||||
await sleep(100);
|
||||
|
||||
await request(getUrl(vendor, env))
|
||||
.patch(`/items/${localCollectionFirst}/${insertedId}`)
|
||||
.send({ name: updatedName })
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
|
||||
await sleep(100);
|
||||
|
||||
await request(getUrl(vendor, env))
|
||||
.delete(`/items/${localCollectionFirst}/${insertedId}`)
|
||||
.set('Authorization', `Bearer ${common.USER.ADMIN.TOKEN}`);
|
||||
|
||||
await sleep(100);
|
||||
|
||||
for (const uid of eventUids) {
|
||||
if (uid === undefined) {
|
||||
messageList = (await ws.getMessages(3)) || [];
|
||||
messageListGql = (await wsGql.getMessages(3)) || [];
|
||||
} else {
|
||||
messageListFiltered[uid] = await ws.getMessages(1, { uid });
|
||||
messageListGqlFiltered[uid] = await wsGql.getMessages(1, { uid });
|
||||
}
|
||||
}
|
||||
|
||||
ws.conn.close();
|
||||
await wsGql.client.dispose();
|
||||
|
||||
// Assert
|
||||
expect(messageList).toHaveLength(3);
|
||||
|
||||
expect(messageList[0]).toEqual({
|
||||
type: 'subscription',
|
||||
event: 'create',
|
||||
data: [{ id: insertedId, name: insertedName }],
|
||||
});
|
||||
|
||||
expect(messageList[1]).toEqual({
|
||||
type: 'subscription',
|
||||
event: 'update',
|
||||
data: [{ id: insertedId, name: updatedName }],
|
||||
});
|
||||
|
||||
expect(messageList[2]).toEqual({
|
||||
type: 'subscription',
|
||||
event: 'delete',
|
||||
data: [String(insertedId)],
|
||||
});
|
||||
|
||||
for (const uid of eventUids) {
|
||||
if (!uid) continue;
|
||||
|
||||
expect(messageListFiltered[uid]).toHaveLength(1);
|
||||
|
||||
switch (uid) {
|
||||
case 'create':
|
||||
expect(messageListFiltered[uid][0]).toEqual({
|
||||
type: 'subscription',
|
||||
event: uid,
|
||||
data: [{ id: insertedId, name: insertedName }],
|
||||
uid,
|
||||
});
|
||||
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
expect(messageListFiltered[uid][0]).toEqual({
|
||||
type: 'subscription',
|
||||
event: uid,
|
||||
data: [{ id: insertedId, name: updatedName }],
|
||||
uid,
|
||||
});
|
||||
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
expect(messageListFiltered[uid][0]).toEqual({
|
||||
type: 'subscription',
|
||||
event: uid,
|
||||
data: [String(insertedId)],
|
||||
uid,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(messageListGql).toHaveLength(3);
|
||||
|
||||
expect(messageListGql[0]).toEqual({
|
||||
data: {
|
||||
[subscriptionKey]: {
|
||||
event: 'create',
|
||||
data: {
|
||||
id: String(insertedId),
|
||||
name: insertedName,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(messageListGql[1]).toEqual({
|
||||
data: {
|
||||
[subscriptionKey]: {
|
||||
event: 'update',
|
||||
data: {
|
||||
id: String(insertedId),
|
||||
name: updatedName,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(messageListGql[2]).toEqual({
|
||||
data: {
|
||||
[subscriptionKey]: {
|
||||
event: 'delete',
|
||||
data: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const uid of eventUids) {
|
||||
if (!uid) continue;
|
||||
|
||||
expect(messageListGqlFiltered[uid]).toHaveLength(1);
|
||||
|
||||
switch (uid) {
|
||||
case 'create':
|
||||
expect(messageListGqlFiltered[uid][0]).toEqual({
|
||||
data: {
|
||||
[subscriptionKey]: {
|
||||
event: 'create',
|
||||
data: {
|
||||
id: String(insertedId),
|
||||
name: insertedName,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
expect(messageListGqlFiltered[uid][0]).toEqual({
|
||||
data: {
|
||||
[subscriptionKey]: {
|
||||
event: 'update',
|
||||
data: {
|
||||
id: String(insertedId),
|
||||
name: updatedName,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
expect(messageListGqlFiltered[uid][0]).toEqual({
|
||||
data: {
|
||||
[subscriptionKey]: {
|
||||
event: 'delete',
|
||||
key: String(insertedId),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
100000
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user