@webda/graphql 4.0.0-beta.5 → 4.0.0-beta.7

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.
@@ -32,10 +32,10 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
32
32
  if (target) Object.defineProperty(target, contextIn.name, descriptor);
33
33
  done = true;
34
34
  };
35
- import { DomainService, DomainServiceParameters, Route, WebdaError, useApplication, useCore, useCoreEvents, useModelMetadata, useRepository, isModelActionAllowed, queryModelWithPermissions, getClientWritableAttributes, assertNoPrivateFields } from "@webda/core";
35
+ import { DomainService, DomainServiceParameters, Route, WebdaError, useApplication, useCore, useCoreEvents, useModelMetadata, useRepository, isModelActionAllowed, queryModelWithPermissions, getClientWritableAttributes, assertNoPrivateFields, assertFilterOnly, callOperation, canCallOperation, createOperationFilter, useInstanceStorage, runWithInstanceStorage, emitCoreEvent, runWithContext, useRouter } from "@webda/core";
36
36
  import * as WebdaQL from "@webda/ql";
37
37
  import { EventIterator, MergedIterator } from "@webda/runtime";
38
- import { GraphQLBoolean, GraphQLError, GraphQLInputObjectType, GraphQLList, GraphQLObjectType, GraphQLSchema, GraphQLString, printSchema } from "graphql";
38
+ import { GraphQLBoolean, GraphQLError, GraphQLInputObjectType, GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLSchema, GraphQLString, printSchema } from "graphql";
39
39
  import { createHandler } from "graphql-http";
40
40
  import { CloseCode, makeServer } from "graphql-ws";
41
41
  import { nextTick } from "process";
@@ -43,7 +43,8 @@ import { WebSocketServer } from "ws";
43
43
  import { AnyScalarType } from "./types/any.js";
44
44
  import { DateScalar } from "./types/date.js";
45
45
  import { GraphQLLong } from "./types/long.js";
