@makerx/graphql-core 3.4.0 → 3.5.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/README.md CHANGED
@@ -382,6 +382,24 @@ This library includes a `subscriptions` module to provide simple setup using the
382
382
 
383
383
  Pass `includeDeprecatedElements: true` to add [deprecation usage](#collectdeprecatedelementusage) to the operation log entry as `deprecatedElements`. It is collected once, when the subscription is established, rather than per emitted payload — the usage is a property of the operation, not of each event.
384
384
 
385
+ By default a connection's token is verified when the connection is established, and again each time the client subscribes on it. A token that no longer verifies then closes the connection with `4403 Forbidden`, and the operation is not run. Subscriptions already running continue until that happens.
386
+
387
+ To give connections a set lifetime instead, pass `connectionExpiry`. It receives the claims `verifyToken` returned and returns the time the connection ends, in milliseconds since the epoch (or `undefined` for the default behaviour). At that time the connection is closed with `4403`, which ends every subscription running on it, and a time already past refuses the connection. Until then the client may subscribe again without the token being verified again: a connection's token cannot change.
388
+
389
+ ```ts
390
+ // End each connection when its token expires
391
+ connectionExpiry: (claims) => (claims.exp === undefined ? undefined : claims.exp * 1000)
392
+
393
+ // A short-lived token (such as a proxy's assertion) that proves a longer session: connections last an hour from when the token was issued
394
+ connectionExpiry: (claims) => (claims.iat === undefined ? undefined : (claims.iat + 3600) * 1000)
395
+ ```
396
+
397
+ With a lifetime longer than the token's, a revoked session is noticed only when the connection ends.
398
+
399
+ graphql-ws clients retry `4403` by default, but a retry succeeds only with a fresh token: pass `connectionParams` to `createClient` as a function that returns a current token, or authenticate the upgrade request afresh (by a cookie and a proxy, for example). A client that sends the same expired token fails again until its `retryAttempts` run out, and its subscriptions then end with an error.
400
+
401
+ `keepAlive` sets the milliseconds between the server's websocket pings (default 12 seconds, graphql-ws's default; `0` turns them off). A connection that has not answered a ping by the time the next one is due is terminated. The pings also stop proxies and load balancers from closing an idle connection, so keep the interval below their idle timeouts.
402
+
385
403
  Example for Apollo Server (`wsServerCleanup` called in the `drainServer` plugin callback):
386
404
 
