@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.
Files changed (57) hide show
  1. package/dist/emitter.d.ts +3 -2
  2. package/dist/emitter.js +12 -4
  3. package/dist/env.js +15 -4
  4. package/dist/messenger.d.ts +3 -3
  5. package/dist/messenger.js +14 -5
  6. package/dist/middleware/authenticate.js +2 -38
  7. package/dist/server.js +10 -0
  8. package/dist/services/graphql/index.d.ts +0 -6
  9. package/dist/services/graphql/index.js +98 -57
  10. package/dist/services/graphql/subscription.d.ts +16 -0
  11. package/dist/services/graphql/subscription.js +77 -0
  12. package/dist/services/server.js +24 -0
  13. package/dist/services/websocket.d.ts +14 -0
  14. package/dist/services/websocket.js +26 -0
  15. package/dist/utils/apply-diff.js +11 -2
  16. package/dist/utils/apply-query.js +5 -6
  17. package/dist/utils/get-accountability-for-token.d.ts +2 -0
  18. package/dist/utils/get-accountability-for-token.js +50 -0
  19. package/dist/utils/get-service.d.ts +7 -0
  20. package/dist/utils/get-service.js +49 -0
  21. package/dist/utils/redact.d.ts +4 -0
  22. package/dist/utils/redact.js +15 -1
  23. package/dist/utils/to-boolean.d.ts +4 -0
  24. package/dist/utils/to-boolean.js +6 -0
  25. package/dist/websocket/authenticate.d.ts +6 -0
  26. package/dist/websocket/authenticate.js +62 -0
  27. package/dist/websocket/controllers/base.d.ts +42 -0
  28. package/dist/websocket/controllers/base.js +276 -0
  29. package/dist/websocket/controllers/graphql.d.ts +12 -0
  30. package/dist/websocket/controllers/graphql.js +102 -0
  31. package/dist/websocket/controllers/hooks.d.ts +1 -0
  32. package/dist/websocket/controllers/hooks.js +122 -0
  33. package/dist/websocket/controllers/index.d.ts +10 -0
  34. package/dist/websocket/controllers/index.js +35 -0
  35. package/dist/websocket/controllers/rest.d.ts +9 -0
  36. package/dist/websocket/controllers/rest.js +47 -0
  37. package/dist/websocket/exceptions.d.ts +16 -0
  38. package/dist/websocket/exceptions.js +55 -0
  39. package/dist/websocket/handlers/heartbeat.d.ts +11 -0
  40. package/dist/websocket/handlers/heartbeat.js +72 -0
  41. package/dist/websocket/handlers/index.d.ts +4 -0
  42. package/dist/websocket/handlers/index.js +11 -0
  43. package/dist/websocket/handlers/items.d.ts +6 -0
  44. package/dist/websocket/handlers/items.js +103 -0
  45. package/dist/websocket/handlers/subscribe.d.ts +43 -0
  46. package/dist/websocket/handlers/subscribe.js +278 -0
  47. package/dist/websocket/messages.d.ts +311 -0
  48. package/dist/websocket/messages.js +96 -0
  49. package/dist/websocket/types.d.ts +34 -0
  50. package/dist/websocket/types.js +1 -0
  51. package/dist/websocket/utils/get-expires-at-for-token.d.ts +1 -0
  52. package/dist/websocket/utils/get-expires-at-for-token.js +8 -0
  53. package/dist/websocket/utils/message.d.ts +4 -0
  54. package/dist/websocket/utils/message.js +27 -0
  55. package/dist/websocket/utils/wait-for-message.d.ts +4 -0
  56. package/dist/websocket/utils/wait-for-message.js +45 -0
  57. package/package.json +19 -14