46
- import { createFromInput, isInputAttribute, loadForAction, updateFromInput } from "./mutations.js";
46
+ import { createFromInput, isInputAttribute, loadForAction, operationError, updateFromInput } from "./mutations.js";
47
+ import { GRAPHQL_NAME, GraphQLOperationContext, graphqlPlacement, operationTypeName, publicCopy } from "./operations.js";
47
48
  const GraphIQL = `
48
49
  <!doctype html>
49
50
  <html lang="en">
@@ -140,6 +141,7 @@ export class GraphQLParameters extends DomainServiceParameters {
140
141
  this.userModel ?? (this.userModel = "User");
141
142
  this.exposeMe ?? (this.exposeMe = true);
142
143
  this.globalSubscription ?? (this.globalSubscription = true);
144
+ this.exposeOperations ?? (this.exposeOperations = ["*"]);
143
145
  return this;
144
146
  }
145
147
  }
@@ -161,6 +163,10 @@ let GraphQLService = (() => {
161
163
  constructor() {
162
164
  super(...arguments);
163
165
  this.schema = __runInitializers(this, _instanceExtraInitializers);
166
+ /**
167
+ * Generated named types, by name: a name is generated once per shape (reset by generateSchema)
168
+ */
169
+ this.namedTypes = new Map();
164
170
  }
165
171
  /**
166
172
  * Load and initialize GraphQL service parameters
@@ -170,6 +176,14 @@ let GraphQLService = (() => {
170
176
  loadParameters(params) {
171
177
  return new GraphQLParameters().load(params);
172
178
  }
179
+ /**
180
+ * Parameters with their defaults (the core builds a service configuration through this, not loadParameters)
181
+ * @param params - raw partial configuration
182
+ * @returns initialized GraphQLParameters
183
+ */
184
+ static createConfiguration(params = {}) {
185
+ return new GraphQLParameters().load(params);
186
+ }
173
187
  /**
174
188
  * Resolve a JSON Schema property by following $ref pointers
175
189
  * @param prop - JSON Schema property that may contain $ref
@@ -188,6 +202,41 @@ let GraphQLService = (() => {
188
202
  }
189
203
  return prop || { type: "null" };
190
204
  }
205
+ /**
206
+ * One GraphQL type per name: reuse the type already generated for this name and shape
207
+ * @param name - type name
208
+ * @param shape - what makes two types of this name the same (kind and field names)
209
+ * @param create - builds the type the first time
210
+ * @returns the type
211
+ * @throws Error when the name was generated for another shape
212
+ */
213
+ namedType(name, shape, create) {
214
+ this.namedTypes ?? (this.namedTypes = new Map());
215
+ const known = this.namedTypes.get(name);
216
+ if (known) {
217
+ if (known.shape !== shape) {
218
+ throw new Error(`GraphQL type ${name} is generated for two different schemas (${known.shape} / ${shape}): give one of them another title`);
219
+ }
220
+ return known.type;
221
+ }
222
+ const type = create();
223
+ this.namedTypes.set(name, { type, shape });
224
+ return type;
225
+ }
226
+ /**
227
+ * Reserve a type name for a type built later (its entry gets the type once built)
228
+ * @param name - type name
229
+ * @param shape - identity of the reserving type
230
+ * @throws Error when the name is already taken by another shape
231
+ */
232
+ reserveName(name, shape) {
233
+ this.namedTypes ?? (this.namedTypes = new Map());
234
+ const known = this.namedTypes.get(name);
235
+ if (known && known.shape !== shape) {
236
+ throw new Error(`GraphQL type ${name} is generated for two different schemas (${known.shape} / ${shape}): give one of them another title`);
237
+ }
238
+ this.namedTypes.set(name, known || { type: undefined, shape });
239
+ }
191
240
  /**
192
241
  * Convert a JSON Schema to a GraphQL type definition
193
242
  * @param schema - JSON Schema to convert
@@ -196,6 +245,17 @@ let GraphQLService = (() => {
196
245
  * @returns object with the GraphQL type and optional description, or undefined for null types
197
246
  */
198
247
  getGraphQLSchemaFromSchema(schema, defaultName, input) {
248
+ // `type: ["string", "null"]`: the nullable form of one type (GraphQL fields are nullable already)
249
+ if (Array.isArray(schema?.type)) {
250
+ const types = schema.type.filter(t => t !== "null");
251
+ if (types.length === 0) {
252
+ return undefined;
253
+ }
254
+ if (types.length > 1) {
255
+ return { type: AnyScalarType, description: schema.description };
256
+ }
257
+ return this.getGraphQLSchemaFromSchema({ ...schema, type: types[0] }, defaultName, input);
258
+ }
199
259
  let type;
200
260
  if (!schema || !schema.type) {
201
261
  type = AnyScalarType;
@@ -242,10 +302,12 @@ let GraphQLService = (() => {
242
302
  this.log("DEBUG", "Return map for", defaultName, "because no fields");
243
303
  return { type: AnyScalarType, description: "Map" };
244
304
  }
245
- type = new (input ? GraphQLInputObjectType : GraphQLObjectType)({
246
- fields,
247
- name: schema.title || defaultName
248
- });
305
+ // A titled input type gets its own name: the output type of the same title already uses the title
306
+ const name = schema.title ? (input ? `${schema.title}Input` : schema.title) : defaultName;
307
+ type = this.namedType(name, `${input ? "input" : "output"}:${Object.keys(fields)
308
+ .sort()
309
+ .map(k => `${k}:${fields[k].type}`)
310
+ .join(",")}`, () => (input ? new GraphQLInputObjectType({ fields, name }) : new GraphQLObjectType({ fields, name })));
249
311
  }
250
312
  return { type, description: schema.description };
251
313
  }
@@ -383,7 +445,8 @@ let GraphQLService = (() => {
383
445
  * private (`__`) fields
384
446
  * @param filter - the client filter
385
447
  * @returns the validator, undefined without filter
386
- * @throws GraphQLError BAD_USER_INPUT for an invalid filter or one reading private fields
448
+ * @throws GraphQLError BAD_USER_INPUT for an invalid filter, one reading private fields, or a statement (DELETE,
449
+ * UPDATE, SELECT field list): GraphQL selection sets choose the fields
387
450
  */
388
451
  parseFilter(filter) {
389
452
  if (!filter) {
@@ -392,6 +455,7 @@ let GraphQLService = (() => {
392
455
  try {
393
456
  const validator = new WebdaQL.PartialValidator(WebdaQL.unsanitize(filter));
394
457
  assertNoPrivateFields(validator);
458
+ assertFilterOnly(validator);
395
459
  return validator;
396
460
  }
397
461
  catch (err) {
@@ -488,17 +552,40 @@ let GraphQLService = (() => {
488
552
  super.resolve();
489
553
  // Set-up ws server
490
554
  this.wss = new WebSocketServer({ noServer: true });
555
+ // The HTTP server events fire outside the instance storage: re-enter it
556
+ const storage = useInstanceStorage();
491
557
  useCoreEvents("Webda.Init.Http", (http) => {
492
558
  http.on("upgrade", (req, socket, head) => {
493
- if (req.url === this.parameters.url) {
559
+ runWithInstanceStorage(storage, () => {
560
+ if (req.url !== this.parameters.url)
561
+ return;
494
562
  (async () => {
495
- req.webdaContext ?? (req.webdaContext = await useCore().getContextFromRequest(req));
496
- await req.webdaContext.init();
563
+ const httpServer = Object.values(useCore().getServices()).find(s => s.server === http && typeof s.getContextFromRequest === "function");
564
+ req.webdaContext ?? (req.webdaContext = await (httpServer ?? useCore()).getContextFromRequest(req));
565
+ const context = req.webdaContext;
566
+ await context.init();
567
+ // Same checks as a normal request: Webda.Request listeners and the router request filters
568
+ const allowed = await runWithContext(context, async () => {
569
+ try {
570
+ emitCoreEvent("Webda.Request", { context });
571
+ }
572
+ catch {
573
+ // listener error
574
+ }
575
+ return useRouter().checkRequest(context);
576
+ });
577
+ if (!allowed) {
578
+ socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
579
+ return;
580
+ }
497
581
  this.wss.handleUpgrade(req, socket, head, ws => {
498
582
  this.wss.emit("connection", ws, req);
499
583
  });
500
- })();
501
- }
584
+ })().catch(err => {
585
+ this.log("ERROR", "GraphQL WebSocket upgrade failed", err);
586
+ socket.destroy();
587
+ });
588
+ });
502
589
  });
503
590
  });
504
591
  return this;
@@ -512,6 +599,10 @@ let GraphQLService = (() => {
512
599
  const subscriptions = {};
513
600
  const models = this.app.getModels();
514
601
  this.modelsMap = {};
602
+ this.namedTypes = new Map();
603
+ // First pass: reserve every exposed model name (and its Input) before any type is built, so a nested
604
+ // titled type of the same name fails whatever the model order
605
+ const exposed = [];
515
606
  for (const i in models) {
516
607
  const model = models[i];
517
608
  const metadata = useModelMetadata(model);
@@ -532,6 +623,11 @@ let GraphQLService = (() => {
532
623
  if (!schema)
533
624
  continue;
534
625
  const name = (metadata.ShortName || i.split("/").pop()).replace("/", "_");
626
+ this.reserveName(name, `model ${i}`);
627
+ this.reserveName(`${name}Input`, `model input ${i}`);
628
+ exposed.push({ i, model, metadata, schema, name });
629
+ }
630
+ for (const { i, model, metadata, schema, name } of exposed) {
535
631
  this.log("INFO", "Add GraphQL type", name);
536
632
  const modelGraph = metadata.Relations;
537
633
  this.modelsMap[i] = new GraphQLObjectType({
@@ -542,6 +638,8 @@ let GraphQLService = (() => {
542
638
  fields: this.getGraphQLFieldsFromSchema(schema, name + "Input", modelGraph, true, getClientWritableAttributes(model)),
543
639
  name: name + "Input"
544
640
  });
641
+ this.namedTypes.get(name).type = this.modelsMap[i];
642
+ this.namedTypes.get(`${name}Input`).type = input;
545
643
  const actionsName = Object.keys(metadata.Actions);
546
644
  if (!actionsName.includes("create")) {
547
645
  mutations[`create${name}`] = {
@@ -597,7 +695,7 @@ let GraphQLService = (() => {
597
695
  },
598
696
  resolve: async (_, args, context) => {
599
697
  // Same permission filtering as the DomainService query operation
600
- return await queryModelWithPermissions(model, WebdaQL.unsanitize(args.query || ""), context);
698
+ return await this.queryModel(model, WebdaQL.unsanitize(args.query || ""), context);
601
699
  },
602
700
  subscribe: async (_source, args, context) => {
603
701
  this.log("DEBUG", "Subscription called on", args);
@@ -617,7 +715,8 @@ let GraphQLService = (() => {
617
715
  },
618
716
  subscribe: async (_source, args, context) => {
619
717
  this.log("DEBUG", "Subscription called on", args);
620
- return this.registerAsyncIterator(model, args[uuidField], context);
718
+ // The payload is keyed by the field name: the default resolver reads it from there
719
+ return this.registerAsyncIterator(model, args[uuidField], context, this.transformName(i.split("/").pop()));
621
720
  }
622
721
  };
623
722
  const events = metadata.Events || [];
@@ -774,6 +873,9 @@ let GraphQLService = (() => {
774
873
  for (const i in rootFields) {
775
874
  subscriptions[i] ?? (subscriptions[i] = { ...rootFields[i], resolve: undefined });
776
875
  }
876
+ // Operations (service operations, model and behaviour actions) next to the model schema; added after the copy
877
+ // above on purpose: a query operation is not a subscription
878
+ this.addOperationFields(rootFields, mutations, subscriptions);
777
879
  if (this.parameters.globalSubscription) {
778
880
  // Create global type based on all previous subscriptions
779
881
  subscriptions["Aggregate"] = {
@@ -809,6 +911,149 @@ let GraphQLService = (() => {
809
911
  }
810
912
  this.schema = this.getGraphQLSchema(rootFields, mutations, subscriptions);
811
913
  }
914
+ /**
915
+ * Expose the registered operations, except the model CRUD the model schema already serves: read-only ones as
916
+ * queries, the others as mutations, server-streaming ones as subscriptions
917
+ * @param rootFields - query fields
918
+ * @param mutations - mutation fields
919
+ * @param subscriptions - subscription fields
920
+ * @throws Error when a field name is already defined
921
+ */
922
+ addOperationFields(rootFields, mutations, subscriptions) {
923
+ const exposed = createOperationFilter(this.parameters.exposeOperations);
924
+ const targets = { query: rootFields, mutation: mutations, subscription: subscriptions };
925
+ const owners = new Map();
926
+ const operations = useInstanceStorage().operations;
927
+ for (const id of Object.keys(operations).sort()) {
928
+ const op = operations[id];
929
+ if (!exposed(id))
930
+ continue;
931
+ const placement = graphqlPlacement(id, op);
932
+ if (!placement)
933
+ continue;
934
+ const target = targets[placement.kind];
935
+ const key = `${placement.kind}.${placement.name}`;
936
+ if (placement.kind === "subscription" && placement.name === "Aggregate" && this.parameters.globalSubscription) {
937
+ throw new Error(`GraphQL subscription field Aggregate of operation ${id} is already defined by the global Aggregate subscription (globalSubscription): rename it with @Operation({ graphql: { subscription: "…" } })`);
938
+ }
939
+ if (target[placement.name]) {
940
+ throw new Error(`GraphQL ${placement.kind} field ${placement.name} of operation ${id} is already defined by ${owners.get(key) ?? "the model schema"}: rename it with @Operation({ graphql: { ${placement.kind}: "…" } })`);
941
+ }
942
+ owners.set(key, `operation ${id}`);
943
+ const base = operationTypeName(id);
944
+ const { args, toInput } = this.operationArgs(op, base);
945
+ const type = this.operationOutputType(op, base);
946
+ const description = op.description || op.summary;
947
+ target[placement.name] =
948
+ placement.kind === "subscription"
949
+ ? {
950
+ type,
951
+ args,
952
+ description,
953
+ subscribe: async (_source, fieldArgs, context) => this.subscribeOperation(id, op, toInput(fieldArgs), context),
954
+ resolve: (chunk) => chunk
955
+ }
956
+ : {
957
+ type,
958
+ args,
959
+ description,
960
+ resolve: async (_source, fieldArgs, context) => this.runOperation(id, op, toInput(fieldArgs), context)
961
+ };
962
+ this.log("INFO", `Add GraphQL ${placement.kind}`, placement.name, "for operation", id);
963
+ }
964
+ }
965
+ /**
966
+ * GraphQL arguments of an operation: one per input schema property, or a single `input` argument
967
+ * @param op - operation
968
+ * @param base - type name prefix
969
+ * @returns the arguments and how to turn them back into the operation input
970
+ */
971
+ operationArgs(op, base) {
972
+ const schema = op.input && op.input !== "void" ? this.app.getSchema(op.input) : undefined;
973
+ if (!schema) {
974
+ return { args: {}, toInput: () => undefined };
975
+ }
976
+ const resolved = this.getJsonSchemaDefinition(schema, schema.definitions);
977
+ const names = Object.keys(resolved.properties ?? {}).filter(name => !name.startsWith("__"));
978
+ if (resolved.type === "object" && names.length > 0 && names.every(name => GRAPHQL_NAME.test(name))) {
979
+ const required = new Set(resolved.required ?? []);
980
+ const args = {};
981
+ for (const name of names) {
982
+ const converted = this.getGraphQLSchemaFromSchema(this.getJsonSchemaDefinition(resolved.properties[name], resolved.definitions ?? schema.definitions), `${base}Input_${name}`, true);
983
+ // A property the converter cannot map still is an argument: the Object scalar takes anything
984
+ const type = (converted?.type ?? AnyScalarType);
985
+ args[name] = {
986
+ type: required.has(name) ? new GraphQLNonNull(type) : type,
987
+ description: converted?.description
988
+ };
989
+ }
990
+ return { args, toInput: fieldArgs => ({ ...fieldArgs }) };
991
+ }
992
+ const converted = this.getGraphQLSchemaFromSchema(resolved, `${base}Input`, true);
993
+ return {
994
+ args: { input: { type: (converted?.type ?? AnyScalarType) } },
995
+ toInput: fieldArgs => fieldArgs.input
996
+ };
997
+ }
998
+ /**
999
+ * GraphQL type of an operation result
1000
+ * @param op - operation
1001
+ * @param base - type name prefix
1002
+ * @returns the model type for a model output, Boolean for void, else the converted output schema
1003
+ */
1004
+ operationOutputType(op, base) {
1005
+ if (!op.output || op.output === "void") {
1006
+ return GraphQLBoolean;
1007
+ }
1008
+ if (this.modelsMap[op.output]) {
1009
+ return this.modelsMap[op.output];
1010
+ }
1011
+ const schema = this.app.getSchema(op.output);
1012
+ const converted = schema &&
1013
+ this.getGraphQLSchemaFromSchema(this.getJsonSchemaDefinition(schema, schema.definitions), `${base}Output`);
1014
+ return (converted?.type ?? AnyScalarType);
1015
+ }
1016
+ /**
1017
+ * Run an operation for a query or mutation field
1018
+ * @param id - operation id
1019
+ * @param op - operation
1020
+ * @param input - operation input
1021
+ * @param context - the GraphQL request context
1022
+ * @returns the field value
1023
+ */
1024
+ async runOperation(id, op, input, context) {
1025
+ this.countOperation(context);
1026
+ const ctx = new GraphQLOperationContext(context, input);
1027
+ await ctx.init();
1028
+ try {
1029
+ await callOperation(ctx, id);
1030
+ }
1031
+ catch (err) {
1032
+ throw operationError(err);
1033
+ }
1034
+ if (!op.output || op.output === "void") {
1035
+ return true;
1036
+ }
1037
+ return this.modelsMap[op.output] ? ctx.result : publicCopy(ctx.result);
1038
+ }
1039
+ /**
1040
+ * Start a server-streaming operation for a subscription field
1041
+ * @param id - operation id
1042
+ * @param op - operation
1043
+ * @param input - operation input
1044
+ * @param context - the GraphQL request context
1045
+ * @returns the chunks; leaving the subscription cancels the operation
1046
+ */
1047
+ async subscribeOperation(id, op, input, context) {
1048
+ this.countOperation(context);
1049
+ const ctx = new GraphQLOperationContext(context, input, this.modelsMap[op.output] ? chunk => chunk : publicCopy);
1050
+ await ctx.init();
1051
+ if (!(await canCallOperation(ctx, id, { input }))) {
1052
+ throw operationError(new WebdaError.Forbidden(`${id} PermissionDenied`));
1053
+ }
1054
+ callOperation(ctx, id).then(() => ctx.finish(), err => ctx.fail(err));
1055
+ return ctx.chunks();
1056
+ }
812
1057
  /**
813
1058
  * Extract the list of event names from the GraphQL selection set (excluding latestEventTime)
814
1059
  * @param info - GraphQL resolve info containing field selections
@@ -830,8 +1075,44 @@ let GraphQLService = (() => {
830
1075
  * @returns the readable related objects
831
1076
  */
832
1077
  async queryRelated(related, model, query, context) {
1078
+ this.assertFilterQuery(query);
833
1079
  return queryModelWithPermissions(model, related.getQuery(query), context);
834
1080
  }
1081
+ /**
1082
+ * Run a root query argument: the DomainService query with the caller permissions
1083
+ * @param model - the model class
1084
+ * @param query - the client query
1085
+ * @param context - the caller context
1086
+ * @returns the readable results
1087
+ * @throws GraphQLError BAD_USER_INPUT for a statement (DELETE, UPDATE, SELECT field list)
1088
+ */
1089
+ async queryModel(model, query, context) {
1090
+ this.assertFilterQuery(query);
1091
+ return queryModelWithPermissions(model, query, context);
1092
+ }
1093
+ /**
1094
+ * Refuse a query argument that is not a filter: DELETE and UPDATE are never run from GraphQL, and a SELECT field
1095
+ * list is refused because the selection set already chooses the fields
1096
+ *
1097
+ * A query that does not parse is left to the query path, which reports it as before.
1098
+ * @param query - the client query
1099
+ * @throws GraphQLError BAD_USER_INPUT for a DELETE, an UPDATE or a SELECT field list
1100
+ */
1101
+ assertFilterQuery(query) {
1102
+ let validator;
1103
+ try {
1104
+ validator = new WebdaQL.QueryValidator(query ?? "");
1105
+ }
1106
+ catch {
1107
+ return;
1108
+ }
1109
+ try {
1110
+ assertFilterOnly(validator);
1111
+ }
1112
+ catch (err) {
1113
+ throw new GraphQLError(err.message, { extensions: { code: "BAD_USER_INPUT" } });
1114
+ }
1115
+ }
835
1116
  /**
836
1117
  * Create an async iterator that streams query results, updating when store events occur
837
1118
  * @param model - model class to query
@@ -842,6 +1123,7 @@ let GraphQLService = (() => {
842
1123
  */
843
1124
  async registerAsyncIteratorQuery(model, plural, query, context) {
844
1125
  // Results are always filtered with the subscriber permissions (permission query and canAct "get")
1126
+ this.assertFilterQuery(query);
845
1127
  const runQuery = () => queryModelWithPermissions(model, query, context);
846
1128
  let result = await runQuery();
847
1129
  const queryInfo = new WebdaQL.QueryValidator(query);
@@ -941,11 +1223,24 @@ let GraphQLService = (() => {
941
1223
  */
942
1224
  async registerAsyncEventIterator(model, uuid, events, context, identifier) {
943
1225
  const updatedCallback = eventName => async (evt) => {
944
- if (uuid === null || evt.object_id !== uuid)
1226
+ // A class-wide subscription (uuid null) receives the events of every object of the model
1227
+ if (uuid !== null && evt.object_id !== uuid)
945
1228
  return;
946
- // We rely on the cache of the store to get the full object
947
- // We let the other listeners finish before returning the object
948
- return { latestEventTime: Date.now(), [eventName]: evt };
1229
+ if (uuid === null) {
1230
+ // Only the events of objects the subscriber can read: an object that is gone or unreadable is skipped
1231
+ let object = evt.object;
1232
+ if (!object) {
1233
+ try {
1234
+ object = await model.ref(evt.object_id).get();
1235
+ }
1236
+ catch {
1237
+ return;
1238
+ }
1239
+ }
1240
+ if (!object || !(await isModelActionAllowed(object, context, "get", model)))
1241
+ return;
1242
+ }
1243
+ return { latestEventTime: Date.now(), [eventName]: publicCopy(evt) };
949
1244
  };
950
1245
  const eventsMap = {};
951
1246
  if (uuid !== null) {
@@ -969,6 +1264,30 @@ let GraphQLService = (() => {
969
1264
  logout: { evt: "nok?" }
970
1265
  }).iterate();
971
1266
  }
1267
+ /**
1268
+ * Hand a WebSocket message to graphql-ws; a failure is logged and closes the socket without leaking details
1269
+ * @param socket - the WebSocket
1270
+ * @param cb - the graphql-ws message handler
1271
+ * @param data - the message
1272
+ * @param storage - the instance storage to re-enter for the resolvers
1273
+ * @returns a promise settled once the message is handled
1274
+ */
1275
+ async handleSocketMessage(socket, cb, data, storage) {
1276
+ try {
1277
+ // wait for the the operation to complete
1278
+ // - if init message, waits for connect
1279
+ // - if query/mutation, waits for result
1280
+ // - if subscription, waits for complete
1281
+ // The socket events fire outside the instance storage: re-enter it for the resolvers
1282
+ await runWithInstanceStorage(storage, () => cb(data));
1283
+ }
1284
+ catch (err) {
1285
+ // all errors that could be thrown during the execution of operations will be caught here
1286
+ this.log("ERROR", "GraphQL websocket message failed", err);
1287
+ // A close reason is limited to 123 bytes and must not leak internal details
1288
+ socket.close(CloseCode.InternalServerError, "Internal server error");
1289
+ }
1290
+ }
972
1291
  /**
973
1292
  * Assemble the final GraphQLSchema from root query fields, mutations, and subscriptions
974
1293
  * @param rootFields - top-level query fields
@@ -1020,6 +1339,7 @@ let GraphQLService = (() => {
1020
1339
  return req.extra?.context || {};
1021
1340
  }
1022
1341
  });
1342
+ const storage = useInstanceStorage();
1023
1343
  this.wss.on("connection", async (socket, request) => {
1024
1344
  // a new socket opened, let graphql-ws take over
1025
1345
  const closed = this.wsHandler.opened({
@@ -1028,20 +1348,7 @@ let GraphQLService = (() => {
1028
1348
  socket.send(data, err => (err ? reject(err) : resolve()));
1029
1349
  }), // control your data flow by timing the promise resolve
1030
1350
  close: (code, reason) => socket.close(code, reason), // there are protocol standard closures
1031
- onMessage: cb => socket.on("message", async (event) => {
1032
- try {
1033
- // wait for the the operation to complete
1034
- // - if init message, waits for connect
1035
- // - if query/mutation, waits for result
1036
- // - if subscription, waits for complete
1037
- await cb(event.toString());
1038
- }
1039
- catch (err) {
1040
- // all errors that could be thrown during the
1041
- // execution of operations will be caught here
1042
- socket.close(CloseCode.InternalServerError, err.message);
1043
- }
1044
- })
1351
+ onMessage: cb => socket.on("message", event => this.handleSocketMessage(socket, cb, event.toString(), storage))
1045
1352
  },
1046
1353
  // pass values to the `extra` field in the context
1047
1354
  { socket, request, context: request.webdaContext });