387
405
  ```ts
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "**"
5
5
  ],
6
6
  "name": "@makerx/graphql-core",
7
- "version": "3.4.0",
7
+ "version": "3.5.0",
8
8
  "private": false,
9
9
  "description": "A set of core GraphQL utilities that MakerX uses to build GraphQL APIs",
10
10
  "author": "MakerX",
@@ -42,7 +42,8 @@
42
42
  }
43
43
  },
44
44
  "overrides": {
45
- "esbuild": "^0.28.1"
45
+ "esbuild": "^0.28.1",
46
+ "@graphql-tools/utils": "12.0.1"
46
47
  },
47
48
  "main": "./index.js",
48
49
  "module": "./index.mjs",
@@ -1,9 +1,9 @@
1
1
  import type { Logger } from '@makerx/node-common';
2
- import type { GraphQLSchema } from 'graphql';
2
+ import { type GraphQLSchema } from 'graphql';
3
3
  import type { Server } from 'http';
4
4
  import type { GraphQLContext, JwtPayload } from '../context';
5
5
  import type { CreateSubscriptionContext, ExtractSubscriptionToken } from './context';
6
- export declare function useSubscriptionsServer<TLogger extends Logger = Logger>({ schema, httpServer, createSubscriptionContext, logger, operationLogLevel, path, verifyToken, extractToken, requireAuth, jwtClaimsToLog, resolveSubscriptionOperationLogger, includeDeprecatedElements, }: {
6
+ export declare function useSubscriptionsServer<TLogger extends Logger = Logger>({ schema, httpServer, createSubscriptionContext, logger, operationLogLevel, path, verifyToken, extractToken, requireAuth, jwtClaimsToLog, resolveSubscriptionOperationLogger, includeDeprecatedElements, connectionExpiry, keepAlive, }: {
7
7
  schema: GraphQLSchema;
8
8
  httpServer: Server;
9
9
  createSubscriptionContext: CreateSubscriptionContext;
@@ -25,4 +25,39 @@ export declare function useSubscriptionsServer<TLogger extends Logger = Logger>(
25
25
  * established. Not collected per emitted payload: the usage is a property of the operation.
26
26
  */
27
27
  includeDeprecatedElements?: boolean;
28
+ /**
29
+ * Returns the time a connection ends, in milliseconds since the epoch, from the claims `verifyToken`
30
+ * returned when it was established. `undefined` keeps the default behaviour below.
31
+ *
32
+ * When it returns a time:
33
+ * - A time already past refuses the connection with `4403 Forbidden`, as an invalid token does.
34
+ * - Otherwise at that time the connection is closed with `4403`, which ends every subscription
35
+ * running on it.
36
+ * - Until then the client may subscribe again on the connection, and the token is not verified
37
+ * again: it was verified when the connection was established, and it cannot change for the life
38
+ * of the connection.
39
+ *
40
+ * To end a connection when its token expires: `(claims) => (claims.exp === undefined ? undefined : claims.exp * 1000)`.
41
+ * When the token is short-lived proof of a longer session, such as an assertion that a proxy adds to
42
+ * the upgrade request, a later time keeps subscriptions from being cut off every few minutes:
43
+ * `(claims) => (claims.iat === undefined ? undefined : (claims.iat + 3600) * 1000)`. A connection then
44
+ * outlives its token, so a revoked session is noticed only when the connection ends.
45
+ *
46
+ * By default a connection has no set end. Its token is verified again each time the client
47
+ * subscribes, and a token that no longer verifies closes the connection with `4403` then.
48
+ * Subscriptions already running continue until that happens.
49
+ *
50
+ * graphql-ws clients retry `4403` by default, but a retry succeeds only with a fresh token: the
51
+ * client's `connectionParams` must be a function that returns a current token, or the upgrade
52
+ * request must be authenticated afresh (by a cookie and a proxy, for example). A client that sends
53
+ * the same expired token fails again until its `retryAttempts` run out, and its subscriptions then
54
+ * end with an error.
55
+ */
56
+ connectionExpiry?: (claims: JwtPayload) => number | undefined;
57
+ /**
58
+ * Milliseconds between the server's websocket pings. A connection that has not answered a ping by
59
+ * the time the next one is due is terminated. `0` turns the pings off. Defaults to graphql-ws's 12
60
+ * seconds.
61
+ */
62
+ keepAlive?: number;
28
63
  }): import("graphql-ws").Disposable;
@@ -1,9 +1,9 @@
1
1
  import type { Logger } from '@makerx/node-common';
2
- import type { GraphQLSchema } from 'graphql';
2
+ import { type GraphQLSchema } from 'graphql';
3
3
  import type { Server } from 'http';
4
4
  import type { GraphQLContext, JwtPayload } from '../context.mjs';
5
5
  import type { CreateSubscriptionContext, ExtractSubscriptionToken } from './context.mjs';
6
- export declare function useSubscriptionsServer<TLogger extends Logger = Logger>({ schema, httpServer, createSubscriptionContext, logger, operationLogLevel, path, verifyToken, extractToken, requireAuth, jwtClaimsToLog, resolveSubscriptionOperationLogger, includeDeprecatedElements, }: {
6
+ export declare function useSubscriptionsServer<TLogger extends Logger = Logger>({ schema, httpServer, createSubscriptionContext, logger, operationLogLevel, path, verifyToken, extractToken, requireAuth, jwtClaimsToLog, resolveSubscriptionOperationLogger, includeDeprecatedElements, connectionExpiry, keepAlive, }: {
7
7
  schema: GraphQLSchema;
8
8
  httpServer: Server;
9
9
  createSubscriptionContext: CreateSubscriptionContext;
@@ -25,4 +25,39 @@ export declare function useSubscriptionsServer<TLogger extends Logger = Logger>(
25
25
  * established. Not collected per emitted payload: the usage is a property of the operation.
26
26
  */
27
27
  includeDeprecatedElements?: boolean;
28
+ /**
29
+ * Returns the time a connection ends, in milliseconds since the epoch, from the claims `verifyToken`
30
+ * returned when it was established. `undefined` keeps the default behaviour below.
31
+ *
32
+ * When it returns a time:
33
+ * - A time already past refuses the connection with `4403 Forbidden`, as an invalid token does.
34
+ * - Otherwise at that time the connection is closed with `4403`, which ends every subscription
35
+ * running on it.
36
+ * - Until then the client may subscribe again on the connection, and the token is not verified
37
+ * again: it was verified when the connection was established, and it cannot change for the life
38
+ * of the connection.
39
+ *
40
+ * To end a connection when its token expires: `(claims) => (claims.exp === undefined ? undefined : claims.exp * 1000)`.
41
+ * When the token is short-lived proof of a longer session, such as an assertion that a proxy adds to
42
+ * the upgrade request, a later time keeps subscriptions from being cut off every few minutes:
43
+ * `(claims) => (claims.iat === undefined ? undefined : (claims.iat + 3600) * 1000)`. A connection then
44
+ * outlives its token, so a revoked session is noticed only when the connection ends.
45
+ *
46
+ * By default a connection has no set end. Its token is verified again each time the client
47
+ * subscribes, and a token that no longer verifies closes the connection with `4403` then.
48
+ * Subscriptions already running continue until that happens.
49
+ *
50
+ * graphql-ws clients retry `4403` by default, but a retry succeeds only with a fresh token: the
51
+ * client's `connectionParams` must be a function that returns a current token, or the upgrade
52
+ * request must be authenticated afresh (by a cookie and a proxy, for example). A client that sends
53
+ * the same expired token fails again until its `retryAttempts` run out, and its subscriptions then
54
+ * end with an error.
55
+ */
56
+ connectionExpiry?: (claims: JwtPayload) => number | undefined;
57
+ /**
58
+ * Milliseconds between the server's websocket pings. A connection that has not answered a ping by
59
+ * the time the next one is due is terminated. `0` turns the pings off. Defaults to graphql-ws's 12
60
+ * seconds.
61
+ */
62
+ keepAlive?: number;
28
63
  }): import("graphql-ws").Disposable;
@@ -1,9 +1,9 @@
1
1
  import type { Logger } from '@makerx/node-common';
2
- import type { GraphQLSchema } from 'graphql';
2
+ import { type GraphQLSchema } from 'graphql';
3
3
  import type { Server } from 'http';
4
4
  import type { GraphQLContext, JwtPayload } from '../context';
5
5
  import type { CreateSubscriptionContext, ExtractSubscriptionToken } from './context';
6
- export declare function useSubscriptionsServer<TLogger extends Logger = Logger>({ schema, httpServer, createSubscriptionContext, logger, operationLogLevel, path, verifyToken, extractToken, requireAuth, jwtClaimsToLog, resolveSubscriptionOperationLogger, includeDeprecatedElements, }: {
6
+ export declare function useSubscriptionsServer<TLogger extends Logger = Logger>({ schema, httpServer, createSubscriptionContext, logger, operationLogLevel, path, verifyToken, extractToken, requireAuth, jwtClaimsToLog, resolveSubscriptionOperationLogger, includeDeprecatedElements, connectionExpiry, keepAlive, }: {
7
7
  schema: GraphQLSchema;
8
8
  httpServer: Server;
9
9
  createSubscriptionContext: CreateSubscriptionContext;
@@ -25,4 +25,39 @@ export declare function useSubscriptionsServer<TLogger extends Logger = Logger>(
25
25
  * established. Not collected per emitted payload: the usage is a property of the operation.
26
26
  */
27
27
  includeDeprecatedElements?: boolean;
28
+ /**
29
+ * Returns the time a connection ends, in milliseconds since the epoch, from the claims `verifyToken`
30
+ * returned when it was established. `undefined` keeps the default behaviour below.
31
+ *
32
+ * When it returns a time:
33
+ * - A time already past refuses the connection with `4403 Forbidden`, as an invalid token does.
34
+ * - Otherwise at that time the connection is closed with `4403`, which ends every subscription
35
+ * running on it.
36
+ * - Until then the client may subscribe again on the connection, and the token is not verified
37
+ * again: it was verified when the connection was established, and it cannot change for the life
38
+ * of the connection.
39
+ *
40
+ * To end a connection when its token expires: `(claims) => (claims.exp === undefined ? undefined : claims.exp * 1000)`.
41
+ * When the token is short-lived proof of a longer session, such as an assertion that a proxy adds to
42
+ * the upgrade request, a later time keeps subscriptions from being cut off every few minutes:
43
+ * `(claims) => (claims.iat === undefined ? undefined : (claims.iat + 3600) * 1000)`. A connection then
44
+ * outlives its token, so a revoked session is noticed only when the connection ends.
45
+ *
46
+ * By default a connection has no set end. Its token is verified again each time the client
47
+ * subscribes, and a token that no longer verifies closes the connection with `4403` then.
48
+ * Subscriptions already running continue until that happens.
49
+ *
50
+ * graphql-ws clients retry `4403` by default, but a retry succeeds only with a fresh token: the
51
+ * client's `connectionParams` must be a function that returns a current token, or the upgrade
52
+ * request must be authenticated afresh (by a cookie and a proxy, for example). A client that sends
53
+ * the same expired token fails again until its `retryAttempts` run out, and its subscriptions then
54
+ * end with an error.
55
+ */
56
+ connectionExpiry?: (claims: JwtPayload) => number | undefined;
57
+ /**
58
+ * Milliseconds between the server's websocket pings. A connection that has not answered a ping by
59
+ * the time the next one is due is terminated. `0` turns the pings off. Defaults to graphql-ws's 12
60
+ * seconds.
61
+ */
62
+ keepAlive?: number;
28
63
  }): import("graphql-ws").Disposable;
@@ -1,23 +1,66 @@
1
1
  'use strict';
2
2
 
3
3
  var compat = require('es-toolkit/compat');
4
+ var graphql = require('graphql');
4
5
  var graphqlWs = require('graphql-ws');
5
6
  var ws$1 = require('graphql-ws/use/ws');
6
7
  var ws = require('ws');
7
8
  var logging = require('../logging.js');
8
9
  var utils = require('./utils.js');
9
10
 
10
- function useSubscriptionsServer({ schema, httpServer, createSubscriptionContext, logger, operationLogLevel = 'info', path = '/graphql', verifyToken, extractToken = utils.defaultExtractSubscriptionToken, requireAuth, jwtClaimsToLog = ['oid', 'iss'], resolveSubscriptionOperationLogger, includeDeprecatedElements, }) {
11
+ // The longest delay setTimeout accepts. A connection that ends later than this is timed in steps.
12
+ const maxTimeoutMs = 2 ** 31 - 1;
13
+ // What onSubscribe returns for an operation it refuses, so onError can tell a refusal from a real error.
14
+ class SubscriptionRefusedError extends graphql.GraphQLError {
15
+ constructor() {
16
+ super('Forbidden', { extensions: { code: 'FORBIDDEN' } });
17
+ }
18
+ }
19
+ function useSubscriptionsServer({ schema, httpServer, createSubscriptionContext, logger, operationLogLevel = 'info', path = '/graphql', verifyToken, extractToken = utils.defaultExtractSubscriptionToken, requireAuth, jwtClaimsToLog = ['oid', 'iss'], resolveSubscriptionOperationLogger, includeDeprecatedElements, connectionExpiry, keepAlive = 12000, }) {
11
20
  if (requireAuth && !verifyToken)
12
21
  throw new Error('verifyToken must be supplied when requireAuth is true');
13
22
  const wsServer = new ws.WebSocketServer({
14
23
  server: httpServer,
15
24
  path,
16
25
  });
26
+ // When each connection ends, for the connections that connectionExpiry gave a time.
27
+ const connectionEnds = new WeakMap();
28
+ const endConnectionAt = (socket, endsAt, claims) => {
29
+ // A client that went away while its token was being verified has already closed the socket.
30
+ if (socket.readyState !== socket.OPEN)
31
+ return;
32
+ connectionEnds.set(socket, endsAt);
33
+ let timer;
34
+ // The socket's own close event, not graphql-ws's onClose, which waits for every subscription on the connection to
35
+ // return. A subscription waiting for its next event can hold that up indefinitely.
36
+ socket.once('close', () => clearTimeout(timer));
37
+ const schedule = () => {
38
+ const remaining = endsAt - Date.now();
39
+ timer = setTimeout(() => {
40
+ if (remaining > maxTimeoutMs)
41
+ return schedule();
42
+ if (socket.readyState !== socket.OPEN)
43
+ return;
44
+ logger.info('Subscription connection expired', { claims: compat.pick(claims, jwtClaimsToLog) });
45
+ socket.close(graphqlWs.CloseCode.Forbidden, 'Forbidden');
46
+ }, Math.min(Math.max(remaining, 0), maxTimeoutMs));
47
+ timer.unref();
48
+ };
49
+ schedule();
50
+ };
51
+ // Closes the connection, and keeps graphql-ws from running the operation: it would run it after the
52
+ // close if onSubscribe returned nothing.
53
+ const refuse = (socket) => {
54
+ socket.close(graphqlWs.CloseCode.Forbidden, 'Forbidden');
55
+ return [new SubscriptionRefusedError()];
56
+ };
17
57
  return ws$1.useServer({
18
58
  schema,
19
- onError(_ctx, message, errors) {
20
- logger.error('GraphQL subscriptions server error', { message, errors });
59
+ onError(_ctx, id, _payload, errors) {
60
+ // A refusal is already logged, and is not a server error.
61
+ if (errors.every((error) => error instanceof SubscriptionRefusedError))
62
+ return;
63
+ logger.error('GraphQL subscriptions server error', { id, errors });
21
64
  },
22
65
  onConnect: async (ctx) => {
23
66
  const connectionEstablished = 'Subscription connection established';
@@ -34,27 +77,53 @@ function useSubscriptionsServer({ schema, httpServer, createSubscriptionContext,
34
77
  logger.info(connectionEstablished);
35
78
  return true;
36
79
  }
80
+ let claims;
37
81
  try {
38
- const claims = await verifyToken(utils.getHost(ctx.extra.request), token);
39
- ctx.extra.claims = claims;
40
- logger.info(connectionEstablished, {
41
- claims: compat.pick(claims, jwtClaimsToLog),
42
- });
43
- return true;
82
+ claims = await verifyToken(utils.getHost(ctx.extra.request), token);
44
83
  }
45
84
  catch (error) {
46
85
  logger.error('Failed to verify subscription connection auth token', { error });
47
86
  return false;
48
87
  }
88
+ let endsAt;
89
+ try {
90
+ endsAt = connectionExpiry?.(claims);
91
+ }
92
+ catch (error) {
93
+ logger.error('Failed to compute subscription connection expiry', { claims: compat.pick(claims, jwtClaimsToLog), error });
94
+ return false;
95
+ }
96
+ // Refused before the connection is acknowledged: an acknowledged connection resets a graphql-ws
97
+ // client's retry count, so closing it at once would have the client reconnect without end.
98
+ if (endsAt !== undefined && !(endsAt > Date.now())) {
99
+ logger.warn('Subscription connection has already expired', { claims: compat.pick(claims, jwtClaimsToLog) });
100
+ return false;
101
+ }
102
+ ctx.extra.claims = claims;
103
+ logger.info(connectionEstablished, {
104
+ claims: compat.pick(claims, jwtClaimsToLog),
105
+ });
106
+ if (endsAt !== undefined)
107
+ endConnectionAt(ctx.extra.socket, endsAt, claims);
108
+ return true;
49
109
  },
50
110
  onSubscribe: async (ctx) => {
51
111
  if (!verifyToken)
52
112
  return;
113
+ const endsAt = connectionEnds.get(ctx.extra.socket);
114
+ if (endsAt !== undefined) {
115
+ // The token stands for the life connectionExpiry gave the connection. This covers an operation that
116
+ // arrives before the timer closes the connection.
117
+ if (Date.now() < endsAt)
118
+ return;
119
+ logger.warn('Subscription connection has expired', { claims: compat.pick(ctx.extra.claims, jwtClaimsToLog) });
120
+ return refuse(ctx.extra.socket);
121
+ }
53
122
  const token = extractToken({ connectRequest: ctx.extra.request, connectionParams: ctx.connectionParams });
54
123
  if (!token) {
55
124
  if (requireAuth) {
56
125
  logger.error('No auth token was supplied with the websocket connection');
57
- ctx.extra.socket.close(graphqlWs.CloseCode.Forbidden, 'Forbidden');
126
+ return refuse(ctx.extra.socket);
58
127
  }
59
128
  return;
60
129
  }
@@ -63,7 +132,7 @@ function useSubscriptionsServer({ schema, httpServer, createSubscriptionContext,
63
132
  }
64
133
  catch (error) {
65
134
  logger.warn('Subscription connection auth token is no longer valid', { claims: compat.pick(ctx.extra.claims, jwtClaimsToLog), error });
66
- ctx.extra.socket.close(graphqlWs.CloseCode.Forbidden, 'Forbidden');
135
+ return refuse(ctx.extra.socket);
67
136
  }
68
137
  },
69
138
  onDisconnect({ extra: { claims } }) {
@@ -88,7 +157,7 @@ function useSubscriptionsServer({ schema, httpServer, createSubscriptionContext,
88
157
  onNext(_ctx, id, _payload, args, { data, ...result }) {
89
158
  logging.logSubscriptionOperation({ id, args, logLevel: operationLogLevel, result, resolveLogger: resolveSubscriptionOperationLogger });
90
159
  },
91
- }, wsServer);
160
+ }, wsServer, keepAlive);
92
161
  }
93
162
 
94
163
  exports.useSubscriptionsServer = useSubscriptionsServer;
@@ -1 +1 @@
1
- {"version":3,"file":"server.js","sources":["../../src/subscriptions/server.ts"],"sourcesContent":["import type { Logger } from '@makerx/node-common'\nimport { pick } from 'es-toolkit/compat'\nimport type { GraphQLSchema } from 'graphql'\nimport { CloseCode } from 'graphql-ws'\nimport { useServer } from 'graphql-ws/use/ws'\nimport type { Server } from 'http'\nimport { WebSocketServer } from 'ws'\nimport type { GraphQLContext, JwtPayload } from '../context'\nimport { logSubscriptionOperation } from '../logging'\nimport type { CreateSubscriptionContext, ExtractSubscriptionToken } from './context'\nimport { defaultExtractSubscriptionToken, getHost } from './utils'\n\nexport function useSubscriptionsServer<TLogger extends Logger = Logger>({\n schema,\n httpServer,\n createSubscriptionContext,\n logger,\n operationLogLevel = 'info',\n path = '/graphql',\n verifyToken,\n extractToken = defaultExtractSubscriptionToken,\n requireAuth,\n jwtClaimsToLog = ['oid', 'iss'],\n resolveSubscriptionOperationLogger,\n includeDeprecatedElements,\n}: {\n schema: GraphQLSchema\n httpServer: Server\n createSubscriptionContext: CreateSubscriptionContext\n logger: TLogger\n operationLogLevel?: keyof TLogger\n path?: string\n verifyToken?: (host: string, token: string) => Promise<JwtPayload>\n /**\n * Returns the token to pass to `verifyToken`, e.g. from a JWT assertion header that a proxy adds\n * to the connect request. Defaults to the bearer token in the `authorization` or `Authorization`\n * connection parameter. Pass the same function to `createSubscriptionContextFactory`.\n */\n extractToken?: ExtractSubscriptionToken\n requireAuth?: boolean\n jwtClaimsToLog?: string[]\n resolveSubscriptionOperationLogger?: (context: GraphQLContext) => TLogger\n /**\n * If true, deprecated schema elements a subscription uses are collected and logged when it is\n * established. Not collected per emitted payload: the usage is a property of the operation.\n */\n includeDeprecatedElements?: boolean\n}) {\n if (requireAuth && !verifyToken) throw new Error('verifyToken must be supplied when requireAuth is true')\n\n const wsServer = new WebSocketServer({\n server: httpServer,\n path,\n })\n\n return useServer(\n {\n schema,\n onError(_ctx, message, errors) {\n logger.error('GraphQL subscriptions server error', { message, errors })\n },\n onConnect: async (ctx) => {\n const connectionEstablished = 'Subscription connection established'\n if (!verifyToken) {\n logger.info(connectionEstablished)\n return true\n }\n\n const token = extractToken({ connectRequest: ctx.extra.request, connectionParams: ctx.connectionParams })\n if (!token) {\n if (requireAuth) {\n logger.error('No auth token was supplied with the websocket connection')\n return false\n }\n logger.info(connectionEstablished)\n return true\n }\n\n try {\n const claims = await verifyToken(getHost(ctx.extra.request), token)\n ctx.extra.claims = claims as unknown as undefined\n logger.info(connectionEstablished, {\n claims: pick(claims, jwtClaimsToLog),\n })\n return true\n } catch (error) {\n logger.error('Failed to verify subscription connection auth token', { error })\n return false\n }\n },\n onSubscribe: async (ctx) => {\n if (!verifyToken) return\n const token = extractToken({ connectRequest: ctx.extra.request, connectionParams: ctx.connectionParams })\n if (!token) {\n if (requireAuth) {\n logger.error('No auth token was supplied with the websocket connection')\n ctx.extra.socket.close(CloseCode.Forbidden, 'Forbidden')\n }\n return\n }\n try {\n await verifyToken(getHost(ctx.extra.request), token)\n } catch (error) {\n logger.warn('Subscription connection auth token is no longer valid', { claims: pick(ctx.extra.claims, jwtClaimsToLog), error })\n ctx.extra.socket.close(CloseCode.Forbidden, 'Forbidden')\n }\n },\n onDisconnect({ extra: { claims } }) {\n logger.info('Subscription connection disconnected', { claims: pick(claims, jwtClaimsToLog) })\n },\n context: async (ctx) => {\n return createSubscriptionContext({\n connectRequest: ctx.extra.request,\n connectionParams: ctx.connectionParams,\n claims: ctx.extra.claims as JwtPayload | undefined,\n })\n },\n onOperation(_ctx, id, _payload, args) {\n logSubscriptionOperation({\n id,\n args,\n logLevel: operationLogLevel,\n resolveLogger: resolveSubscriptionOperationLogger,\n includeDeprecatedElements,\n })\n },\n onNext(_ctx, id, _payload, args, { data, ...result }) {\n logSubscriptionOperation({ id, args, logLevel: operationLogLevel, result, resolveLogger: resolveSubscriptionOperationLogger })\n },\n },\n wsServer,\n )\n}\n"],"names":["defaultExtractSubscriptionToken","WebSocketServer","useServer","getHost","pick","CloseCode","logSubscriptionOperation"],"mappings":"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"}
1
+ {"version":3,"file":"server.js","sources":["../../src/subscriptions/server.ts"],"sourcesContent":["import type { Logger } from '@makerx/node-common'\nimport { pick } from 'es-toolkit/compat'\nimport { GraphQLError, type GraphQLSchema } from 'graphql'\nimport { CloseCode } from 'graphql-ws'\nimport { useServer } from 'graphql-ws/use/ws'\nimport type { Server } from 'http'\nimport { WebSocketServer, type WebSocket } from 'ws'\nimport type { GraphQLContext, JwtPayload } from '../context'\nimport { logSubscriptionOperation } from '../logging'\nimport type { CreateSubscriptionContext, ExtractSubscriptionToken } from './context'\nimport { defaultExtractSubscriptionToken, getHost } from './utils'\n\n// The longest delay setTimeout accepts. A connection that ends later than this is timed in steps.\nconst maxTimeoutMs = 2 ** 31 - 1\n\n// What onSubscribe returns for an operation it refuses, so onError can tell a refusal from a real error.\nclass SubscriptionRefusedError extends GraphQLError {\n constructor() {\n super('Forbidden', { extensions: { code: 'FORBIDDEN' } })\n }\n}\n\nexport function useSubscriptionsServer<TLogger extends Logger = Logger>({\n schema,\n httpServer,\n createSubscriptionContext,\n logger,\n operationLogLevel = 'info',\n path = '/graphql',\n verifyToken,\n extractToken = defaultExtractSubscriptionToken,\n requireAuth,\n jwtClaimsToLog = ['oid', 'iss'],\n resolveSubscriptionOperationLogger,\n includeDeprecatedElements,\n connectionExpiry,\n keepAlive = 12_000,\n}: {\n schema: GraphQLSchema\n httpServer: Server\n createSubscriptionContext: CreateSubscriptionContext\n logger: TLogger\n operationLogLevel?: keyof TLogger\n path?: string\n verifyToken?: (host: string, token: string) => Promise<JwtPayload>\n /**\n * Returns the token to pass to `verifyToken`, e.g. from a JWT assertion header that a proxy adds\n * to the connect request. Defaults to the bearer token in the `authorization` or `Authorization`\n * connection parameter. Pass the same function to `createSubscriptionContextFactory`.\n */\n extractToken?: ExtractSubscriptionToken\n requireAuth?: boolean\n jwtClaimsToLog?: string[]\n resolveSubscriptionOperationLogger?: (context: GraphQLContext) => TLogger\n /**\n * If true, deprecated schema elements a subscription uses are collected and logged when it is\n * established. Not collected per emitted payload: the usage is a property of the operation.\n */\n includeDeprecatedElements?: boolean\n /**\n * Returns the time a connection ends, in milliseconds since the epoch, from the claims `verifyToken`\n * returned when it was established. `undefined` keeps the default behaviour below.\n *\n * When it returns a time:\n * - A time already past refuses the connection with `4403 Forbidden`, as an invalid token does.\n * - Otherwise at that time the connection is closed with `4403`, which ends every subscription\n * running on it.\n * - Until then the client may subscribe again on the connection, and the token is not verified\n * again: it was verified when the connection was established, and it cannot change for the life\n * of the connection.\n *\n * To end a connection when its token expires: `(claims) => (claims.exp === undefined ? undefined : claims.exp * 1000)`.\n * When the token is short-lived proof of a longer session, such as an assertion that a proxy adds to\n * the upgrade request, a later time keeps subscriptions from being cut off every few minutes:\n * `(claims) => (claims.iat === undefined ? undefined : (claims.iat + 3600) * 1000)`. A connection then\n * outlives its token, so a revoked session is noticed only when the connection ends.\n *\n * By default a connection has no set end. Its token is verified again each time the client\n * subscribes, and a token that no longer verifies closes the connection with `4403` then.\n * Subscriptions already running continue until that happens.\n *\n * graphql-ws clients retry `4403` by default, but a retry succeeds only with a fresh token: the\n * client's `connectionParams` must be a function that returns a current token, or the upgrade\n * request must be authenticated afresh (by a cookie and a proxy, for example). A client that sends\n * the same expired token fails again until its `retryAttempts` run out, and its subscriptions then\n * end with an error.\n */\n connectionExpiry?: (claims: JwtPayload) => number | undefined\n /**\n * Milliseconds between the server's websocket pings. A connection that has not answered a ping by\n * the time the next one is due is terminated. `0` turns the pings off. Defaults to graphql-ws's 12\n * seconds.\n */\n keepAlive?: number\n}) {\n if (requireAuth && !verifyToken) throw new Error('verifyToken must be supplied when requireAuth is true')\n\n const wsServer = new WebSocketServer({\n server: httpServer,\n path,\n })\n\n // When each connection ends, for the connections that connectionExpiry gave a time.\n const connectionEnds = new WeakMap<WebSocket, number>()\n\n const endConnectionAt = (socket: WebSocket, endsAt: number, claims: JwtPayload) => {\n // A client that went away while its token was being verified has already closed the socket.\n if (socket.readyState !== socket.OPEN) return\n connectionEnds.set(socket, endsAt)\n let timer: NodeJS.Timeout | undefined\n // The socket's own close event, not graphql-ws's onClose, which waits for every subscription on the connection to\n // return. A subscription waiting for its next event can hold that up indefinitely.\n socket.once('close', () => clearTimeout(timer))\n const schedule = () => {\n const remaining = endsAt - Date.now()\n timer = setTimeout(\n () => {\n if (remaining > maxTimeoutMs) return schedule()\n if (socket.readyState !== socket.OPEN) return\n logger.info('Subscription connection expired', { claims: pick(claims, jwtClaimsToLog) })\n socket.close(CloseCode.Forbidden, 'Forbidden')\n },\n Math.min(Math.max(remaining, 0), maxTimeoutMs),\n )\n timer.unref()\n }\n schedule()\n }\n\n // Closes the connection, and keeps graphql-ws from running the operation: it would run it after the\n // close if onSubscribe returned nothing.\n const refuse = (socket: WebSocket) => {\n socket.close(CloseCode.Forbidden, 'Forbidden')\n return [new SubscriptionRefusedError()]\n }\n\n return useServer(\n {\n schema,\n onError(_ctx, id, _payload, errors) {\n // A refusal is already logged, and is not a server error.\n if (errors.every((error) => error instanceof SubscriptionRefusedError)) return\n logger.error('GraphQL subscriptions server error', { id, errors })\n },\n onConnect: async (ctx) => {\n const connectionEstablished = 'Subscription connection established'\n if (!verifyToken) {\n logger.info(connectionEstablished)\n return true\n }\n\n const token = extractToken({ connectRequest: ctx.extra.request, connectionParams: ctx.connectionParams })\n if (!token) {\n if (requireAuth) {\n logger.error('No auth token was supplied with the websocket connection')\n return false\n }\n logger.info(connectionEstablished)\n return true\n }\n\n let claims: JwtPayload\n try {\n claims = await verifyToken(getHost(ctx.extra.request), token)\n } catch (error) {\n logger.error('Failed to verify subscription connection auth token', { error })\n return false\n }\n\n let endsAt: number | undefined\n try {\n endsAt = connectionExpiry?.(claims)\n } catch (error) {\n logger.error('Failed to compute subscription connection expiry', { claims: pick(claims, jwtClaimsToLog), error })\n return false\n }\n // Refused before the connection is acknowledged: an acknowledged connection resets a graphql-ws\n // client's retry count, so closing it at once would have the client reconnect without end.\n if (endsAt !== undefined && !(endsAt > Date.now())) {\n logger.warn('Subscription connection has already expired', { claims: pick(claims, jwtClaimsToLog) })\n return false\n }\n\n ctx.extra.claims = claims as unknown as undefined\n logger.info(connectionEstablished, {\n claims: pick(claims, jwtClaimsToLog),\n })\n if (endsAt !== undefined) endConnectionAt(ctx.extra.socket, endsAt, claims)\n return true\n },\n onSubscribe: async (ctx) => {\n if (!verifyToken) return\n const endsAt = connectionEnds.get(ctx.extra.socket)\n if (endsAt !== undefined) {\n // The token stands for the life connectionExpiry gave the connection. This covers an operation that\n // arrives before the timer closes the connection.\n if (Date.now() < endsAt) return\n logger.warn('Subscription connection has expired', { claims: pick(ctx.extra.claims, jwtClaimsToLog) })\n return refuse(ctx.extra.socket)\n }\n const token = extractToken({ connectRequest: ctx.extra.request, connectionParams: ctx.connectionParams })\n if (!token) {\n if (requireAuth) {\n logger.error('No auth token was supplied with the websocket connection')\n return refuse(ctx.extra.socket)\n }\n return\n }\n try {\n await verifyToken(getHost(ctx.extra.request), token)\n } catch (error) {\n logger.warn('Subscription connection auth token is no longer valid', { claims: pick(ctx.extra.claims, jwtClaimsToLog), error })\n return refuse(ctx.extra.socket)\n }\n },\n onDisconnect({ extra: { claims } }) {\n logger.info('Subscription connection disconnected', { claims: pick(claims, jwtClaimsToLog) })\n },\n context: async (ctx) => {\n return createSubscriptionContext({\n connectRequest: ctx.extra.request,\n connectionParams: ctx.connectionParams,\n claims: ctx.extra.claims as JwtPayload | undefined,\n })\n },\n onOperation(_ctx, id, _payload, args) {\n logSubscriptionOperation({\n id,\n args,\n logLevel: operationLogLevel,\n resolveLogger: resolveSubscriptionOperationLogger,\n includeDeprecatedElements,\n })\n },\n onNext(_ctx, id, _payload, args, { data, ...result }) {\n logSubscriptionOperation({ id, args, logLevel: operationLogLevel, result, resolveLogger: resolveSubscriptionOperationLogger })\n },\n },\n wsServer,\n keepAlive,\n )\n}\n"],"names":["GraphQLError","defaultExtractSubscriptionToken","WebSocketServer","pick","CloseCode","useServer","getHost","logSubscriptionOperation"],"mappings":"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"}
@@ -1,21 +1,64 @@
1
1
  import { pick } from 'es-toolkit/compat';
2
+ import { GraphQLError } from 'graphql';
2
3
  import { CloseCode } from 'graphql-ws';
3
4
  import { useServer } from 'graphql-ws/use/ws';
4
5
  import { WebSocketServer } from 'ws';
5
6
  import { logSubscriptionOperation } from '../logging.mjs';
6
7
  import { defaultExtractSubscriptionToken, getHost } from './utils.mjs';
7
8
 
8
- function useSubscriptionsServer({ schema, httpServer, createSubscriptionContext, logger, operationLogLevel = 'info', path = '/graphql', verifyToken, extractToken = defaultExtractSubscriptionToken, requireAuth, jwtClaimsToLog = ['oid', 'iss'], resolveSubscriptionOperationLogger, includeDeprecatedElements, }) {
9
+ // The longest delay setTimeout accepts. A connection that ends later than this is timed in steps.
10
+ const maxTimeoutMs = 2 ** 31 - 1;
11
+ // What onSubscribe returns for an operation it refuses, so onError can tell a refusal from a real error.
12
+ class SubscriptionRefusedError extends GraphQLError {
13
+ constructor() {
14
+ super('Forbidden', { extensions: { code: 'FORBIDDEN' } });
15
+ }
16
+ }
17
+ function useSubscriptionsServer({ schema, httpServer, createSubscriptionContext, logger, operationLogLevel = 'info', path = '/graphql', verifyToken, extractToken = defaultExtractSubscriptionToken, requireAuth, jwtClaimsToLog = ['oid', 'iss'], resolveSubscriptionOperationLogger, includeDeprecatedElements, connectionExpiry, keepAlive = 12000, }) {
9
18
  if (requireAuth && !verifyToken)
10
19
  throw new Error('verifyToken must be supplied when requireAuth is true');
11
20
  const wsServer = new WebSocketServer({
12
21
  server: httpServer,
13
22
  path,
14
23
  });
24
+ // When each connection ends, for the connections that connectionExpiry gave a time.
25
+ const connectionEnds = new WeakMap();
26
+ const endConnectionAt = (socket, endsAt, claims) => {
27
+ // A client that went away while its token was being verified has already closed the socket.
28
+ if (socket.readyState !== socket.OPEN)
29
+ return;
30
+ connectionEnds.set(socket, endsAt);
31
+ let timer;
32
+ // The socket's own close event, not graphql-ws's onClose, which waits for every subscription on the connection to
33
+ // return. A subscription waiting for its next event can hold that up indefinitely.
34
+ socket.once('close', () => clearTimeout(timer));
35
+ const schedule = () => {
36
+ const remaining = endsAt - Date.now();
37
+ timer = setTimeout(() => {
38
+ if (remaining > maxTimeoutMs)
39
+ return schedule();
40
+ if (socket.readyState !== socket.OPEN)
41
+ return;
42
+ logger.info('Subscription connection expired', { claims: pick(claims, jwtClaimsToLog) });
43
+ socket.close(CloseCode.Forbidden, 'Forbidden');
44
+ }, Math.min(Math.max(remaining, 0), maxTimeoutMs));
45
+ timer.unref();
46
+ };
47
+ schedule();
48
+ };
49
+ // Closes the connection, and keeps graphql-ws from running the operation: it would run it after the
50
+ // close if onSubscribe returned nothing.
51
+ const refuse = (socket) => {
52
+ socket.close(CloseCode.Forbidden, 'Forbidden');
53
+ return [new SubscriptionRefusedError()];
54
+ };
15
55
  return useServer({
16
56
  schema,
17
- onError(_ctx, message, errors) {
18
- logger.error('GraphQL subscriptions server error', { message, errors });
57
+ onError(_ctx, id, _payload, errors) {
58
+ // A refusal is already logged, and is not a server error.
59
+ if (errors.every((error) => error instanceof SubscriptionRefusedError))
60
+ return;
61
+ logger.error('GraphQL subscriptions server error', { id, errors });
19
62
  },
20
63
  onConnect: async (ctx) => {
21
64
  const connectionEstablished = 'Subscription connection established';
@@ -32,27 +75,53 @@ function useSubscriptionsServer({ schema, httpServer, createSubscriptionContext,
32
75
  logger.info(connectionEstablished);
33
76
  return true;
34
77
  }
78
+ let claims;
35
79
  try {
36
- const claims = await verifyToken(getHost(ctx.extra.request), token);
37
- ctx.extra.claims = claims;
38
- logger.info(connectionEstablished, {
39
- claims: pick(claims, jwtClaimsToLog),
40
- });
41
- return true;
80
+ claims = await verifyToken(getHost(ctx.extra.request), token);
42
81
  }
43
82
  catch (error) {
44
83
  logger.error('Failed to verify subscription connection auth token', { error });
45
84
  return false;
46
85
  }
86
+ let endsAt;
87
+ try {
88
+ endsAt = connectionExpiry?.(claims);
89
+ }
90
+ catch (error) {
91
+ logger.error('Failed to compute subscription connection expiry', { claims: pick(claims, jwtClaimsToLog), error });
92
+ return false;
93
+ }
94
+ // Refused before the connection is acknowledged: an acknowledged connection resets a graphql-ws
95
+ // client's retry count, so closing it at once would have the client reconnect without end.
96
+ if (endsAt !== undefined && !(endsAt > Date.now())) {
97
+ logger.warn('Subscription connection has already expired', { claims: pick(claims, jwtClaimsToLog) });
98
+ return false;
99
+ }
100
+ ctx.extra.claims = claims;
101
+ logger.info(connectionEstablished, {
102
+ claims: pick(claims, jwtClaimsToLog),
103
+ });
104
+ if (endsAt !== undefined)
105
+ endConnectionAt(ctx.extra.socket, endsAt, claims);
106
+ return true;
47
107
  },
48
108
  onSubscribe: async (ctx) => {
49
109
  if (!verifyToken)
50
110
  return;
111
+ const endsAt = connectionEnds.get(ctx.extra.socket);
112
+ if (endsAt !== undefined) {
113
+ // The token stands for the life connectionExpiry gave the connection. This covers an operation that
114
+ // arrives before the timer closes the connection.
115
+ if (Date.now() < endsAt)
116
+ return;
117
+ logger.warn('Subscription connection has expired', { claims: pick(ctx.extra.claims, jwtClaimsToLog) });
118
+ return refuse(ctx.extra.socket);
119
+ }
51
120
  const token = extractToken({ connectRequest: ctx.extra.request, connectionParams: ctx.connectionParams });
52
121
  if (!token) {
53
122
  if (requireAuth) {
54
123
  logger.error('No auth token was supplied with the websocket connection');
55
- ctx.extra.socket.close(CloseCode.Forbidden, 'Forbidden');
124
+ return refuse(ctx.extra.socket);
56
125
  }
57
126
  return;
58
127
  }
@@ -61,7 +130,7 @@ function useSubscriptionsServer({ schema, httpServer, createSubscriptionContext,
61
130
  }
62
131
  catch (error) {
63
132
  logger.warn('Subscription connection auth token is no longer valid', { claims: pick(ctx.extra.claims, jwtClaimsToLog), error });
64
- ctx.extra.socket.close(CloseCode.Forbidden, 'Forbidden');
133
+ return refuse(ctx.extra.socket);
65
134
  }
66
135
  },
67
136
  onDisconnect({ extra: { claims } }) {
@@ -86,7 +155,7 @@ function useSubscriptionsServer({ schema, httpServer, createSubscriptionContext,
86
155
  onNext(_ctx, id, _payload, args, { data, ...result }) {
87
156
  logSubscriptionOperation({ id, args, logLevel: operationLogLevel, result, resolveLogger: resolveSubscriptionOperationLogger });
88
157
  },
89
- }, wsServer);
158
+ }, wsServer, keepAlive);
90
159
  }
91
160
 
92
161
  export { useSubscriptionsServer };
@@ -1 +1 @@
1
- {"version":3,"file":"server.mjs","sources":["../../src/subscriptions/server.ts"],"sourcesContent":["import type { Logger } from '@makerx/node-common'\nimport { pick } from 'es-toolkit/compat'\nimport type { GraphQLSchema } from 'graphql'\nimport { CloseCode } from 'graphql-ws'\nimport { useServer } from 'graphql-ws/use/ws'\nimport type { Server } from 'http'\nimport { WebSocketServer } from 'ws'\nimport type { GraphQLContext, JwtPayload } from '../context'\nimport { logSubscriptionOperation } from '../logging'\nimport type { CreateSubscriptionContext, ExtractSubscriptionToken } from './context'\nimport { defaultExtractSubscriptionToken, getHost } from './utils'\n\nexport function useSubscriptionsServer<TLogger extends Logger = Logger>({\n schema,\n httpServer,\n createSubscriptionContext,\n logger,\n operationLogLevel = 'info',\n path = '/graphql',\n verifyToken,\n extractToken = defaultExtractSubscriptionToken,\n requireAuth,\n jwtClaimsToLog = ['oid', 'iss'],\n resolveSubscriptionOperationLogger,\n includeDeprecatedElements,\n}: {\n schema: GraphQLSchema\n httpServer: Server\n createSubscriptionContext: CreateSubscriptionContext\n logger: TLogger\n operationLogLevel?: keyof TLogger\n path?: string\n verifyToken?: (host: string, token: string) => Promise<JwtPayload>\n /**\n * Returns the token to pass to `verifyToken`, e.g. from a JWT assertion header that a proxy adds\n * to the connect request. Defaults to the bearer token in the `authorization` or `Authorization`\n * connection parameter. Pass the same function to `createSubscriptionContextFactory`.\n */\n extractToken?: ExtractSubscriptionToken\n requireAuth?: boolean\n jwtClaimsToLog?: string[]\n resolveSubscriptionOperationLogger?: (context: GraphQLContext) => TLogger\n /**\n * If true, deprecated schema elements a subscription uses are collected and logged when it is\n * established. Not collected per emitted payload: the usage is a property of the operation.\n */\n includeDeprecatedElements?: boolean\n}) {\n if (requireAuth && !verifyToken) throw new Error('verifyToken must be supplied when requireAuth is true')\n\n const wsServer = new WebSocketServer({\n server: httpServer,\n path,\n })\n\n return useServer(\n {\n schema,\n onError(_ctx, message, errors) {\n logger.error('GraphQL subscriptions server error', { message, errors })\n },\n onConnect: async (ctx) => {\n const connectionEstablished = 'Subscription connection established'\n if (!verifyToken) {\n logger.info(connectionEstablished)\n return true\n }\n\n const token = extractToken({ connectRequest: ctx.extra.request, connectionParams: ctx.connectionParams })\n if (!token) {\n if (requireAuth) {\n logger.error('No auth token was supplied with the websocket connection')\n return false\n }\n logger.info(connectionEstablished)\n return true\n }\n\n try {\n const claims = await verifyToken(getHost(ctx.extra.request), token)\n ctx.extra.claims = claims as unknown as undefined\n logger.info(connectionEstablished, {\n claims: pick(claims, jwtClaimsToLog),\n })\n return true\n } catch (error) {\n logger.error('Failed to verify subscription connection auth token', { error })\n return false\n }\n },\n onSubscribe: async (ctx) => {\n if (!verifyToken) return\n const token = extractToken({ connectRequest: ctx.extra.request, connectionParams: ctx.connectionParams })\n if (!token) {\n if (requireAuth) {\n logger.error('No auth token was supplied with the websocket connection')\n ctx.extra.socket.close(CloseCode.Forbidden, 'Forbidden')\n }\n return\n }\n try {\n await verifyToken(getHost(ctx.extra.request), token)\n } catch (error) {\n logger.warn('Subscription connection auth token is no longer valid', { claims: pick(ctx.extra.claims, jwtClaimsToLog), error })\n ctx.extra.socket.close(CloseCode.Forbidden, 'Forbidden')\n }\n },\n onDisconnect({ extra: { claims } }) {\n logger.info('Subscription connection disconnected', { claims: pick(claims, jwtClaimsToLog) })\n },\n context: async (ctx) => {\n return createSubscriptionContext({\n connectRequest: ctx.extra.request,\n connectionParams: ctx.connectionParams,\n claims: ctx.extra.claims as JwtPayload | undefined,\n })\n },\n onOperation(_ctx, id, _payload, args) {\n logSubscriptionOperation({\n id,\n args,\n logLevel: operationLogLevel,\n resolveLogger: resolveSubscriptionOperationLogger,\n includeDeprecatedElements,\n })\n },\n onNext(_ctx, id, _payload, args, { data, ...result }) {\n logSubscriptionOperation({ id, args, logLevel: operationLogLevel, result, resolveLogger: resolveSubscriptionOperationLogger })\n },\n },\n wsServer,\n )\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"server.mjs","sources":["../../src/subscriptions/server.ts"],"sourcesContent":["import type { Logger } from '@makerx/node-common'\nimport { pick } from 'es-toolkit/compat'\nimport { GraphQLError, type GraphQLSchema } from 'graphql'\nimport { CloseCode } from 'graphql-ws'\nimport { useServer } from 'graphql-ws/use/ws'\nimport type { Server } from 'http'\nimport { WebSocketServer, type WebSocket } from 'ws'\nimport type { GraphQLContext, JwtPayload } from '../context'\nimport { logSubscriptionOperation } from '../logging'\nimport type { CreateSubscriptionContext, ExtractSubscriptionToken } from './context'\nimport { defaultExtractSubscriptionToken, getHost } from './utils'\n\n// The longest delay setTimeout accepts. A connection that ends later than this is timed in steps.\nconst maxTimeoutMs = 2 ** 31 - 1\n\n// What onSubscribe returns for an operation it refuses, so onError can tell a refusal from a real error.\nclass SubscriptionRefusedError extends GraphQLError {\n constructor() {\n super('Forbidden', { extensions: { code: 'FORBIDDEN' } })\n }\n}\n\nexport function useSubscriptionsServer<TLogger extends Logger = Logger>({\n schema,\n httpServer,\n createSubscriptionContext,\n logger,\n operationLogLevel = 'info',\n path = '/graphql',\n verifyToken,\n extractToken = defaultExtractSubscriptionToken,\n requireAuth,\n jwtClaimsToLog = ['oid', 'iss'],\n resolveSubscriptionOperationLogger,\n includeDeprecatedElements,\n connectionExpiry,\n keepAlive = 12_000,\n}: {\n schema: GraphQLSchema\n httpServer: Server\n createSubscriptionContext: CreateSubscriptionContext\n logger: TLogger\n operationLogLevel?: keyof TLogger\n path?: string\n verifyToken?: (host: string, token: string) => Promise<JwtPayload>\n /**\n * Returns the token to pass to `verifyToken`, e.g. from a JWT assertion header that a proxy adds\n * to the connect request. Defaults to the bearer token in the `authorization` or `Authorization`\n * connection parameter. Pass the same function to `createSubscriptionContextFactory`.\n */\n extractToken?: ExtractSubscriptionToken\n requireAuth?: boolean\n jwtClaimsToLog?: string[]\n resolveSubscriptionOperationLogger?: (context: GraphQLContext) => TLogger\n /**\n * If true, deprecated schema elements a subscription uses are collected and logged when it is\n * established. Not collected per emitted payload: the usage is a property of the operation.\n */\n includeDeprecatedElements?: boolean\n /**\n * Returns the time a connection ends, in milliseconds since the epoch, from the claims `verifyToken`\n * returned when it was established. `undefined` keeps the default behaviour below.\n *\n * When it returns a time:\n * - A time already past refuses the connection with `4403 Forbidden`, as an invalid token does.\n * - Otherwise at that time the connection is closed with `4403`, which ends every subscription\n * running on it.\n * - Until then the client may subscribe again on the connection, and the token is not verified\n * again: it was verified when the connection was established, and it cannot change for the life\n * of the connection.\n *\n * To end a connection when its token expires: `(claims) => (claims.exp === undefined ? undefined : claims.exp * 1000)`.\n * When the token is short-lived proof of a longer session, such as an assertion that a proxy adds to\n * the upgrade request, a later time keeps subscriptions from being cut off every few minutes:\n * `(claims) => (claims.iat === undefined ? undefined : (claims.iat + 3600) * 1000)`. A connection then\n * outlives its token, so a revoked session is noticed only when the connection ends.\n *\n * By default a connection has no set end. Its token is verified again each time the client\n * subscribes, and a token that no longer verifies closes the connection with `4403` then.\n * Subscriptions already running continue until that happens.\n *\n * graphql-ws clients retry `4403` by default, but a retry succeeds only with a fresh token: the\n * client's `connectionParams` must be a function that returns a current token, or the upgrade\n * request must be authenticated afresh (by a cookie and a proxy, for example). A client that sends\n * the same expired token fails again until its `retryAttempts` run out, and its subscriptions then\n * end with an error.\n */\n connectionExpiry?: (claims: JwtPayload) => number | undefined\n /**\n * Milliseconds between the server's websocket pings. A connection that has not answered a ping by\n * the time the next one is due is terminated. `0` turns the pings off. Defaults to graphql-ws's 12\n * seconds.\n */\n keepAlive?: number\n}) {\n if (requireAuth && !verifyToken) throw new Error('verifyToken must be supplied when requireAuth is true')\n\n const wsServer = new WebSocketServer({\n server: httpServer,\n path,\n })\n\n // When each connection ends, for the connections that connectionExpiry gave a time.\n const connectionEnds = new WeakMap<WebSocket, number>()\n\n const endConnectionAt = (socket: WebSocket, endsAt: number, claims: JwtPayload) => {\n // A client that went away while its token was being verified has already closed the socket.\n if (socket.readyState !== socket.OPEN) return\n connectionEnds.set(socket, endsAt)\n let timer: NodeJS.Timeout | undefined\n // The socket's own close event, not graphql-ws's onClose, which waits for every subscription on the connection to\n // return. A subscription waiting for its next event can hold that up indefinitely.\n socket.once('close', () => clearTimeout(timer))\n const schedule = () => {\n const remaining = endsAt - Date.now()\n timer = setTimeout(\n () => {\n if (remaining > maxTimeoutMs) return schedule()\n if (socket.readyState !== socket.OPEN) return\n logger.info('Subscription connection expired', { claims: pick(claims, jwtClaimsToLog) })\n socket.close(CloseCode.Forbidden, 'Forbidden')\n },\n Math.min(Math.max(remaining, 0), maxTimeoutMs),\n )\n timer.unref()\n }\n schedule()\n }\n\n // Closes the connection, and keeps graphql-ws from running the operation: it would run it after the\n // close if onSubscribe returned nothing.\n const refuse = (socket: WebSocket) => {\n socket.close(CloseCode.Forbidden, 'Forbidden')\n return [new SubscriptionRefusedError()]\n }\n\n return useServer(\n {\n schema,\n onError(_ctx, id, _payload, errors) {\n // A refusal is already logged, and is not a server error.\n if (errors.every((error) => error instanceof SubscriptionRefusedError)) return\n logger.error('GraphQL subscriptions server error', { id, errors })\n },\n onConnect: async (ctx) => {\n const connectionEstablished = 'Subscription connection established'\n if (!verifyToken) {\n logger.info(connectionEstablished)\n return true\n }\n\n const token = extractToken({ connectRequest: ctx.extra.request, connectionParams: ctx.connectionParams })\n if (!token) {\n if (requireAuth) {\n logger.error('No auth token was supplied with the websocket connection')\n return false\n }\n logger.info(connectionEstablished)\n return true\n }\n\n let claims: JwtPayload\n try {\n claims = await verifyToken(getHost(ctx.extra.request), token)\n } catch (error) {\n logger.error('Failed to verify subscription connection auth token', { error })\n return false\n }\n\n let endsAt: number | undefined\n try {\n endsAt = connectionExpiry?.(claims)\n } catch (error) {\n logger.error('Failed to compute subscription connection expiry', { claims: pick(claims, jwtClaimsToLog), error })\n return false\n }\n // Refused before the connection is acknowledged: an acknowledged connection resets a graphql-ws\n // client's retry count, so closing it at once would have the client reconnect without end.\n if (endsAt !== undefined && !(endsAt > Date.now())) {\n logger.warn('Subscription connection has already expired', { claims: pick(claims, jwtClaimsToLog) })\n return false\n }\n\n ctx.extra.claims = claims as unknown as undefined\n logger.info(connectionEstablished, {\n claims: pick(claims, jwtClaimsToLog),\n })\n if (endsAt !== undefined) endConnectionAt(ctx.extra.socket, endsAt, claims)\n return true\n },\n onSubscribe: async (ctx) => {\n if (!verifyToken) return\n const endsAt = connectionEnds.get(ctx.extra.socket)\n if (endsAt !== undefined) {\n // The token stands for the life connectionExpiry gave the connection. This covers an operation that\n // arrives before the timer closes the connection.\n if (Date.now() < endsAt) return\n logger.warn('Subscription connection has expired', { claims: pick(ctx.extra.claims, jwtClaimsToLog) })\n return refuse(ctx.extra.socket)\n }\n const token = extractToken({ connectRequest: ctx.extra.request, connectionParams: ctx.connectionParams })\n if (!token) {\n if (requireAuth) {\n logger.error('No auth token was supplied with the websocket connection')\n return refuse(ctx.extra.socket)\n }\n return\n }\n try {\n await verifyToken(getHost(ctx.extra.request), token)\n } catch (error) {\n logger.warn('Subscription connection auth token is no longer valid', { claims: pick(ctx.extra.claims, jwtClaimsToLog), error })\n return refuse(ctx.extra.socket)\n }\n },\n onDisconnect({ extra: { claims } }) {\n logger.info('Subscription connection disconnected', { claims: pick(claims, jwtClaimsToLog) })\n },\n context: async (ctx) => {\n return createSubscriptionContext({\n connectRequest: ctx.extra.request,\n connectionParams: ctx.connectionParams,\n claims: ctx.extra.claims as JwtPayload | undefined,\n })\n },\n onOperation(_ctx, id, _payload, args) {\n logSubscriptionOperation({\n id,\n args,\n logLevel: operationLogLevel,\n resolveLogger: resolveSubscriptionOperationLogger,\n includeDeprecatedElements,\n })\n },\n onNext(_ctx, id, _payload, args, { data, ...result }) {\n logSubscriptionOperation({ id, args, logLevel: operationLogLevel, result, resolveLogger: resolveSubscriptionOperationLogger })\n },\n },\n wsServer,\n keepAlive,\n )\n}\n"],"names":[],"mappings":";;;;;;;;AAYA;AACA,MAAM,YAAY,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC;AAEhC;AACA,MAAM,wBAAyB,SAAQ,YAAY,CAAA;AACjD,IAAA,WAAA,GAAA;AACE,QAAA,KAAK,CAAC,WAAW,EAAE,EAAE,UAAU,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE,CAAC;IAC3D;AACD;AAEK,SAAU,sBAAsB,CAAkC,EACtE,MAAM,EACN,UAAU,EACV,yBAAyB,EACzB,MAAM,EACN,iBAAiB,GAAG,MAAM,EAC1B,IAAI,GAAG,UAAU,EACjB,WAAW,EACX,YAAY,GAAG,+BAA+B,EAC9C,WAAW,EACX,cAAc,GAAG,CAAC,KAAK,EAAE,KAAK,CAAC,EAC/B,kCAAkC,EAClC,yBAAyB,EACzB,gBAAgB,EAChB,SAAS,GAAG,KAAM,GA0DnB,EAAA;IACC,IAAI,WAAW,IAAI,CAAC,WAAW;AAAE,QAAA,MAAM,IAAI,KAAK,CAAC,uDAAuD,CAAC;AAEzG,IAAA,MAAM,QAAQ,GAAG,IAAI,eAAe,CAAC;AACnC,QAAA,MAAM,EAAE,UAAU;QAClB,IAAI;AACL,KAAA,CAAC;;AAGF,IAAA,MAAM,cAAc,GAAG,IAAI,OAAO,EAAqB;IAEvD,MAAM,eAAe,GAAG,CAAC,MAAiB,EAAE,MAAc,EAAE,MAAkB,KAAI;;AAEhF,QAAA,IAAI,MAAM,CAAC,UAAU,KAAK,MAAM,CAAC,IAAI;YAAE;AACvC,QAAA,cAAc,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC;AAClC,QAAA,IAAI,KAAiC;;;AAGrC,QAAA,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,YAAY,CAAC,KAAK,CAAC,CAAC;QAC/C,MAAM,QAAQ,GAAG,MAAK;YACpB,MAAM,SAAS,GAAG,MAAM,GAAG,IAAI,CAAC,GAAG,EAAE;AACrC,YAAA,KAAK,GAAG,UAAU,CAChB,MAAK;gBACH,IAAI,SAAS,GAAG,YAAY;oBAAE,OAAO,QAAQ,EAAE;AAC/C,gBAAA,IAAI,MAAM,CAAC,UAAU,KAAK,MAAM,CAAC,IAAI;oBAAE;AACvC,gBAAA,MAAM,CAAC,IAAI,CAAC,iCAAiC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,cAAc,CAAC,EAAE,CAAC;gBACxF,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,SAAS,EAAE,WAAW,CAAC;AAChD,YAAA,CAAC,EACD,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC,CAAC,EAAE,YAAY,CAAC,CAC/C;YACD,KAAK,CAAC,KAAK,EAAE;AACf,QAAA,CAAC;AACD,QAAA,QAAQ,EAAE;AACZ,IAAA,CAAC;;;AAID,IAAA,MAAM,MAAM,GAAG,CAAC,MAAiB,KAAI;QACnC,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,SAAS,EAAE,WAAW,CAAC;AAC9C,QAAA,OAAO,CAAC,IAAI,wBAAwB,EAAE,CAAC;AACzC,IAAA,CAAC;AAED,IAAA,OAAO,SAAS,CACd;QACE,MAAM;AACN,QAAA,OAAO,CAAC,IAAI,EAAE,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAA;;AAEhC,YAAA,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,KAAK,KAAK,YAAY,wBAAwB,CAAC;gBAAE;YACxE,MAAM,CAAC,KAAK,CAAC,oCAAoC,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,CAAC;QACpE,CAAC;AACD,QAAA,SAAS,EAAE,OAAO,GAAG,KAAI;YACvB,MAAM,qBAAqB,GAAG,qCAAqC;YACnE,IAAI,CAAC,WAAW,EAAE;AAChB,gBAAA,MAAM,CAAC,IAAI,CAAC,qBAAqB,CAAC;AAClC,gBAAA,OAAO,IAAI;YACb;YAEA,MAAM,KAAK,GAAG,YAAY,CAAC,EAAE,cAAc,EAAE,GAAG,CAAC,KAAK,CAAC,OAAO,EAAE,gBAAgB,EAAE,GAAG,CAAC,gBAAgB,EAAE,CAAC;YACzG,IAAI,CAAC,KAAK,EAAE;gBACV,IAAI,WAAW,EAAE;AACf,oBAAA,MAAM,CAAC,KAAK,CAAC,0DAA0D,CAAC;AACxE,oBAAA,OAAO,KAAK;gBACd;AACA,gBAAA,MAAM,CAAC,IAAI,CAAC,qBAAqB,CAAC;AAClC,gBAAA,OAAO,IAAI;YACb;AAEA,YAAA,IAAI,MAAkB;AACtB,YAAA,IAAI;AACF,gBAAA,MAAM,GAAG,MAAM,WAAW,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,KAAK,CAAC;YAC/D;YAAE,OAAO,KAAK,EAAE;gBACd,MAAM,CAAC,KAAK,CAAC,qDAAqD,EAAE,EAAE,KAAK,EAAE,CAAC;AAC9E,gBAAA,OAAO,KAAK;YACd;AAEA,YAAA,IAAI,MAA0B;AAC9B,YAAA,IAAI;AACF,gBAAA,MAAM,GAAG,gBAAgB,GAAG,MAAM,CAAC;YACrC;YAAE,OAAO,KAAK,EAAE;AACd,gBAAA,MAAM,CAAC,KAAK,CAAC,kDAAkD,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,cAAc,CAAC,EAAE,KAAK,EAAE,CAAC;AACjH,gBAAA,OAAO,KAAK;YACd;;;AAGA,YAAA,IAAI,MAAM,KAAK,SAAS,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,EAAE;AAClD,gBAAA,MAAM,CAAC,IAAI,CAAC,6CAA6C,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,cAAc,CAAC,EAAE,CAAC;AACpG,gBAAA,OAAO,KAAK;YACd;AAEA,YAAA,GAAG,CAAC,KAAK,CAAC,MAAM,GAAG,MAA8B;AACjD,YAAA,MAAM,CAAC,IAAI,CAAC,qBAAqB,EAAE;AACjC,gBAAA,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,cAAc,CAAC;AACrC,aAAA,CAAC;YACF,IAAI,MAAM,KAAK,SAAS;gBAAE,eAAe,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC;AAC3E,YAAA,OAAO,IAAI;QACb,CAAC;AACD,QAAA,WAAW,EAAE,OAAO,GAAG,KAAI;AACzB,YAAA,IAAI,CAAC,WAAW;gBAAE;AAClB,YAAA,MAAM,MAAM,GAAG,cAAc,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC;AACnD,YAAA,IAAI,MAAM,KAAK,SAAS,EAAE;;;AAGxB,gBAAA,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,MAAM;oBAAE;gBACzB,MAAM,CAAC,IAAI,CAAC,qCAAqC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,EAAE,cAAc,CAAC,EAAE,CAAC;gBACtG,OAAO,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC;YACjC;YACA,MAAM,KAAK,GAAG,YAAY,CAAC,EAAE,cAAc,EAAE,GAAG,CAAC,KAAK,CAAC,OAAO,EAAE,gBAAgB,EAAE,GAAG,CAAC,gBAAgB,EAAE,CAAC;YACzG,IAAI,CAAC,KAAK,EAAE;gBACV,IAAI,WAAW,EAAE;AACf,oBAAA,MAAM,CAAC,KAAK,CAAC,0DAA0D,CAAC;oBACxE,OAAO,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC;gBACjC;gBACA;YACF;AACA,YAAA,IAAI;AACF,gBAAA,MAAM,WAAW,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,KAAK,CAAC;YACtD;YAAE,OAAO,KAAK,EAAE;gBACd,MAAM,CAAC,IAAI,CAAC,uDAAuD,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,EAAE,cAAc,CAAC,EAAE,KAAK,EAAE,CAAC;gBAC/H,OAAO,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC;YACjC;QACF,CAAC;AACD,QAAA,YAAY,CAAC,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAA;AAChC,YAAA,MAAM,CAAC,IAAI,CAAC,sCAAsC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,cAAc,CAAC,EAAE,CAAC;QAC/F,CAAC;AACD,QAAA,OAAO,EAAE,OAAO,GAAG,KAAI;AACrB,YAAA,OAAO,yBAAyB,CAAC;AAC/B,gBAAA,cAAc,EAAE,GAAG,CAAC,KAAK,CAAC,OAAO;gBACjC,gBAAgB,EAAE,GAAG,CAAC,gBAAgB;AACtC,gBAAA,MAAM,EAAE,GAAG,CAAC,KAAK,CAAC,MAAgC;AACnD,aAAA,CAAC;QACJ,CAAC;AACD,QAAA,WAAW,CAAC,IAAI,EAAE,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAA;AAClC,YAAA,wBAAwB,CAAC;gBACvB,EAAE;gBACF,IAAI;AACJ,gBAAA,QAAQ,EAAE,iBAAiB;AAC3B,gBAAA,aAAa,EAAE,kCAAkC;gBACjD,yBAAyB;AAC1B,aAAA,CAAC;QACJ,CAAC;AACD,QAAA,MAAM,CAAC,IAAI,EAAE,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,GAAG,MAAM,EAAE,EAAA;AAClD,YAAA,wBAAwB,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,iBAAiB,EAAE,MAAM,EAAE,aAAa,EAAE,kCAAkC,EAAE,CAAC;QAChI,CAAC;AACF,KAAA,EACD,QAAQ,EACR,SAAS,CACV;AACH;;;;"}