package/dist/emitter.d.ts CHANGED
@@ -4,8 +4,9 @@ export declare class Emitter {
4
4
  private actionEmitter;
5
5
  private initEmitter;
6
6
  constructor();
7
- emitFilter<T>(event: string | string[], payload: T, meta: Record<string, any>, context: EventContext): Promise<T>;
8
- emitAction(event: string | string[], meta: Record<string, any>, context: EventContext): void;
7
+ private getDefaultContext;
8
+ emitFilter<T>(event: string | string[], payload: T, meta: Record<string, any>, context?: EventContext | null): Promise<T>;
9
+ emitAction(event: string | string[], meta: Record<string, any>, context?: EventContext | null): void;
9
10
  emitInit(event: string, meta: Record<string, any>): Promise<void>;
10
11
  onFilter(event: string, handler: FilterHandler): void;
11
12
  onAction(event: string, handler: ActionHandler): void;
package/dist/emitter.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import ee2 from 'eventemitter2';
2
2
  import logger from './logger.js';
3
+ import getDatabase from './database/index.js';
3
4
  export class Emitter {
4
5
  filterEmitter;
5
6
  actionEmitter;
@@ -16,7 +17,14 @@ export class Emitter {
16
17
  this.actionEmitter = new ee2.EventEmitter2(emitterOptions);
17
18
  this.initEmitter = new ee2.EventEmitter2(emitterOptions);
18
19
  }
19
- async emitFilter(event, payload, meta, context) {
20
+ getDefaultContext() {
21
+ return {
22
+ database: getDatabase(),
23
+ accountability: null,
24
+ schema: null,
25
+ };
26
+ }
27
+ async emitFilter(event, payload, meta, context = null) {
20
28
  const events = Array.isArray(event) ? event : [event];
21
29
  const eventListeners = events.map((event) => ({
22
30
  event,
@@ -25,7 +33,7 @@ export class Emitter {
25
33
  let updatedPayload = payload;
26
34
  for (const { event, listeners } of eventListeners) {
27
35
  for (const listener of listeners) {
28
- const result = await listener(updatedPayload, { event, ...meta }, context);
36
+ const result = await listener(updatedPayload, { event, ...meta }, context ?? this.getDefaultContext());
29
37
  if (result !== undefined) {
30
38
  updatedPayload = result;
31
39
  }
@@ -33,10 +41,10 @@ export class Emitter {
33
41
  }
34
42
  return updatedPayload;
35
43
  }
36
- emitAction(event, meta, context) {
44
+ emitAction(event, meta, context = null) {
37
45
  const events = Array.isArray(event) ? event : [event];
38
46
  for (const event of events) {
39
- this.actionEmitter.emitAsync(event, { event, ...meta }, context).catch((err) => {
47
+ this.actionEmitter.emitAsync(event, { event, ...meta }, context ?? this.getDefaultContext()).catch((err) => {
40
48
  logger.warn(`An error was thrown while executing action "${event}"`);
41
49
  logger.warn(err);
42
50
  });
package/dist/env.js CHANGED
@@ -6,9 +6,10 @@ import { parseJSON, toArray } from '@directus/utils';
6
6
  import dotenv from 'dotenv';
7
7
  import fs from 'fs';
8
8
  import { clone, toNumber, toString } from 'lodash-es';
9
+ import { createRequire } from 'node:module';
9
10
  import path from 'path';
10
11
  import { requireYAML } from './utils/require-yaml.js';
11
- import { createRequire } from 'node:module';
12
+ import { toBoolean } from './utils/to-boolean.js';
12
13
  const require = createRequire(import.meta.url);
13
14
  // keeping this here for now to prevent a circular import to constants.ts
14
15
  const allowedEnvironmentVars = [
@@ -200,6 +201,8 @@ const allowedEnvironmentVars = [
200
201
  // flows
201
202
  'FLOWS_EXEC_ALLOWED_MODULES',
202
203
  'FLOWS_ENV_ALLOW_LIST',
204
+ // websockets
205
+ 'WEBSOCKETS_.+',
203
206
  ].map((name) => new RegExp(`^${name}$`));
204
207
  const acceptedEnvTypes = ['string', 'number', 'regex', 'array', 'json'];
205
208
  const defaults = {
@@ -275,6 +278,17 @@ const defaults = {
275
278
  EXPORT_BATCH_SIZE: 5000,
276
279
  FILE_METADATA_ALLOW_LIST: 'ifd0.Make,ifd0.Model,exif.FNumber,exif.ExposureTime,exif.FocalLength,exif.ISO',
277
280
  GRAPHQL_INTROSPECTION: true,
281
+ WEBSOCKETS_ENABLED: false,
282
+ WEBSOCKETS_REST_ENABLED: true,
283
+ WEBSOCKETS_REST_AUTH: 'handshake',
284
+ WEBSOCKETS_REST_AUTH_TIMEOUT: 10,
285
+ WEBSOCKETS_REST_PATH: '/websocket',
286
+ WEBSOCKETS_GRAPHQL_ENABLED: true,
287
+ WEBSOCKETS_GRAPHQL_AUTH: 'handshake',
288
+ WEBSOCKETS_GRAPHQL_AUTH_TIMEOUT: 10,
289
+ WEBSOCKETS_GRAPHQL_PATH: '/graphql',
290
+ WEBSOCKETS_HEARTBEAT_ENABLED: true,
291
+ WEBSOCKETS_HEARTBEAT_PERIOD: 30,
278
292
  FLOWS_EXEC_ALLOWED_MODULES: false,
279
293
  FLOWS_ENV_ALLOW_LIST: false,
280
294
  PRESSURE_LIMITER_ENABLED: true,
@@ -484,6 +498,3 @@ function tryJSON(value) {
484
498
  return value;
485
499
  }
486
500
  }
487
- function toBoolean(value) {
488
- return value === 'true' || value === true || value === '1' || value === 1;
489
- }
@@ -3,14 +3,14 @@ export type MessengerSubscriptionCallback = (payload: Record<string, any>) => vo
3
3
  export interface Messenger {
4
4
  publish: (channel: string, payload: Record<string, any>) => void;
5
5
  subscribe: (channel: string, callback: MessengerSubscriptionCallback) => void;
6
- unsubscribe: (channel: string) => void;
6
+ unsubscribe: (channel: string, callback?: MessengerSubscriptionCallback) => void;
7
7
  }
8
8
  export declare class MessengerMemory implements Messenger {
9
- handlers: Record<string, MessengerSubscriptionCallback>;
9
+ handlers: Record<string, Set<MessengerSubscriptionCallback>>;
10
10
  constructor();
11
11
  publish(channel: string, payload: Record<string, any>): void;
12
12
  subscribe(channel: string, callback: MessengerSubscriptionCallback): void;
13
- unsubscribe(channel: string): void;
13
+ unsubscribe(channel: string, callback?: MessengerSubscriptionCallback): void;
14
14
  }
15
15
  export declare class MessengerRedis implements Messenger {
16
16
  namespace: string;
package/dist/messenger.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { parseJSON } from '@directus/utils';
2
2
  import { Redis } from 'ioredis';
3
- import env from './env.js';
3
+ import { getEnv } from './env.js';
4
4
  import { getConfigFromEnv } from './utils/get-config-from-env.js';
5
5
  export class MessengerMemory {
6
6
  handlers;
@@ -8,13 +8,20 @@ export class MessengerMemory {
8
8
  this.handlers = {};
9
9
  }
10
10
  publish(channel, payload) {
11
- this.handlers[channel]?.(payload);
11
+ this.handlers[channel]?.forEach((callback) => callback(payload));
12
12
  }
13
13
  subscribe(channel, callback) {
14
- this.handlers[channel] = callback;
14
+ if (!this.handlers[channel])
15
+ this.handlers[channel] = new Set();
16
+ this.handlers[channel]?.add(callback);
15
17
  }
16
- unsubscribe(channel) {
17
- delete this.handlers[channel];
18
+ unsubscribe(channel, callback) {
19
+ if (!callback) {
20
+ delete this.handlers[channel];
21
+ }
22
+ else {
23
+ this.handlers[channel]?.delete(callback);
24
+ }
18
25
  }
19
26
  }
20
27
  export class MessengerRedis {
@@ -23,6 +30,7 @@ export class MessengerRedis {
23
30
  sub;
24
31
  constructor() {
25
32
  const config = getConfigFromEnv('MESSENGER_REDIS');
33
+ const env = getEnv();
26
34
  this.pub = new Redis(env['MESSENGER_REDIS'] ?? config);
27
35
  this.sub = new Redis(env['MESSENGER_REDIS'] ?? config);
28
36
  this.namespace = env['MESSENGER_NAMESPACE'] ?? 'directus';
@@ -47,6 +55,7 @@ let messenger;
47
55
  export function getMessenger() {
48
56
  if (messenger)
49
57
  return messenger;
58
+ const env = getEnv();
50
59
  if (env['MESSENGER_STORE'] === 'redis') {
51
60
  messenger = new MessengerRedis();
52
61
  }
@@ -1,12 +1,9 @@
1
1
  import { isEqual } from 'lodash-es';
2
2
  import getDatabase from '../database/index.js';
3
3
  import emitter from '../emitter.js';
4
- import env from '../env.js';
5
- import { InvalidCredentialsException } from '../exceptions/index.js';
6
4
  import asyncHandler from '../utils/async-handler.js';
7
5
  import { getIPFromReq } from '../utils/get-ip-from-req.js';
8
- import isDirectusJWT from '../utils/is-directus-jwt.js';
9
- import { verifyAccessJWT } from '../utils/jwt.js';
6
+ import { getAccountabilityForToken } from '../utils/get-accountability-for-token.js';
10
7
  /**
11
8
  * Verify the passed JWT and assign the user ID and role to `req`
12
9
  */
@@ -36,40 +33,7 @@ export const handler = async (req, _res, next) => {
36
33
  req.accountability = customAccountability;
37
34
  return next();
38
35
  }
39
- req.accountability = defaultAccountability;
40
- if (req.token) {
41
- if (isDirectusJWT(req.token)) {
42
- const payload = verifyAccessJWT(req.token, env['SECRET']);
43
- req.accountability.role = payload.role;
44
- req.accountability.admin = payload.admin_access === true || payload.admin_access == 1;
45
- req.accountability.app = payload.app_access === true || payload.app_access == 1;
46
- if (payload.share)
47
- req.accountability.share = payload.share;
48
- if (payload.share_scope)
49
- req.accountability.share_scope = payload.share_scope;
50
- if (payload.id)
51
- req.accountability.user = payload.id;
52
- }
53
- else {
54
- // Try finding the user with the provided token
55
- const user = await database
56
- .select('directus_users.id', 'directus_users.role', 'directus_roles.admin_access', 'directus_roles.app_access')
57
- .from('directus_users')
58
- .leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id')
59
- .where({
60
- 'directus_users.token': req.token,
61
- status: 'active',
62
- })
63
- .first();
64
- if (!user) {
65
- throw new InvalidCredentialsException();
66
- }
67
- req.accountability.user = user.id;
68
- req.accountability.role = user.role;
69
- req.accountability.admin = user.admin_access === true || user.admin_access == 1;
70
- req.accountability.app = user.app_access === true || user.app_access == 1;
71
- }
72
- }
36
+ req.accountability = await getAccountabilityForToken(req.token, defaultAccountability);
73
37
  return next();
74
38
  };
75
39
  export default asyncHandler(handler);
package/dist/server.js CHANGED
@@ -10,6 +10,9 @@ import emitter from './emitter.js';
10
10
  import env from './env.js';
11
11
  import logger from './logger.js';
12
12
  import { getConfigFromEnv } from './utils/get-config-from-env.js';
13
+ import { createSubscriptionController, createWebSocketController, getSubscriptionController, getWebSocketController, } from './websocket/controllers/index.js';
14
+ import { startWebSocketHandlers } from './websocket/handlers/index.js';
15
+ import { toBoolean } from './utils/to-boolean.js';
13
16
  export let SERVER_ONLINE = true;
14
17
  export async function createServer() {
15
18
  const server = http.createServer(await createApp());
@@ -66,6 +69,11 @@ export async function createServer() {
66
69
  res.once('finish', complete.bind(null, true));
67
70
  res.once('close', complete.bind(null, false));
68
71
  });
72
+ if (toBoolean(env['WEBSOCKETS_ENABLED']) === true) {
73
+ createSubscriptionController(server);
74
+ createWebSocketController(server);
75
+ startWebSocketHandlers();
76
+ }
69
77
  const terminusOptions = {
70
78
  timeout: env['SERVER_SHUTDOWN_TIMEOUT'] >= 0 && env['SERVER_SHUTDOWN_TIMEOUT'] < Infinity
71
79
  ? env['SERVER_SHUTDOWN_TIMEOUT']
@@ -84,6 +92,8 @@ export async function createServer() {
84
92
  SERVER_ONLINE = false;
85
93
  }
86
94
  async function onSignal() {
95
+ getSubscriptionController()?.terminate();
96
+ getWebSocketController()?.terminate();
87
97
  const database = getDatabase();
88
98
  await database.destroy();
89
99
  logger.info('Database connections destroyed');
@@ -5,7 +5,6 @@ import { GraphQLError, GraphQLSchema } from 'graphql';
5
5
  import { ObjectTypeComposer, SchemaComposer } from 'graphql-compose';
6
6
  import type { Knex } from 'knex';
7
7
  import type { AbstractServiceOptions, GraphQLParams, Item } from '../../types/index.js';
8
- import { ItemsService } from '../items.js';
9
8
  export declare class GraphQLService {
10
9
  accountability: Accountability | null;
11
10
  knex: Knex;
@@ -63,11 +62,6 @@ export declare class GraphQLService {
63
62
  * Convert Directus-Exception into a GraphQL format, so it can be returned by GraphQL properly.
64
63
  */
65
64
  formatError(error: BaseException | BaseException[]): GraphQLError;
66
- /**
67
- * Select the correct service for the given collection. This allows the individual services to run
68
- * their custom checks (f.e. it allows UsersService to prevent updating TFA secret from outside)
69
- */
70
- getService(collection: string): ItemsService;
71
65
  /**
72
66
  * Replace all fragments in a selectionset for the actual selection set as defined in the fragment
73
67
  * Effectively merges the selections with the fragments used in those selections
@@ -21,24 +21,13 @@ import { AuthenticationService } from '../authentication.js';
21
21
  import { CollectionsService } from '../collections.js';
22
22
  import { FieldsService } from '../fields.js';
23
23
  import { FilesService } from '../files.js';
24
- import { FlowsService } from '../flows.js';
25
- import { FoldersService } from '../folders.js';
26
- import { ItemsService } from '../items.js';
27
- import { NotificationsService } from '../notifications.js';
28
- import { OperationsService } from '../operations.js';
29
- import { PermissionsService } from '../permissions.js';
30
- import { PresetsService } from '../presets.js';
31
24
  import { RelationsService } from '../relations.js';
32
25
  import { RevisionsService } from '../revisions.js';
33
- import { RolesService } from '../roles.js';
34
26
  import { ServerService } from '../server.js';
35
- import { SettingsService } from '../settings.js';
36
- import { SharesService } from '../shares.js';
37
27
  import { SpecificationService } from '../specifications.js';
38
28
  import { TFAService } from '../tfa.js';
39
29
  import { UsersService } from '../users.js';
40
30
  import { UtilsService } from '../utils.js';
41
- import { WebhooksService } from '../webhooks.js';
42
31
  import { GraphQLBigInt } from './types/bigint.js';
43
32
  import { GraphQLDate } from './types/date.js';
44
33
  import { GraphQLGeoJSON } from './types/geojson.js';
@@ -47,6 +36,9 @@ import { GraphQLStringOrFloat } from './types/string-or-float.js';
47
36
  import { GraphQLVoid } from './types/void.js';
48
37
  import { addPathToValidationError } from './utils/add-path-to-validation-error.js';
49
38
  import processError from './utils/process-error.js';
39
+ import { createSubscriptionGenerator } from './subscription.js';
40
+ import { getService } from '../../utils/get-service.js';
41
+ import { toBoolean } from '../../utils/to-boolean.js';
50
42
  const validationRules = Array.from(specifiedRules);
51
43
  if (env['GRAPHQL_INTROSPECTION'] === false) {
52
44
  validationRules.push(NoSchemaIntrospectionCustomRule);
@@ -123,6 +115,14 @@ export class GraphQLService {
123
115
  ? this.schema
124
116
  : reduceSchema(this.schema, this.accountability?.permissions || null, ['delete']),
125
117
  };
118
+ const subscriptionEventType = schemaComposer.createEnumTC({
119
+ name: 'EventEnum',
120
+ values: {
121
+ create: { value: 'create' },
122
+ update: { value: 'update' },
123
+ delete: { value: 'delete' },
124
+ },
125
+ });
126
126
  const { ReadCollectionTypes } = getReadableTypes();
127
127
  const { CreateCollectionTypes, UpdateCollectionTypes, DeleteCollectionTypes } = getWritableTypes();
128
128
  const scopeFilter = (collection) => {
@@ -871,6 +871,26 @@ export class GraphQLService {
871
871
  },
872
872
  });
873
873
  }
874
+ const eventName = `${collection.collection}_mutated`;
875
+ if (collection.collection in ReadCollectionTypes) {
876
+ const subscriptionType = schemaComposer.createObjectTC({
877
+ name: eventName,
878
+ fields: {
879
+ key: new GraphQLNonNull(GraphQLID),
880
+ event: subscriptionEventType,
881
+ data: ReadCollectionTypes[collection.collection],
882
+ },
883
+ });
884
+ schemaComposer.Subscription.addFields({
885
+ [eventName]: {
886
+ type: subscriptionType,
887
+ args: {
888
+ event: subscriptionEventType,
889
+ },
890
+ subscribe: createSubscriptionGenerator(self, eventName),
891
+ },
892
+ });
893
+ }
874
894
  }
875
895
  for (const relation of schema.read.relations) {
876
896
  if (relation.related_collection) {
@@ -1147,7 +1167,11 @@ export class GraphQLService {
1147
1167
  if (singleton && action === 'update') {
1148
1168
  return await this.upsertSingleton(collection, args['data'], query);
1149
1169
  }
1150
- const service = this.getService(collection);
1170
+ const service = getService(collection, {
1171
+ knex: this.knex,
1172
+ accountability: this.accountability,
1173
+ schema: this.schema,
1174
+ });
1151
1175
  const hasQuery = (query.fields || []).length > 0;
1152
1176
  try {
1153
1177
  if (single) {
@@ -1195,7 +1219,11 @@ export class GraphQLService {
1195
1219
  * Execute the read action on the correct service. Checks for singleton as well.
1196
1220
  */
1197
1221
  async read(collection, query) {
1198
- const service = this.getService(collection);
1222
+ const service = getService(collection, {
1223
+ knex: this.knex,
1224
+ accountability: this.accountability,
1225
+ schema: this.schema,
1226
+ });
1199
1227
  const result = this.schema.collections[collection].singleton
1200
1228
  ? await service.readSingleton(query, { stripNonRequested: false })
1201
1229
  : await service.readByQuery(query, { stripNonRequested: false });
@@ -1205,7 +1233,11 @@ export class GraphQLService {
1205
1233
  * Upsert and read singleton item
1206
1234
  */
1207
1235
  async upsertSingleton(collection, body, query) {
1208
- const service = this.getService(collection);
1236
+ const service = getService(collection, {
1237
+ knex: this.knex,
1238
+ accountability: this.accountability,
1239
+ schema: this.schema,
1240
+ });
1209
1241
  try {
1210
1242
  await service.upsertSingleton(body);
1211
1243
  if ((query.fields || []).length > 0) {
@@ -1405,49 +1437,6 @@ export class GraphQLService {
1405
1437
  set(error, 'extensions.code', error.code);
1406
1438
  return new GraphQLError(error.message, undefined, undefined, undefined, undefined, error);
1407
1439
  }
1408
- /**
1409
- * Select the correct service for the given collection. This allows the individual services to run
1410
- * their custom checks (f.e. it allows UsersService to prevent updating TFA secret from outside)
1411
- */
1412
- getService(collection) {
1413
- const opts = {
1414
- knex: this.knex,
1415
- accountability: this.accountability,
1416
- schema: this.schema,
1417
- };
1418
- switch (collection) {
1419
- case 'directus_activity':
1420
- return new ActivityService(opts);
1421
- case 'directus_files':
1422
- return new FilesService(opts);
1423
- case 'directus_folders':
1424
- return new FoldersService(opts);
1425
- case 'directus_permissions':
1426
- return new PermissionsService(opts);
1427
- case 'directus_presets':
1428
- return new PresetsService(opts);
1429
- case 'directus_notifications':
1430
- return new NotificationsService(opts);
1431
- case 'directus_revisions':
1432
- return new RevisionsService(opts);
1433
- case 'directus_roles':
1434
- return new RolesService(opts);
1435
- case 'directus_settings':
1436
- return new SettingsService(opts);
1437
- case 'directus_users':
1438
- return new UsersService(opts);
1439
- case 'directus_webhooks':
1440
- return new WebhooksService(opts);
1441
- case 'directus_shares':
1442
- return new SharesService(opts);
1443
- case 'directus_flows':
1444
- return new FlowsService(opts);
1445
- case 'directus_operations':
1446
- return new OperationsService(opts);
1447
- default:
1448
- return new ItemsService(collection, opts);
1449
- }
1450
- }
1451
1440
  /**
1452
1441
  * Replace all fragments in a selectionset for the actual selection set as defined in the fragment
1453
1442
  * Effectively merges the selections with the fragments used in those selections
@@ -1539,6 +1528,58 @@ export class GraphQLService {
1539
1528
  },
1540
1529
  }),
1541
1530
  },
1531
+ websocket: toBoolean(env['WEBSOCKETS_ENABLED'])
1532
+ ? {
1533
+ type: new GraphQLObjectType({
1534
+ name: 'server_info_websocket',
1535
+ fields: {
1536
+ rest: {
1537
+ type: toBoolean(env['WEBSOCKETS_REST_ENABLED'])
1538
+ ? new GraphQLObjectType({
1539
+ name: 'server_info_websocket_rest',
1540
+ fields: {
1541
+ authentication: {
1542
+ type: new GraphQLEnumType({
1543
+ name: 'server_info_websocket_rest_authentication',
1544
+ values: {
1545
+ public: { value: 'public' },
1546
+ handshake: { value: 'handshake' },
1547
+ strict: { value: 'strict' },
1548
+ },
1549
+ }),
1550
+ },
1551
+ path: { type: GraphQLString },
1552
+ },
1553
+ })
1554
+ : GraphQLBoolean,
1555
+ },
1556
+ graphql: {
1557
+ type: toBoolean(env['WEBSOCKETS_GRAPHQL_ENABLED'])
1558
+ ? new GraphQLObjectType({
1559
+ name: 'server_info_websocket_graphql',
1560
+ fields: {
1561
+ authentication: {
1562
+ type: new GraphQLEnumType({
1563
+ name: 'server_info_websocket_graphql_authentication',
1564
+ values: {
1565
+ public: { value: 'public' },
1566
+ handshake: { value: 'handshake' },
1567
+ strict: { value: 'strict' },
1568
+ },
1569
+ }),
1570
+ },
1571
+ path: { type: GraphQLString },
1572
+ },
1573
+ })
1574
+ : GraphQLBoolean,
1575
+ },
1576
+ heartbeat: {
1577
+ type: toBoolean(env['WEBSOCKETS_HEARTBEAT_ENABLED']) ? GraphQLInt : GraphQLBoolean,
1578
+ },
1579
+ },
1580
+ }),
1581
+ }
1582
+ : GraphQLBoolean,
1542
1583
  queryLimit: {
1543
1584
  type: new GraphQLObjectType({
1544
1585
  name: 'server_info_query_limit',
@@ -0,0 +1,16 @@
1
+ import type { GraphQLService } from './index.js';
2
+ import type { GraphQLResolveInfo } from 'graphql';
3
+ export declare function bindPubSub(): void;
4
+ export declare function createSubscriptionGenerator(self: GraphQLService, event: string): (_x: unknown, _y: unknown, _z: unknown, request: GraphQLResolveInfo) => AsyncGenerator<{
5
+ [x: string]: {
6
+ key: any;
7
+ data: import("../../types/items.js").Item;
8
+ event: string;
9
+ };
10
+ } | {
11
+ [x: string]: {
12
+ key: any;
13
+ data: null;
14
+ event: string;
15
+ };
16
+ }, void, unknown>;
@@ -0,0 +1,77 @@
1
+ import { EventEmitter, on } from 'events';
2
+ import { getMessenger } from '../../messenger.js';
3
+ import { getSchema } from '../../utils/get-schema.js';
4
+ import { ItemsService } from '../items.js';
5
+ const messages = createPubSub(new EventEmitter());
6
+ export function bindPubSub() {
7
+ const messenger = getMessenger();
8
+ messenger.subscribe('websocket.event', (message) => {
9
+ messages.publish(`${message['collection']}_mutated`, message);
10
+ });
11
+ }
12
+ export function createSubscriptionGenerator(self, event) {
13
+ return async function* (_x, _y, _z, request) {
14
+ const fields = parseFields(self, request);
15
+ const args = parseArguments(request);
16
+ for await (const payload of messages.subscribe(event)) {
17
+ const eventData = payload;
18
+ if ('event' in args && eventData['action'] !== args['event']) {
19
+ continue; // skip filtered events
20
+ }
21
+ const schema = await getSchema();
22
+ if (eventData['action'] === 'create') {
23
+ const { collection, key } = eventData;
24
+ const service = new ItemsService(collection, { schema });
25
+ const data = await service.readOne(key, { fields });
26
+ yield { [event]: { key, data, event: 'create' } };
27
+ }
28
+ if (eventData['action'] === 'update') {
29
+ const { collection, keys } = eventData;
30
+ const service = new ItemsService(collection, { schema });
31
+ for (const key of keys) {
32
+ const data = await service.readOne(key, { fields });
33
+ yield { [event]: { key, data, event: 'update' } };
34
+ }
35
+ }
36
+ if (eventData['action'] === 'delete') {
37
+ const { keys } = eventData;
38
+ for (const key of keys) {
39
+ yield { [event]: { key, data: null, event: 'delete' } };
40
+ }
41
+ }
42
+ }
43
+ };
44
+ }
45
+ function createPubSub(emitter) {
46
+ return {
47
+ publish: (event, payload) => void emitter.emit(event, payload),
48
+ subscribe: async function* (event) {
49
+ const asyncIterator = on(emitter, event);
50
+ for await (const [value] of asyncIterator) {
51
+ yield value;
52
+ }
53
+ },
54
+ };
55
+ }
56
+ function parseFields(service, request) {
57
+ const selections = request.fieldNodes[0]?.selectionSet?.selections ?? [];
58
+ const dataSelections = selections.reduce((result, selection) => {
59
+ if (selection.kind === 'Field' &&
60
+ selection.name.value === 'data' &&
61
+ selection.selectionSet?.kind === 'SelectionSet') {
62
+ return selection.selectionSet.selections;
63
+ }
64
+ return result;
65
+ }, []);
66
+ const { fields } = service.getQuery({}, dataSelections, request.variableValues);
67
+ return fields ?? [];
68
+ }
69
+ function parseArguments(request) {
70
+ const args = request.fieldNodes[0]?.arguments ?? [];
71
+ return args.reduce((result, current) => {
72
+ if ('value' in current.value && typeof current.value.value === 'string') {
73
+ result[current.name.value] = current.value.value;
74
+ }
75
+ return result;
76
+ }, {});
77
+ }
@@ -13,6 +13,7 @@ import { SERVER_ONLINE } from '../server.js';
13
13
  import { getStorage } from '../storage/index.js';
14
14
  import { version } from '../utils/package.js';
15
15
  import { SettingsService } from './settings.js';
16
+ import { toBoolean } from '../utils/to-boolean.js';
16
17
  export class ServerService {
17
18
  knex;
18
19
  accountability;
@@ -67,6 +68,29 @@ export class ServerService {
67
68
  max: Number.isFinite(env['QUERY_LIMIT_MAX']) ? env['QUERY_LIMIT_MAX'] : -1,
68
69
  };
69
70
  }
71
+ if (this.accountability?.user) {
72
+ if (toBoolean(env['WEBSOCKETS_ENABLED'])) {
73
+ info['websocket'] = {};
74
+ info['websocket'].rest = toBoolean(env['WEBSOCKETS_REST_ENABLED'])
75
+ ? {
76
+ authentication: env['WEBSOCKETS_REST_AUTH'],
77
+ path: env['WEBSOCKETS_REST_PATH'],
78
+ }
79
+ : false;
80
+ info['websocket'].graphql = toBoolean(env['WEBSOCKETS_GRAPHQL_ENABLED'])
81
+ ? {
82
+ authentication: env['WEBSOCKETS_GRAPHQL_AUTH'],
83
+ path: env['WEBSOCKETS_GRAPHQL_PATH'],
84
+ }
85
+ : false;
86
+ info['websocket'].heartbeat = toBoolean(env['WEBSOCKETS_HEARTBEAT_ENABLED'])
87
+ ? env['WEBSOCKETS_HEARTBEAT_PERIOD']
88
+ : false;
89
+ }
90
+ else {
91
+ info['websocket'] = false;
92
+ }
93
+ }
70
94
  return info;
71
95
  }
72
96
  async health() {
@@ -0,0 +1,14 @@
1
+ import type { ActionHandler } from '@directus/types';
2
+ import type { WebSocketClient } from '../websocket/types.js';
3
+ import type { WebSocketMessage } from '../websocket/messages.js';
4
+ export declare class WebSocketService {
5
+ private controller;
6
+ constructor();
7
+ on(event: 'connect' | 'message' | 'error' | 'close', callback: ActionHandler): void;
8
+ off(event: 'connect' | 'message' | 'error' | 'close', callback: ActionHandler): void;
9
+ broadcast(message: string | WebSocketMessage, filter?: {
10
+ user?: string;
11
+ role?: string;
12
+ }): void;
13
+ clients(): Set<WebSocketClient>;
14
+ }