@nifrajs/graphql 4.0.2 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ws.ts CHANGED
@@ -16,13 +16,14 @@
16
16
  import type { NifraWebSocket, WebSocketContext, WebSocketHandler } from "@nifrajs/core/ws"
17
17
  import {
18
18
  type DocumentNode,
19
+ type ExecutionArgs,
19
20
  type ExecutionResult,
20
21
  execute,
21
22
  GraphQLError,
22
23
  type GraphQLSchema,
23
24
  parse,
24
- specifiedRules,
25
25
  subscribe,
26
+ type ValidationRule,
26
27
  validate,
27
28
  } from "graphql"
28
29
  import {
@@ -38,7 +39,9 @@ import {
38
39
  documentMetrics,
39
40
  type GraphqlLimits,
40
41
  graphqlLimits,
42
+ overlapLimitError,
41
43
  requestLimitError,
44
+ validationRules,
42
45
  } from "./limits.ts"
43
46
 
44
47
  /** Per-connection state carried on `ws.data` for the graphql-ws bridge. */
@@ -49,6 +52,16 @@ interface GraphqlWsConnection {
49
52
  /** Tear the graphql-ws connection down (graphql-ws's `opened()` return value). */
50
53
  closed?: (code: number, reason: string) => void | Promise<void>
51
54
  readonly subscriptions: Set<string>
55
+ /** One abort controller per running operation id, aborted by the client's complete or a close. */
56
+ operations?: Map<string, AbortController>
57
+ /** Set once graphql-ws sends `connection_ack`, i.e. `connection_init` was admitted. */
58
+ acknowledged?: boolean
59
+ }
60
+
61
+ /** The `extra` graphql-ws hands its hooks for one socket. */
62
+ interface GraphqlWsExtra {
63
+ readonly request: Request
64
+ readonly operations: Map<string, AbortController>
52
65
  }
53
66
 
54
67
  export interface GraphqlWsOptions<
@@ -58,6 +71,11 @@ export interface GraphqlWsOptions<
58
71
  readonly schema: GraphQLSchema
59
72
  /** Build the resolver `contextValue` from the upgrade request. Shared with the HTTP transport. */
60
73
  readonly context?: GraphqlContextBuilder<Context>
74
+ /**
75
+ * Admit a socket from its upgrade request, before {@link onConnect}; return `false` (or throw) to
76
+ * reject the connection. `mountGraphql({ subscriptions: true })` passes its HTTP `authorize` here.
77
+ */
78
+ readonly authorize?: (request: Request) => boolean | Promise<boolean>
61
79
  /**
62
80
  * Called once per connection with the client's `connection_init` payload; return `false` (or throw)
63
81
  * to reject the connection. The seam for authenticating a socket from an init token.
@@ -75,9 +93,19 @@ export interface GraphqlWsOptions<
75
93
  readonly maxAliases?: number
76
94
  readonly maxComplexity?: number
77
95
  readonly maxOperations?: number
96
+ readonly maxFieldOverlaps?: number
78
97
  /** Maximum concurrent subscription operation ids per connection. Default 20. */
79
98
  readonly maxSubscriptions?: number
80
- /** Maximum time for initial execute/subscribe setup. Default 10 seconds. */
99
+ /**
100
+ * Answer introspection (`__schema`, `__type`). Default `true`. Set `false` to refuse such an
101
+ * operation with an `error` frame; `__typename` still resolves.
102
+ */
103
+ readonly introspection?: boolean
104
+ /**
105
+ * Maximum time for initial execute/subscribe setup. Default 10 seconds. Each operation's abort
106
+ * signal, which a resolver reads through `info.getAbortSignal()`, is aborted at this deadline, when
107
+ * the client completes the operation, and when the socket closes.
108
+ */
81
109
  readonly executionTimeoutMs?: number
82
110
  }
83
111
 
