@directus/api 11.0.1 → 11.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/emitter.d.ts +3 -2
- package/dist/emitter.js +12 -4
- package/dist/env.js +15 -4
- package/dist/messenger.d.ts +3 -3
- package/dist/messenger.js +14 -5
- package/dist/middleware/authenticate.js +2 -38
- package/dist/server.js +10 -0
- package/dist/services/graphql/index.d.ts +0 -6
- package/dist/services/graphql/index.js +98 -57
- package/dist/services/graphql/subscription.d.ts +16 -0
- package/dist/services/graphql/subscription.js +77 -0
- package/dist/services/server.js +24 -0
- package/dist/services/websocket.d.ts +14 -0
- package/dist/services/websocket.js +26 -0
- package/dist/utils/apply-diff.js +11 -2
- package/dist/utils/apply-query.js +5 -6
- package/dist/utils/get-accountability-for-token.d.ts +2 -0
- package/dist/utils/get-accountability-for-token.js +50 -0
- package/dist/utils/get-service.d.ts +7 -0
- package/dist/utils/get-service.js +49 -0
- package/dist/utils/redact.d.ts +4 -0
- package/dist/utils/redact.js +15 -1
- package/dist/utils/to-boolean.d.ts +4 -0
- package/dist/utils/to-boolean.js +6 -0
- package/dist/websocket/authenticate.d.ts +6 -0
- package/dist/websocket/authenticate.js +62 -0
- package/dist/websocket/controllers/base.d.ts +42 -0
- package/dist/websocket/controllers/base.js +276 -0
- package/dist/websocket/controllers/graphql.d.ts +12 -0
- package/dist/websocket/controllers/graphql.js +102 -0
- package/dist/websocket/controllers/hooks.d.ts +1 -0
- package/dist/websocket/controllers/hooks.js +122 -0
- package/dist/websocket/controllers/index.d.ts +10 -0
- package/dist/websocket/controllers/index.js +35 -0
- package/dist/websocket/controllers/rest.d.ts +9 -0
- package/dist/websocket/controllers/rest.js +47 -0
- package/dist/websocket/exceptions.d.ts +16 -0
- package/dist/websocket/exceptions.js +55 -0
- package/dist/websocket/handlers/heartbeat.d.ts +11 -0
- package/dist/websocket/handlers/heartbeat.js +72 -0
- package/dist/websocket/handlers/index.d.ts +4 -0
- package/dist/websocket/handlers/index.js +11 -0
- package/dist/websocket/handlers/items.d.ts +6 -0
- package/dist/websocket/handlers/items.js +103 -0
- package/dist/websocket/handlers/subscribe.d.ts +43 -0
- package/dist/websocket/handlers/subscribe.js +278 -0
- package/dist/websocket/messages.d.ts +311 -0
- package/dist/websocket/messages.js +96 -0
- package/dist/websocket/types.d.ts +34 -0
- package/dist/websocket/types.js +1 -0
- package/dist/websocket/utils/get-expires-at-for-token.d.ts +1 -0
- package/dist/websocket/utils/get-expires-at-for-token.js +8 -0
- package/dist/websocket/utils/message.d.ts +4 -0
- package/dist/websocket/utils/message.js +27 -0
- package/dist/websocket/utils/wait-for-message.d.ts +4 -0
- package/dist/websocket/utils/wait-for-message.js +45 -0
- package/package.json +19 -14
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { CloseCode, MessageType, makeServer } from 'graphql-ws';
|
|
2
|
+
import env from '../../env.js';
|
|
3
|
+
import logger from '../../logger.js';
|
|
4
|
+
import { bindPubSub } from '../../services/graphql/subscription.js';
|
|
5
|
+
import { GraphQLService } from '../../services/index.js';
|
|
6
|
+
import { getSchema } from '../../utils/get-schema.js';
|
|
7
|
+
import { authenticateConnection, refreshAccountability } from '../authenticate.js';
|
|
8
|
+
import { handleWebSocketException } from '../exceptions.js';
|
|
9
|
+
import { ConnectionParams, WebSocketMessage } from '../messages.js';
|
|
10
|
+
import { getMessageType } from '../utils/message.js';
|
|
11
|
+
import SocketController from './base.js';
|
|
12
|
+
export class GraphQLSubscriptionController extends SocketController {
|
|
13
|
+
gql;
|
|
14
|
+
constructor(httpServer) {
|
|
15
|
+
super(httpServer, 'WEBSOCKETS_GRAPHQL');
|
|
16
|
+
this.server.on('connection', (ws, auth) => {
|
|
17
|
+
this.bindEvents(this.createClient(ws, auth));
|
|
18
|
+
});
|
|
19
|
+
this.gql = makeServer({
|
|
20
|
+
schema: async (ctx) => {
|
|
21
|
+
const accountability = ctx.extra.client.accountability;
|
|
22
|
+
// for now only the items will be watched, system events tbd
|
|
23
|
+
const service = new GraphQLService({
|
|
24
|
+
schema: await getSchema(),
|
|
25
|
+
scope: 'items',
|
|
26
|
+
accountability,
|
|
27
|
+
});
|
|
28
|
+
return service.getSchema();
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
bindPubSub();
|
|
32
|
+
logger.info(`GraphQL Subscriptions started at ws://${env['HOST']}:${env['PORT']}${this.endpoint}`);
|
|
33
|
+
}
|
|
34
|
+
bindEvents(client) {
|
|
35
|
+
const closedHandler = this.gql.opened({
|
|
36
|
+
protocol: client.protocol,
|
|
37
|
+
send: (data) => new Promise((resolve, reject) => {
|
|
38
|
+
client.send(data, (err) => (err ? reject(err) : resolve()));
|
|
39
|
+
}),
|
|
40
|
+
close: (code, reason) => client.close(code, reason),
|
|
41
|
+
onMessage: (cb) => {
|
|
42
|
+
client.on('parsed-message', async (message) => {
|
|
43
|
+
try {
|
|
44
|
+
if (getMessageType(message) === 'connection_init' && this.authentication.mode !== 'strict') {
|
|
45
|
+
const params = ConnectionParams.parse(message['payload'] ?? {});
|
|
46
|
+
if (this.authentication.mode === 'handshake') {
|
|
47
|
+
if (typeof params.access_token === 'string') {
|
|
48
|
+
const { accountability, expires_at } = await authenticateConnection({
|
|
49
|
+
access_token: params.access_token,
|
|
50
|
+
});
|
|
51
|
+
client.accountability = accountability;
|
|
52
|
+
client.expires_at = expires_at;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
client.close(CloseCode.Forbidden, 'Forbidden');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else if (this.authentication.mode === 'handshake' && !client.accountability?.user) {
|
|
61
|
+
// the first message should authenticate successfully in this mode
|
|
62
|
+
client.close(CloseCode.Forbidden, 'Forbidden');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
client.accountability = await refreshAccountability(client.accountability);
|
|
67
|
+
}
|
|
68
|
+
await cb(JSON.stringify(message));
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
handleWebSocketException(client, error, MessageType.Error);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
}, { client });
|
|
76
|
+
// notify server that the socket closed
|
|
77
|
+
client.once('close', (code, reason) => closedHandler(code, reason.toString()));
|
|
78
|
+
// check strict authentication status
|
|
79
|
+
if (this.authentication.mode === 'strict' && !client.accountability?.user) {
|
|
80
|
+
client.close(CloseCode.Forbidden, 'Forbidden');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
setTokenExpireTimer(client) {
|
|
84
|
+
if (client.auth_timer !== null) {
|
|
85
|
+
clearTimeout(client.auth_timer);
|
|
86
|
+
client.auth_timer = null;
|
|
87
|
+
}
|
|
88
|
+
if (this.authentication.mode !== 'handshake')
|
|
89
|
+
return;
|
|
90
|
+
client.auth_timer = setTimeout(() => {
|
|
91
|
+
if (!client.accountability?.user) {
|
|
92
|
+
client.close(CloseCode.Forbidden, 'Forbidden');
|
|
93
|
+
}
|
|
94
|
+
}, this.authentication.timeout);
|
|
95
|
+
}
|
|
96
|
+
async handleHandshakeUpgrade({ request, socket, head }) {
|
|
97
|
+
this.server.handleUpgrade(request, socket, head, async (ws) => {
|
|
98
|
+
this.server.emit('connection', ws, { accountability: null, expires_at: null });
|
|
99
|
+
// actual enforcement is handled by the setTokenExpireTimer function
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function registerWebSocketEvents(): void;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import emitter from '../../emitter.js';
|
|
2
|
+
import { getMessenger } from '../../messenger.js';
|
|
3
|
+
let actionsRegistered = false;
|
|
4
|
+
export function registerWebSocketEvents() {
|
|
5
|
+
if (actionsRegistered)
|
|
6
|
+
return;
|
|
7
|
+
actionsRegistered = true;
|
|
8
|
+
registerActionHooks([
|
|
9
|
+
'items',
|
|
10
|
+
'activity',
|
|
11
|
+
'collections',
|
|
12
|
+
'folders',
|
|
13
|
+
'permissions',
|
|
14
|
+
'presets',
|
|
15
|
+
'revisions',
|
|
16
|
+
'roles',
|
|
17
|
+
'settings',
|
|
18
|
+
'users',
|
|
19
|
+
'webhooks',
|
|
20
|
+
]);
|
|
21
|
+
registerFieldsHooks();
|
|
22
|
+
registerFilesHooks();
|
|
23
|
+
registerRelationsHooks();
|
|
24
|
+
}
|
|
25
|
+
function registerActionHooks(modules) {
|
|
26
|
+
// register event hooks that can be handled in an uniform manner
|
|
27
|
+
for (const module of modules) {
|
|
28
|
+
registerAction(module + '.create', ({ key, collection, payload = {} }) => ({
|
|
29
|
+
collection,
|
|
30
|
+
action: 'create',
|
|
31
|
+
key,
|
|
32
|
+
payload,
|
|
33
|
+
}));
|
|
34
|
+
registerAction(module + '.update', ({ keys, collection, payload = {} }) => ({
|
|
35
|
+
collection,
|
|
36
|
+
action: 'update',
|
|
37
|
+
keys,
|
|
38
|
+
payload,
|
|
39
|
+
}));
|
|
40
|
+
registerAction(module + '.delete', ({ keys, collection, payload = [] }) => ({
|
|
41
|
+
collection,
|
|
42
|
+
action: 'delete',
|
|
43
|
+
keys,
|
|
44
|
+
payload,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function registerFieldsHooks() {
|
|
49
|
+
// exception for field hooks that don't report `directus_fields` as being the collection
|
|
50
|
+
registerAction('fields.create', ({ key, payload = {} }) => ({
|
|
51
|
+
collection: 'directus_fields',
|
|
52
|
+
action: 'create',
|
|
53
|
+
key,
|
|
54
|
+
payload,
|
|
55
|
+
}));
|
|
56
|
+
registerAction('fields.update', ({ keys, payload = {} }) => ({
|
|
57
|
+
collection: 'directus_fields',
|
|
58
|
+
action: 'update',
|
|
59
|
+
keys,
|
|
60
|
+
payload,
|
|
61
|
+
}));
|
|
62
|
+
registerAction('fields.delete', ({ keys, payload = [] }) => ({
|
|
63
|
+
collection: 'directus_fields',
|
|
64
|
+
action: 'delete',
|
|
65
|
+
keys,
|
|
66
|
+
payload,
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
function registerFilesHooks() {
|
|
70
|
+
// extra event for file uploads that doubles as create event
|
|
71
|
+
registerAction('files.upload', ({ key, collection, payload = {} }) => ({
|
|
72
|
+
collection,
|
|
73
|
+
action: 'create',
|
|
74
|
+
key,
|
|
75
|
+
payload,
|
|
76
|
+
}));
|
|
77
|
+
registerAction('files.update', ({ keys, collection, payload = {} }) => ({
|
|
78
|
+
collection,
|
|
79
|
+
action: 'update',
|
|
80
|
+
keys,
|
|
81
|
+
payload,
|
|
82
|
+
}));
|
|
83
|
+
registerAction('files.delete', ({ keys, collection, payload = [] }) => ({
|
|
84
|
+
collection,
|
|
85
|
+
action: 'delete',
|
|
86
|
+
keys,
|
|
87
|
+
payload,
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
function registerRelationsHooks() {
|
|
91
|
+
// exception for relation hooks that don't report `directus_relations` as being the collection
|
|
92
|
+
registerAction('relations.create', ({ key, payload = {} }) => ({
|
|
93
|
+
collection: 'directus_relations',
|
|
94
|
+
action: 'create',
|
|
95
|
+
key,
|
|
96
|
+
payload: { ...payload, key },
|
|
97
|
+
}));
|
|
98
|
+
registerAction('relations.update', ({ keys, payload = {} }) => ({
|
|
99
|
+
collection: 'directus_relations',
|
|
100
|
+
action: 'update',
|
|
101
|
+
keys,
|
|
102
|
+
payload,
|
|
103
|
+
}));
|
|
104
|
+
registerAction('relations.delete', ({ collection, payload = [] }) => ({
|
|
105
|
+
collection: 'directus_relations',
|
|
106
|
+
action: 'delete',
|
|
107
|
+
keys: payload,
|
|
108
|
+
payload: { collection, fields: payload },
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Wrapper for emitter.onAction to hook into system events
|
|
113
|
+
* @param event The action event to watch
|
|
114
|
+
* @param transform Transformer function
|
|
115
|
+
*/
|
|
116
|
+
function registerAction(event, transform) {
|
|
117
|
+
const messenger = getMessenger();
|
|
118
|
+
emitter.onAction(event, async (data) => {
|
|
119
|
+
// push the event through the Redis pub/sub
|
|
120
|
+
messenger.publish('websocket.event', transform(data));
|
|
121
|
+
});
|
|
122
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/// <reference types="node" resolution-mode="require"/>
|
|
2
|
+
import type { Server as httpServer } from 'http';
|
|
3
|
+
import { GraphQLSubscriptionController } from './graphql.js';
|
|
4
|
+
import { WebSocketController } from './rest.js';
|
|
5
|
+
export declare function createWebSocketController(server: httpServer): void;
|
|
6
|
+
export declare function getWebSocketController(): WebSocketController;
|
|
7
|
+
export declare function createSubscriptionController(server: httpServer): void;
|
|
8
|
+
export declare function getSubscriptionController(): GraphQLSubscriptionController | undefined;
|
|
9
|
+
export * from './graphql.js';
|
|
10
|
+
export * from './rest.js';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import env from '../../env.js';
|
|
2
|
+
import { ServiceUnavailableException } from '../../index.js';
|
|
3
|
+
import { toBoolean } from '../../utils/to-boolean.js';
|
|
4
|
+
import { GraphQLSubscriptionController } from './graphql.js';
|
|
5
|
+
import { WebSocketController } from './rest.js';
|
|
6
|
+
let websocketController;
|
|
7
|
+
let subscriptionController;
|
|
8
|
+
export function createWebSocketController(server) {
|
|
9
|
+
if (toBoolean(env['WEBSOCKETS_REST_ENABLED'])) {
|
|
10
|
+
websocketController = new WebSocketController(server);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function getWebSocketController() {
|
|
14
|
+
if (!toBoolean(env['WEBSOCKETS_ENABLED']) || !toBoolean(env['WEBSOCKETS_REST_ENABLED'])) {
|
|
15
|
+
throw new ServiceUnavailableException('WebSocket server is disabled', {
|
|
16
|
+
service: 'get-websocket-controller',
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
if (!websocketController) {
|
|
20
|
+
throw new ServiceUnavailableException('WebSocket server is not initialized', {
|
|
21
|
+
service: 'get-websocket-controller',
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return websocketController;
|
|
25
|
+
}
|
|
26
|
+
export function createSubscriptionController(server) {
|
|
27
|
+
if (toBoolean(env['WEBSOCKETS_GRAPHQL_ENABLED'])) {
|
|
28
|
+
subscriptionController = new GraphQLSubscriptionController(server);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function getSubscriptionController() {
|
|
32
|
+
return subscriptionController;
|
|
33
|
+
}
|
|
34
|
+
export * from './graphql.js';
|
|
35
|
+
export * from './rest.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/// <reference types="node" resolution-mode="require"/>
|
|
2
|
+
import type { Server as httpServer } from 'http';
|
|
3
|
+
import { WebSocketMessage } from '../messages.js';
|
|
4
|
+
import SocketController from './base.js';
|
|
5
|
+
export declare class WebSocketController extends SocketController {
|
|
6
|
+
constructor(httpServer: httpServer);
|
|
7
|
+
private bindEvents;
|
|
8
|
+
protected parseMessage(data: string): WebSocketMessage;
|
|
9
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { parseJSON } from '@directus/utils';
|
|
2
|
+
import emitter from '../../emitter.js';
|
|
3
|
+
import env from '../../env.js';
|
|
4
|
+
import logger from '../../logger.js';
|
|
5
|
+
import { refreshAccountability } from '../authenticate.js';
|
|
6
|
+
import { WebSocketException, handleWebSocketException } from '../exceptions.js';
|
|
7
|
+
import { WebSocketMessage } from '../messages.js';
|
|
8
|
+
import SocketController from './base.js';
|
|
9
|
+
export class WebSocketController extends SocketController {
|
|
10
|
+
constructor(httpServer) {
|
|
11
|
+
super(httpServer, 'WEBSOCKETS_REST');
|
|
12
|
+
this.server.on('connection', (ws, auth) => {
|
|
13
|
+
this.bindEvents(this.createClient(ws, auth));
|
|
14
|
+
});
|
|
15
|
+
logger.info(`WebSocket Server started at ws://${env['HOST']}:${env['PORT']}${this.endpoint}`);
|
|
16
|
+
}
|
|
17
|
+
bindEvents(client) {
|
|
18
|
+
client.on('parsed-message', async (message) => {
|
|
19
|
+
try {
|
|
20
|
+
message = WebSocketMessage.parse(await emitter.emitFilter('websocket.message', message, { client }));
|
|
21
|
+
client.accountability = await refreshAccountability(client.accountability);
|
|
22
|
+
emitter.emitAction('websocket.message', { message, client });
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
handleWebSocketException(client, error, 'server');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
client.on('error', (event) => {
|
|
30
|
+
emitter.emitAction('websocket.error', { client, event });
|
|
31
|
+
});
|
|
32
|
+
client.on('close', (event) => {
|
|
33
|
+
emitter.emitAction('websocket.close', { client, event });
|
|
34
|
+
});
|
|
35
|
+
emitter.emitAction('websocket.connect', { client });
|
|
36
|
+
}
|
|
37
|
+
parseMessage(data) {
|
|
38
|
+
let message;
|
|
39
|
+
try {
|
|
40
|
+
message = parseJSON(data);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
throw new WebSocketException('server', 'INVALID_PAYLOAD', 'Unable to parse the incoming message.');
|
|
44
|
+
}
|
|
45
|
+
return message;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { BaseException } from '@directus/exceptions';
|
|
2
|
+
import type { WebSocket } from 'ws';
|
|
3
|
+
import { ZodError } from 'zod';
|
|
4
|
+
import type { WebSocketResponse } from './messages.js';
|
|
5
|
+
import type { WebSocketClient } from './types.js';
|
|
6
|
+
export declare class WebSocketException extends Error {
|
|
7
|
+
type: string;
|
|
8
|
+
code: string;
|
|
9
|
+
uid: string | number | undefined;
|
|
10
|
+
constructor(type: string, code: string, message: string, uid?: string | number);
|
|
11
|
+
toJSON(): WebSocketResponse;
|
|
12
|
+
toMessage(): string;
|
|
13
|
+
static fromException(error: BaseException, type?: string): WebSocketException;
|
|
14
|
+
static fromZodError(error: ZodError, type?: string): WebSocketException;
|
|
15
|
+
}
|
|
16
|
+
export declare function handleWebSocketException(client: WebSocketClient | WebSocket, error: unknown, type?: string): void;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { BaseException } from '@directus/exceptions';
|
|
2
|
+
import { ZodError } from 'zod';
|
|
3
|
+
import { fromZodError } from 'zod-validation-error';
|
|
4
|
+
import logger from '../logger.js';
|
|
5
|
+
export class WebSocketException extends Error {
|
|
6
|
+
type;
|
|
7
|
+
code;
|
|
8
|
+
uid;
|
|
9
|
+
constructor(type, code, message, uid) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.type = type;
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.uid = uid;
|
|
14
|
+
}
|
|
15
|
+
toJSON() {
|
|
16
|
+
const message = {
|
|
17
|
+
type: this.type,
|
|
18
|
+
status: 'error',
|
|
19
|
+
error: {
|
|
20
|
+
code: this.code,
|
|
21
|
+
message: this.message,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
if (this.uid !== undefined) {
|
|
25
|
+
message.uid = this.uid;
|
|
26
|
+
}
|
|
27
|
+
return message;
|
|
28
|
+
}
|
|
29
|
+
toMessage() {
|
|
30
|
+
return JSON.stringify(this.toJSON());
|
|
31
|
+
}
|
|
32
|
+
static fromException(error, type = 'unknown') {
|
|
33
|
+
return new WebSocketException(type, error.code, error.message);
|
|
34
|
+
}
|
|
35
|
+
static fromZodError(error, type = 'unknown') {
|
|
36
|
+
const zError = fromZodError(error);
|
|
37
|
+
return new WebSocketException(type, 'INVALID_PAYLOAD', zError.message);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function handleWebSocketException(client, error, type) {
|
|
41
|
+
if (error instanceof BaseException) {
|
|
42
|
+
client.send(WebSocketException.fromException(error, type).toMessage());
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (error instanceof WebSocketException) {
|
|
46
|
+
client.send(error.toMessage());
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (error instanceof ZodError) {
|
|
50
|
+
client.send(WebSocketException.fromZodError(error, type).toMessage());
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
// unhandled exceptions
|
|
54
|
+
logger.error(`WebSocket unhandled exception ${JSON.stringify({ type, error })}`);
|
|
55
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { WebSocketController } from '../controllers/index.js';
|
|
2
|
+
import { WebSocketMessage } from '../messages.js';
|
|
3
|
+
import type { WebSocketClient } from '../types.js';
|
|
4
|
+
export declare class HeartbeatHandler {
|
|
5
|
+
private pulse;
|
|
6
|
+
private controller;
|
|
7
|
+
constructor(controller?: WebSocketController);
|
|
8
|
+
private checkClients;
|
|
9
|
+
onMessage(client: WebSocketClient, message: WebSocketMessage): void;
|
|
10
|
+
pingClients(): void;
|
|
11
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import emitter from '../../emitter.js';
|
|
2
|
+
import env from '../../env.js';
|
|
3
|
+
import { toBoolean } from '../../utils/to-boolean.js';
|
|
4
|
+
import { WebSocketController, getWebSocketController } from '../controllers/index.js';
|
|
5
|
+
import { WebSocketMessage } from '../messages.js';
|
|
6
|
+
import { fmtMessage, getMessageType } from '../utils/message.js';
|
|
7
|
+
const HEARTBEAT_FREQUENCY = Number(env['WEBSOCKETS_HEARTBEAT_PERIOD']) * 1000;
|
|
8
|
+
export class HeartbeatHandler {
|
|
9
|
+
pulse;
|
|
10
|
+
controller;
|
|
11
|
+
constructor(controller) {
|
|
12
|
+
this.controller = controller ?? getWebSocketController();
|
|
13
|
+
emitter.onAction('websocket.message', ({ client, message }) => {
|
|
14
|
+
try {
|
|
15
|
+
this.onMessage(client, WebSocketMessage.parse(message));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
/* ignore errors */
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
if (toBoolean(env['WEBSOCKETS_HEARTBEAT_ENABLED']) === true) {
|
|
22
|
+
emitter.onAction('websocket.connect', () => this.checkClients());
|
|
23
|
+
emitter.onAction('websocket.error', () => this.checkClients());
|
|
24
|
+
emitter.onAction('websocket.close', () => this.checkClients());
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
checkClients() {
|
|
28
|
+
const hasClients = this.controller.clients.size > 0;
|
|
29
|
+
if (hasClients && !this.pulse) {
|
|
30
|
+
this.pulse = setInterval(() => {
|
|
31
|
+
this.pingClients();
|
|
32
|
+
}, HEARTBEAT_FREQUENCY);
|
|
33
|
+
}
|
|
34
|
+
if (!hasClients && this.pulse) {
|
|
35
|
+
clearInterval(this.pulse);
|
|
36
|
+
this.pulse = undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
onMessage(client, message) {
|
|
40
|
+
if (getMessageType(message) !== 'ping')
|
|
41
|
+
return;
|
|
42
|
+
// send pong message back as acknowledgement
|
|
43
|
+
const data = 'uid' in message ? { uid: message.uid } : {};
|
|
44
|
+
client.send(fmtMessage('pong', data));
|
|
45
|
+
}
|
|
46
|
+
pingClients() {
|
|
47
|
+
const pendingClients = new Set(this.controller.clients);
|
|
48
|
+
const activeClients = new Set();
|
|
49
|
+
const timeout = setTimeout(() => {
|
|
50
|
+
// close connections that haven't responded
|
|
51
|
+
for (const client of pendingClients) {
|
|
52
|
+
client.close();
|
|
53
|
+
}
|
|
54
|
+
}, HEARTBEAT_FREQUENCY);
|
|
55
|
+
const messageWatcher = ({ client }) => {
|
|
56
|
+
// any message means this connection is still open
|
|
57
|
+
if (!activeClients.has(client)) {
|
|
58
|
+
pendingClients.delete(client);
|
|
59
|
+
activeClients.add(client);
|
|
60
|
+
}
|
|
61
|
+
if (pendingClients.size === 0) {
|
|
62
|
+
clearTimeout(timeout);
|
|
63
|
+
emitter.offAction('websocket.message', messageWatcher);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
emitter.onAction('websocket.message', messageWatcher);
|
|
67
|
+
// ping all the clients
|
|
68
|
+
for (const client of pendingClients) {
|
|
69
|
+
client.send(fmtMessage('ping'));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { HeartbeatHandler } from './heartbeat.js';
|
|
2
|
+
import { ItemsHandler } from './items.js';
|
|
3
|
+
import { SubscribeHandler } from './subscribe.js';
|
|
4
|
+
export function startWebSocketHandlers() {
|
|
5
|
+
new HeartbeatHandler();
|
|
6
|
+
new ItemsHandler();
|
|
7
|
+
new SubscribeHandler();
|
|
8
|
+
}
|
|
9
|
+
export * from './heartbeat.js';
|
|
10
|
+
export * from './items.js';
|
|
11
|
+
export * from './subscribe.js';
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import emitter from '../../emitter.js';
|
|
2
|
+
import { ItemsService, MetaService } from '../../services/index.js';
|
|
3
|
+
import { getSchema } from '../../utils/get-schema.js';
|
|
4
|
+
import { sanitizeQuery } from '../../utils/sanitize-query.js';
|
|
5
|
+
import { WebSocketException, handleWebSocketException } from '../exceptions.js';
|
|
6
|
+
import { WebSocketItemsMessage } from '../messages.js';
|
|
7
|
+
import { fmtMessage, getMessageType } from '../utils/message.js';
|
|
8
|
+
export class ItemsHandler {
|
|
9
|
+
constructor() {
|
|
10
|
+
emitter.onAction('websocket.message', ({ client, message }) => {
|
|
11
|
+
if (getMessageType(message) !== 'items')
|
|
12
|
+
return;
|
|
13
|
+
try {
|
|
14
|
+
const parsedMessage = WebSocketItemsMessage.parse(message);
|
|
15
|
+
this.onMessage(client, parsedMessage).catch((err) => {
|
|
16
|
+
// this catch is required because the async onMessage function is not awaited
|
|
17
|
+
handleWebSocketException(client, err, 'items');
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
handleWebSocketException(client, err, 'items');
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
async onMessage(client, message) {
|
|
26
|
+
const uid = message.uid;
|
|
27
|
+
const accountability = client.accountability;
|
|
28
|
+
const schema = await getSchema();
|
|
29
|
+
if (!schema.collections[message.collection] || message.collection.startsWith('directus_')) {
|
|
30
|
+
throw new WebSocketException('items', 'INVALID_COLLECTION', 'The provided collection does not exists or is not accessible.', uid);
|
|
31
|
+
}
|
|
32
|
+
const isSingleton = !!schema.collections[message.collection]?.singleton;
|
|
33
|
+
const service = new ItemsService(message.collection, { schema, accountability });
|
|
34
|
+
const metaService = new MetaService({ schema, accountability });
|
|
35
|
+
let result, meta;
|
|
36
|
+
if (message.action === 'create') {
|
|
37
|
+
const query = sanitizeQuery(message?.query ?? {}, accountability);
|
|
38
|
+
if (Array.isArray(message.data)) {
|
|
39
|
+
const keys = await service.createMany(message.data);
|
|
40
|
+
result = await service.readMany(keys, query);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
const key = await service.createOne(message.data);
|
|
44
|
+
result = await service.readOne(key, query);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (message.action === 'read') {
|
|
48
|
+
const query = sanitizeQuery(message.query ?? {}, accountability);
|
|
49
|
+
if (message.id) {
|
|
50
|
+
result = await service.readOne(message.id, query);
|
|
51
|
+
}
|
|
52
|
+
else if (message.ids) {
|
|
53
|
+
result = await service.readMany(message.ids, query);
|
|
54
|
+
}
|
|
55
|
+
else if (isSingleton) {
|
|
56
|
+
result = await service.readSingleton(query);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
result = await service.readByQuery(query);
|
|
60
|
+
}
|
|
61
|
+
meta = await metaService.getMetaForQuery(message.collection, query);
|
|
62
|
+
}
|
|
63
|
+
if (message.action === 'update') {
|
|
64
|
+
const query = sanitizeQuery(message.query ?? {}, accountability);
|
|
65
|
+
if (message.id) {
|
|
66
|
+
const key = await service.updateOne(message.id, message.data);
|
|
67
|
+
result = await service.readOne(key);
|
|
68
|
+
}
|
|
69
|
+
else if (message.ids) {
|
|
70
|
+
const keys = await service.updateMany(message.ids, message.data);
|
|
71
|
+
meta = await metaService.getMetaForQuery(message.collection, query);
|
|
72
|
+
result = await service.readMany(keys, query);
|
|
73
|
+
}
|
|
74
|
+
else if (isSingleton) {
|
|
75
|
+
await service.upsertSingleton(message.data);
|
|
76
|
+
result = await service.readSingleton(query);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const keys = await service.updateByQuery(query, message.data);
|
|
80
|
+
meta = await metaService.getMetaForQuery(message.collection, query);
|
|
81
|
+
result = await service.readMany(keys, query);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (message.action === 'delete') {
|
|
85
|
+
if (message.id) {
|
|
86
|
+
await service.deleteOne(message.id);
|
|
87
|
+
result = message.id;
|
|
88
|
+
}
|
|
89
|
+
else if (message.ids) {
|
|
90
|
+
await service.deleteMany(message.ids);
|
|
91
|
+
result = message.ids;
|
|
92
|
+
}
|
|
93
|
+
else if (message.query) {
|
|
94
|
+
const query = sanitizeQuery(message.query, accountability);
|
|
95
|
+
result = await service.deleteByQuery(query);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
throw new WebSocketException('items', 'INVALID_PAYLOAD', "Either 'ids', 'id' or 'query' is required for a DELETE request.", uid);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
client.send(fmtMessage('items', { data: result, ...(meta ? { meta } : {}) }, uid));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Messenger } from '../../messenger.js';
|
|
2
|
+
import type { WebSocketEvent } from '../messages.js';
|
|
3
|
+
import { WebSocketSubscribeMessage } from '../messages.js';
|
|
4
|
+
import type { Subscription, WebSocketClient } from '../types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Handler responsible for subscriptions
|
|
7
|
+
*/
|
|
8
|
+
export declare class SubscribeHandler {
|
|
9
|
+
subscriptions: Record<string, Set<Subscription>>;
|
|
10
|
+
protected messenger: Messenger;
|
|
11
|
+
/**
|
|
12
|
+
* Initialize the handler
|
|
13
|
+
*/
|
|
14
|
+
constructor();
|
|
15
|
+
/**
|
|
16
|
+
* Hook into websocket client lifecycle events
|
|
17
|
+
*/
|
|
18
|
+
bindWebSocket(): void;
|
|
19
|
+
/**
|
|
20
|
+
* Register a subscription
|
|
21
|
+
* @param subscription
|
|
22
|
+
*/
|
|
23
|
+
subscribe(subscription: Subscription): void;
|
|
24
|
+
/**
|
|
25
|
+
* Remove a subscription
|
|
26
|
+
* @param subscription
|
|
27
|
+
*/
|
|
28
|
+
unsubscribe(client: WebSocketClient, uid?: string | number): void;
|
|
29
|
+
/**
|
|
30
|
+
* Dispatch event to subscriptions
|
|
31
|
+
*/
|
|
32
|
+
dispatch(event: WebSocketEvent): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Handle incoming (un)subscribe requests
|
|
35
|
+
*/
|
|
36
|
+
onMessage(client: WebSocketClient, message: WebSocketSubscribeMessage): Promise<void>;
|
|
37
|
+
private getSinglePayload;
|
|
38
|
+
private getMultiPayload;
|
|
39
|
+
private getCollectionPayload;
|
|
40
|
+
private getFieldsPayload;
|
|
41
|
+
private getItemsPayload;
|
|
42
|
+
private getSubscription;
|
|
43
|
+
}
|