@@ -106,6 +134,7 @@ function subscriptionFrameError(
106
134
  message: string,
107
135
  schema: GraphQLSchema,
108
136
  limits: GraphqlLimits,
137
+ rules: readonly ValidationRule[],
109
138
  ): { readonly id: unknown; readonly message: string } | undefined {
110
139
  let frame: unknown
111
140
  try {
@@ -133,11 +162,13 @@ function subscriptionFrameError(
133
162
  } catch (error) {
134
163
  return { id, message: error instanceof GraphQLError ? error.message : "Invalid GraphQL query." }
135
164
  }
136
- const validationErrors = validate(schema, document, specifiedRules)
137
- if (validationErrors.length > 0)
138
- return { id, message: validationErrors[0]?.message ?? "Invalid GraphQL query." }
139
- const limitError = documentLimitError(documentMetrics(document), limits)
140
- return limitError === undefined ? undefined : { id, message: limitError }
165
+ const limitError =
166
+ documentLimitError(documentMetrics(document), limits) ?? overlapLimitError(document, limits)
167
+ if (limitError !== undefined) return { id, message: limitError }
168
+ const validationErrors = validate(schema, document, rules)
169
+ return validationErrors.length > 0
170
+ ? { id, message: validationErrors[0]?.message ?? "Invalid GraphQL query." }
171
+ : undefined
141
172
  }
142
173
 
143
174
  function maskWsError(error: unknown): unknown {
@@ -155,12 +186,14 @@ function maskWsError(error: unknown): unknown {
155
186
  * so the id stops counting against `maxSubscriptions`: a client is not expected to complete an
156
187
  * operation the server already ended.
157
188
  */
158
- function outboundFrame(message: string, subscriptions: Set<string>): string {
189
+ function outboundFrame(message: string, connection: GraphqlWsConnection): string {
159
190
  try {
160
191
  const parsed = recordOf(JSON.parse(message))
161
192
  if (parsed === undefined) return message
193
+ if (parsed.type === "connection_ack") connection.acknowledged = true
162
194
  if ((parsed.type === "complete" || parsed.type === "error") && typeof parsed.id === "string") {
163
- subscriptions.delete(parsed.id)
195
+ connection.subscriptions.delete(parsed.id)
196
+ connection.operations?.delete(parsed.id)
164
197
  }
165
198
  if (parsed.type === "next") {
166
199
  const payload = recordOf(parsed.payload)
@@ -180,26 +213,68 @@ function outboundFrame(message: string, subscriptions: Set<string>): string {
180
213
  return message
181
214
  }
182
215
 
216
+ /** Close a result that arrived after its operation timed out, if it is a stream nobody will read. */
217
+ function disposeLate(result: unknown): void {
218
+ if (typeof result !== "object" || result === null || !(Symbol.asyncIterator in result)) return
219
+ const iterator = recordOf(result)
220
+ if (iterator === undefined || typeof iterator.return !== "function") return
221
+ Promise.resolve(iterator.return.call(result)).catch(() => {})
222
+ }
223
+
224
+ const wrappedSubscribeFields = new WeakSet<object>()
225
+
226
+ /**
227
+ * graphql drops a subscribe resolver's result that settles after the operation's signal aborted, so
228
+ * a source the resolver opened anyway would stay open. Each root subscription field's resolver is
229
+ * wrapped once to return such a late source; with no signal, or before an abort, it is unchanged.
230
+ */
231
+ function returnLateSources(schema: GraphQLSchema): void {
232
+ const fields = schema.getSubscriptionType()?.getFields() ?? {}
233
+ for (const field of Object.values(fields)) {
234
+ const resolver = field.subscribe
235
+ if (resolver === undefined || wrappedSubscribeFields.has(field)) continue
236
+ wrappedSubscribeFields.add(field)
237
+ field.subscribe = (source, args, context, info) => {
238
+ const result = resolver(source, args, context, info)
239
+ if (!(result instanceof Promise)) return result
240
+ return result.then((value) => {
241
+ if (info.getAbortSignal()?.aborted === true) disposeLate(value)
242
+ return value
243
+ })
244
+ }
245
+ }
246
+ }
247
+
183
248
  /**
184
249
  * A timeout is that operation's error result. graphql-ws does not catch a rejected execute or
185
- * subscribe, so a rejection would end the whole socket instead of the one operation.
250
+ * subscribe, so a rejection would end the whole socket instead of the one operation. A timeout aborts
251
+ * the operation's `abortSignal`, which resolvers read through `info.getAbortSignal()`, and work that
252
+ * settles after it is not abandoned open: a late subscription stream is returned.
186
253
  */
187
254
  async function withWsTimeout<T>(
188
- work: T | Promise<T>,
255
+ start: (abortSignal: AbortSignal) => T | Promise<T>,
256
+ outer: AbortSignal | null | undefined,
189
257
  timeoutMs: number,
190
258
  ): Promise<T | ExecutionResult> {
191
- if (!(work instanceof Promise) || timeoutMs === 0) return work
259
+ const controller = new AbortController()
260
+ const work = start(outer ? AbortSignal.any([outer, controller.signal]) : controller.signal)
261
+ if (!(work instanceof Promise)) return work
192
262
  let timer: ReturnType<typeof setTimeout> | undefined
263
+ let timedOut = false
193
264
  const timeout = new Promise<ExecutionResult>((resolve) => {
194
- timer = setTimeout(
195
- () => resolve({ errors: [new GraphQLError("Execution timed out.")] }),
196
- timeoutMs,
197
- )
265
+ timer = setTimeout(() => {
266
+ timedOut = true
267
+ const error = new GraphQLError("Execution timed out.")
268
+ // Resolved first: the abort can reject the work, and the race must settle on this result.
269
+ resolve({ errors: [error] })
270
+ controller.abort(error)
271
+ }, timeoutMs)
198
272
  })
199
273
  try {
200
274
  return await Promise.race([work, timeout])
201
275
  } finally {
202
276
  if (timer !== undefined) clearTimeout(timer)
277
+ if (timedOut) work.then(disposeLate, () => {})
203
278
  }
204
279
  }
205
280
 
@@ -221,15 +296,29 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
221
296
  options: GraphqlWsOptions<Context>,
222
297
  ): WebSocketHandler<GraphqlWsConnection> {
223
298
  const limits = graphqlWsLimits(options)
299
+ const rules = validationRules(options.introspection)
224
300
  const maxSubscriptions = options.maxSubscriptions ?? 20
225
301
  assertWsNumber(maxSubscriptions, "maxSubscriptions", 1)
226
302
  const executionTimeoutMs = options.executionTimeoutMs ?? 10_000
227
303
  assertWsNumber(executionTimeoutMs, "executionTimeoutMs", 0)
228
304
  const executeWithTimeout: typeof execute = (args) =>
229
- withWsTimeout(execute(args), executionTimeoutMs)
305
+ executionTimeoutMs === 0
306
+ ? execute(args)
307
+ : withWsTimeout(
308
+ (abortSignal) => execute({ ...args, abortSignal }),
309
+ args.abortSignal,
310
+ executionTimeoutMs,
311
+ )
230
312
  const subscribeWithTimeout: typeof subscribe = (args) =>
231
- withWsTimeout(subscribe(args), executionTimeoutMs)
232
- const gqlServer = makeServer<Record<string, unknown>, { request: Request }>({
313
+ executionTimeoutMs === 0
314
+ ? subscribe(args)
315
+ : withWsTimeout(
316
+ (abortSignal) => subscribe({ ...args, abortSignal }),
317
+ args.abortSignal,
318
+ executionTimeoutMs,
319
+ )
320
+ returnLateSources(options.schema)
321
+ const gqlServer = makeServer<Record<string, unknown>, GraphqlWsExtra>({
233
322
  // Keep the raw escape hatch for auxiliary graphql-ws hooks, but make the package's safety
234
323
  // controls authoritative: callers cannot accidentally replace the bounded execute/subscribe
235
324
  // wrappers or the context/authentication seams with an unbounded implementation.
@@ -237,22 +326,41 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
237
326
  schema: options.schema,
238
327
  execute: executeWithTimeout,
239
328
  subscribe: subscribeWithTimeout,
240
- onConnect: options.onConnect
241
- ? async (ctx: GraphqlWsContext<Record<string, unknown>, { request: Request }>) => {
242
- try {
243
- return await options.onConnect?.(
244
- ctx.connectionParams as ConnectionInitMessage["payload"],
245
- ctx.extra.request,
246
- )
247
- } catch {
248
- return false
329
+ onConnect:
330
+ options.onConnect || options.authorize
331
+ ? async (ctx: GraphqlWsContext<Record<string, unknown>, GraphqlWsExtra>) => {
332
+ try {
333
+ if (options.authorize !== undefined && !(await options.authorize(ctx.extra.request)))
334
+ return false
335
+ return (
336
+ (await options.onConnect?.(
337
+ ctx.connectionParams as ConnectionInitMessage["payload"],
338
+ ctx.extra.request,
339
+ )) ?? true
340
+ )
341
+ } catch {
342
+ return false
343
+ }
249
344
  }
250
- }
251
- : undefined,
252
- context: options.context
253
- ? async (ctx: GraphqlWsContext<Record<string, unknown>, { request: Request }>) =>
254
- await buildContext(options.context, { request: ctx.extra.request })
255
- : undefined,
345
+ : undefined,
346
+ // graphql-ws builds the context once per operation, just before executing it, so the operation's
347
+ // abort signal is attached to its execution arguments here.
348
+ context: async (
349
+ ctx: GraphqlWsContext<Record<string, unknown>, GraphqlWsExtra>,
350
+ id: string,
351
+ _payload: unknown,
352
+ args: ExecutionArgs,
353
+ ) => {
354
+ const controller = new AbortController()
355
+ ctx.extra.operations.set(id, controller)
356
+ args.abortSignal =
357
+ args.abortSignal === undefined || args.abortSignal === null
358
+ ? controller.signal
359
+ : AbortSignal.any([args.abortSignal, controller.signal])
360
+ return options.context === undefined
361
+ ? undefined
362
+ : await buildContext(options.context, { request: ctx.extra.request })
363
+ },
256
364
  })
257
365
 
258
366
  return {
@@ -264,11 +372,13 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
264
372
  const protocol =
265
373
  (ws.raw as { protocol?: string } | undefined)?.protocol ?? GRAPHQL_TRANSPORT_WS_PROTOCOL
266
374
  let onFrame: ((message: string) => void | Promise<void>) | undefined
375
+ const operations = new Map<string, AbortController>()
376
+ ws.data.operations = operations
267
377
  const closed = gqlServer.opened(
268
378
  {
269
379
  protocol,
270
380
  send: async (data: string) => {
271
- ws.send(outboundFrame(data, ws.data.subscriptions))
381
+ ws.send(outboundFrame(data, ws.data))
272
382
  },
273
383
  close: (code: number, reason: string) => {
274
384
  ws.close(code, reason)
@@ -277,7 +387,7 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
277
387
  onFrame = cb
278
388
  },
279
389
  },
280
- { request: ws.data.request },
390
+ { request: ws.data.request, operations },
281
391
  )
282
392
  ws.data.closed = closed
283
393
  ws.data.dispatch = async (message: string) => {
@@ -297,7 +407,13 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
297
407
  // this socket - including the client's `Complete` that stops the subscription. graphql-ws drives
298
408
  // its own sends and error frames from inside that loop; we only feed it inbound frames.
299
409
  const message = asText(data)
300
- const rejected = subscriptionFrameError(message, options.schema, limits)
410
+ // Until connection_init is admitted, graphql-ws answers a subscribe frame by closing with 4401
411
+ // without reading the query. Parsing or validating it here would hand an unauthenticated socket
412
+ // schema suggestions and synchronous validation work.
413
+ const rejected =
414
+ ws.data.acknowledged === true
415
+ ? subscriptionFrameError(message, options.schema, limits, rules)
416
+ : undefined
301
417
  if (rejected !== undefined) {
302
418
  ws.send(
303
419
  JSON.stringify({
@@ -324,6 +440,8 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
324
440
  ws.data.subscriptions.add(frame.id)
325
441
  } else if (frame?.type === "complete" && typeof frame.id === "string") {
326
442
  ws.data.subscriptions.delete(frame.id)
443
+ ws.data.operations?.get(frame.id)?.abort(new GraphQLError("Operation completed."))
444
+ ws.data.operations?.delete(frame.id)
327
445
  }
328
446
  } catch {
329
447
  // graphql-ws owns malformed protocol-frame handling; pass it through unchanged.
@@ -333,6 +451,9 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
333
451
 
334
452
  close(ws: NifraWebSocket<GraphqlWsConnection>, code: number, reason: string): void {
335
453
  ws.data.subscriptions.clear()
454
+ for (const controller of ws.data.operations?.values() ?? [])
455
+ controller.abort(new GraphQLError("Socket closed."))
456
+ ws.data.operations?.clear()
336
457
  // The socket is gone: a failing iterator return() or onClose hook has no one left to answer.
337
458
  void Promise.resolve(ws.data.closed?.(code, reason)).catch(() => {})
338
459
  },