@nifrajs/graphql 4.0.2 → 5.0.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/index.ts CHANGED
@@ -22,7 +22,12 @@ export type {
22
22
  } from "./context.ts"
23
23
  export { buildContext } from "./context.ts"
24
24
  export { type GraphqlHttpOptions, respondGraphql } from "./http.ts"
25
- export { createPubSub, type GraphqlPubSub } from "./pubsub.ts"
25
+ export {
26
+ createPubSub,
27
+ type GraphqlPubSub,
28
+ type GraphqlPubSubOptions,
29
+ GraphqlPubSubOverflowError,
30
+ } from "./pubsub.ts"
26
31
  export { type GraphqlWsOptions, graphqlWebSocket } from "./ws.ts"
27
32
 
28
33
  /** The structural slice of a nifra server `mountGraphql` needs. Kept loose so core stays a peer dep. */
@@ -43,12 +48,42 @@ export interface MountGraphqlOptions<Context = unknown, Env = unknown>
43
48
  /** Enable `GET /graphql` (queries only). Default `true`. */
44
49
  readonly enableGet?: boolean
45
50
  /**
46
- * Enable `graphql-transport-ws` subscriptions on the same path. Pass the WS options (or `true` to
47
- * reuse `schema`/`context`). Requires the app to have `.use(websocket())` and `graphql-ws` installed.
51
+ * Enable `graphql-transport-ws` subscriptions on the same path. Pass `true` to reuse `schema`,
52
+ * `context`, `authorize` (run on the upgrade request) and the query limits, or pass the WS options.
53
+ * With `authorize` set, WS options must bring their own `authorize` or `onConnect`; the mount throws
54
+ * otherwise, since every socket would be admitted. Requires `.use(websocket())` and `graphql-ws`.
48
55
  */
49
56
  readonly subscriptions?: boolean | GraphqlWsOptions<Record<string, unknown>>
50
57
  }
51
58
 
59
+ type Writable<T> = { -readonly [K in keyof T]: T[K] }
60
+
61
+ const SHARED_LIMITS = [
62
+ "maxQueryBytes",
63
+ "maxVariablesBytes",
64
+ "maxDepth",
65
+ "maxAliases",
66
+ "maxComplexity",
67
+ "maxOperations",
68
+ "maxFieldOverlaps",
69
+ "executionTimeoutMs",
70
+ ] as const
71
+
72
+ /** The socket transport of `subscriptions: true`: the HTTP endpoint's schema, context, authorization and limits. */
73
+ function sharedWsOptions<Context, Env>(
74
+ options: MountGraphqlOptions<Context, Env>,
75
+ ): GraphqlWsOptions<Record<string, unknown>> {
76
+ const shared: Writable<GraphqlWsOptions<Record<string, unknown>>> = { schema: options.schema }
77
+ const ctx = options.context as GraphqlContextBuilder<Record<string, unknown>> | undefined
78
+ if (ctx !== undefined) shared.context = ctx
79
+ if (options.authorize !== undefined) shared.authorize = options.authorize
80
+ for (const limit of SHARED_LIMITS) {
81
+ const value = options[limit]
82
+ if (value !== undefined) shared[limit] = value
83
+ }
84
+ return shared
85
+ }
86
+
52
87
  /**
53
88
  * Mount a GraphQL endpoint. Wires `POST` (and `GET`, unless disabled) on `path`, injecting the nifra
54
89
  * route context into your resolver context, and - when `subscriptions` is set - a `graphql-ws` WebSocket
@@ -87,13 +122,18 @@ export function mountGraphql<Context = unknown, Env = unknown>(
87
122
  "mountGraphql: subscriptions require a WebSocket-enabled app - call `.use(websocket())` first.",
88
123
  )
89
124
  }
90
- let wsOptions: GraphqlWsOptions<Record<string, unknown>>
91
- if (options.subscriptions === true) {
92
- const ctx = options.context as GraphqlContextBuilder<Record<string, unknown>> | undefined
93
- wsOptions =
94
- ctx !== undefined ? { schema: options.schema, context: ctx } : { schema: options.schema }
95
- } else {
96
- wsOptions = options.subscriptions
125
+ const wsOptions =
126
+ options.subscriptions === true ? sharedWsOptions(options) : options.subscriptions
127
+ if (
128
+ options.authorize !== undefined &&
129
+ wsOptions.authorize === undefined &&
130
+ wsOptions.onConnect === undefined
131
+ ) {
132
+ throw new Error(
133
+ "mountGraphql: `authorize` guards the HTTP endpoint only, and these subscription options " +
134
+ "admit every socket. Give them their own `authorize` or `onConnect`, or pass " +
135
+ "`subscriptions: true` to reuse `authorize`.",
136
+ )
97
137
  }
98
138
  app.ws(path, graphqlWebSocket(wsOptions))
99
139
  }
package/src/limits.ts CHANGED
@@ -1,4 +1,11 @@
1
- import { type DocumentNode, type GraphQLError, Kind, type SelectionNode } from "graphql"
1
+ import {
2
+ type DocumentNode,
3
+ type GraphQLError,
4
+ Kind,
5
+ type SelectionNode,
6
+ type SelectionSetNode,
7
+ visit,
8
+ } from "graphql"
2
9
 
3
10
  export const DEFAULT_GRAPHQL_LIMITS = Object.freeze({
4
11
  maxQueryBytes: 64 * 1024,
@@ -7,6 +14,7 @@ export const DEFAULT_GRAPHQL_LIMITS = Object.freeze({
7
14
  maxAliases: 100,
8
15
  maxComplexity: 1_000,
9
16
  maxOperations: 10,
17
+ maxFieldOverlaps: 2_000,
10
18
  })
11
19
 
12
20
  export interface GraphqlLimitsOptions {
@@ -16,6 +24,7 @@ export interface GraphqlLimitsOptions {
16
24
  readonly maxAliases?: number
17
25
  readonly maxComplexity?: number
18
26
  readonly maxOperations?: number
27
+ readonly maxFieldOverlaps?: number
19
28
  }
20
29
 
21
30
  export interface GraphqlLimits {
@@ -25,6 +34,7 @@ export interface GraphqlLimits {
25
34
  readonly maxAliases: number
26
35
  readonly maxComplexity: number
27
36
  readonly maxOperations: number
37
+ readonly maxFieldOverlaps: number
28
38
  }
29
39
 
30
40
  function limit(value: number | undefined, fallback: number, name: string, minimum: number): number {
@@ -63,6 +73,12 @@ export function graphqlLimits(options: GraphqlLimitsOptions = {}): GraphqlLimits
63
73
  "maxOperations",
64
74
  1,
65
75
  ),
76
+ maxFieldOverlaps: limit(
77
+ options.maxFieldOverlaps,
78
+ DEFAULT_GRAPHQL_LIMITS.maxFieldOverlaps,
79
+ "maxFieldOverlaps",
80
+ 0,
81
+ ),
66
82
  }
67
83
  }
68
84
 
@@ -172,6 +188,113 @@ export function requestLimitError(
172
188
  return undefined
173
189
  }
174
190
 
191
+ /**
192
+ * Pairs of same-named fields across every selection set, the work graphql-js's
193
+ * OverlappingFieldsCanBeMerged rule does: it compares every two fields that share a response name in
194
+ * one selection set, counting the fields its fragment spreads and inline fragments bring in. A few
195
+ * thousand bytes of `b(x: 1) b(x: 2) ...` hold within the depth and complexity limits yet cost seconds
196
+ * of synchronous validation, so this is counted, and refused, before `validate()` runs. Measured
197
+ * once {@link documentLimitError} passes and no fragment is unused, the fields it walks are bounded
198
+ * by `maxComplexity`.
199
+ */
200
+ export function fieldOverlaps(document: DocumentNode): number {
201
+ const fragments = new Map<string, readonly SelectionNode[]>()
202
+ for (const definition of document.definitions) {
203
+ if (definition.kind === Kind.FRAGMENT_DEFINITION) {
204
+ fragments.set(definition.name.value, definition.selectionSet.selections)
205
+ }
206
+ }
207
+ const fragmentNames = new Map<string, ReadonlyMap<string, number>>()
208
+ const expanding = new Set<string>()
209
+ const add = (
210
+ selections: readonly SelectionNode[],
211
+ names: Map<string, number>,
212
+ spread: Set<string>,
213
+ ): void => {
214
+ for (const selection of selections) {
215
+ if (selection.kind === Kind.FIELD) {
216
+ const name = selection.alias?.value ?? selection.name.value
217
+ names.set(name, (names.get(name) ?? 0) + 1)
218
+ } else if (selection.kind === Kind.INLINE_FRAGMENT) {
219
+ add(selection.selectionSet.selections, names, spread)
220
+ } else if (!spread.has(selection.name.value)) {
221
+ spread.add(selection.name.value)
222
+ for (const [name, count] of fragmentNamesOf(selection.name.value)) {
223
+ names.set(name, (names.get(name) ?? 0) + count)
224
+ }
225
+ }
226
+ }
227
+ }
228
+ // An unknown fragment and a cycle, both refused by validation, contribute nothing.
229
+ const fragmentNamesOf = (fragment: string): ReadonlyMap<string, number> => {
230
+ const known = fragmentNames.get(fragment)
231
+ if (known !== undefined) return known
232
+ const selections = fragments.get(fragment)
233
+ if (selections === undefined || expanding.has(fragment)) return new Map()
234
+ expanding.add(fragment)
235
+ const names = new Map<string, number>()
236
+ add(selections, names, new Set([fragment]))
237
+ expanding.delete(fragment)
238
+ fragmentNames.set(fragment, names)
239
+ return names
240
+ }
241
+ let pairs = 0
242
+ visit(document, {
243
+ SelectionSet(node: SelectionSetNode) {
244
+ const names = new Map<string, number>()
245
+ add(node.selections, names, new Set())
246
+ for (const count of names.values()) pairs += (count * (count - 1)) / 2
247
+ },
248
+ })
249
+ return pairs
250
+ }
251
+
252
+ /** The first fragment no operation reaches; graphql-js refuses it, and no limit above counts it. */
253
+ function unusedFragment(document: DocumentNode): string | undefined {
254
+ const spreads = new Map<string, readonly string[]>()
255
+ const pending: string[] = []
256
+ const spreadsIn = (node: DocumentNode["definitions"][number]): string[] => {
257
+ const names: string[] = []
258
+ visit(node, {
259
+ FragmentSpread(spread) {
260
+ names.push(spread.name.value)
261
+ },
262
+ })
263
+ return names
264
+ }
265
+ for (const definition of document.definitions) {
266
+ if (definition.kind === Kind.FRAGMENT_DEFINITION) {
267
+ spreads.set(definition.name.value, spreadsIn(definition))
268
+ } else if (definition.kind === Kind.OPERATION_DEFINITION) {
269
+ pending.push(...spreadsIn(definition))
270
+ }
271
+ }
272
+ const reached = new Set<string>()
273
+ for (let name = pending.pop(); name !== undefined; name = pending.pop()) {
274
+ if (reached.has(name)) continue
275
+ reached.add(name)
276
+ pending.push(...(spreads.get(name) ?? []))
277
+ }
278
+ for (const name of spreads.keys()) if (!reached.has(name)) return name
279
+ return undefined
280
+ }
281
+
282
+ /**
283
+ * Refuses, before `validate()`, the documents whose overlap validation is expensive: too many
284
+ * same-named field pairs, or a fragment no operation uses (its fields escape `maxComplexity`). Run it
285
+ * after {@link documentLimitError} passes.
286
+ */
287
+ export function overlapLimitError(
288
+ document: DocumentNode,
289
+ limits: GraphqlLimits,
290
+ ): string | undefined {
291
+ const unused = unusedFragment(document)
292
+ if (unused !== undefined) return `Fragment "${unused}" is never used.`
293
+ return fieldOverlaps(document) > limits.maxFieldOverlaps
294
+ ? `GraphQL document repeats field names in one selection set beyond the limit of ${limits.maxFieldOverlaps} same-named field pairs.`
295
+ : undefined
296
+ }
297
+
175
298
  export function documentLimitError(
176
299
  metrics: GraphqlDocumentMetrics,
177
300
  limits: GraphqlLimits,
package/src/pubsub.ts CHANGED
@@ -18,8 +18,34 @@ export interface GraphqlPubSub<Event = unknown> {
18
18
  subscribe(topic: string): AsyncIterableIterator<Event>
19
19
  }
20
20
 
21
- /** Create an in-memory {@link GraphqlPubSub}. */
22
- export function createPubSub<Event = unknown>(): GraphqlPubSub<Event> {
21
+ export interface GraphqlPubSubOptions {
22
+ /** Maximum queued events per subscriber. Positive safe integer; default 1024. */
23
+ readonly maxQueuedEvents?: number
24
+ /** A full queue closes with an error by default. Dropping requires explicit opt-in. */
25
+ readonly overflow?: "error" | "drop-oldest" | "drop-newest"
26
+ }
27
+
28
+ export class GraphqlPubSubOverflowError extends Error {
29
+ readonly code = "graphql_pubsub_overflow"
30
+
31
+ constructor() {
32
+ super("GraphQL subscription event queue is full")
33
+ this.name = "GraphqlPubSubOverflowError"
34
+ }
35
+ }
36
+
37
+ /** Create an in-memory {@link GraphqlPubSub} with finite subscriber queues. */
38
+ export function createPubSub<Event = unknown>(
39
+ options: GraphqlPubSubOptions = {},
40
+ ): GraphqlPubSub<Event> {
41
+ const maxQueuedEvents = options.maxQueuedEvents ?? 1024
42
+ const overflow = options.overflow ?? "error"
43
+ if (!Number.isSafeInteger(maxQueuedEvents) || maxQueuedEvents < 1) {
44
+ throw new RangeError("createPubSub maxQueuedEvents must be a positive safe integer")
45
+ }
46
+ if (overflow !== "error" && overflow !== "drop-oldest" && overflow !== "drop-newest") {
47
+ throw new TypeError("createPubSub overflow must be error, drop-oldest or drop-newest")
48
+ }
23
49
  const topics = new Map<string, Set<Sink<Event>>>()
24
50
 
25
51
  const publish = (topic: string, payload: Event): void => {
@@ -35,7 +61,7 @@ export function createPubSub<Event = unknown>(): GraphqlPubSub<Event> {
35
61
  topics.set(topic, sinks)
36
62
  }
37
63
  const set = sinks
38
- const sink = createSink<Event>(() => {
64
+ const sink = createSink<Event>(maxQueuedEvents, overflow, () => {
39
65
  set.delete(sink)
40
66
  if (set.size === 0) topics.delete(topic)
41
67
  })
@@ -46,16 +72,21 @@ export function createPubSub<Event = unknown>(): GraphqlPubSub<Event> {
46
72
  return { publish, subscribe }
47
73
  }
48
74
 
49
- /** One subscriber: a bounded push queue bridged to an async iterator with backpressure via awaiting pulls. */
75
+ /** Awaiting pulls consume immediately; synchronous publish cannot provide producer backpressure. */
50
76
  interface Sink<Event> {
51
77
  push(payload: Event): void
52
78
  readonly iterator: AsyncIterableIterator<Event>
53
79
  }
54
80
 
55
- function createSink<Event>(onClose: () => void): Sink<Event> {
81
+ function createSink<Event>(
82
+ maxQueuedEvents: number,
83
+ overflow: NonNullable<GraphqlPubSubOptions["overflow"]>,
84
+ onClose: () => void,
85
+ ): Sink<Event> {
56
86
  const queue: Event[] = []
57
87
  const pulls: Array<(result: IteratorResult<Event>) => void> = []
58
88
  let done = false
89
+ let failure: GraphqlPubSubOverflowError | undefined
59
90
 
60
91
  const push = (payload: Event): void => {
61
92
  if (done) return
@@ -64,12 +95,22 @@ function createSink<Event>(onClose: () => void): Sink<Event> {
64
95
  pull({ value: payload, done: false })
65
96
  return
66
97
  }
98
+ if (queue.length >= maxQueuedEvents) {
99
+ if (overflow === "drop-newest") return
100
+ if (overflow === "error") {
101
+ failure = new GraphqlPubSubOverflowError()
102
+ finish()
103
+ return
104
+ }
105
+ queue.shift()
106
+ }
67
107
  queue.push(payload)
68
108
  }
69
109
 
70
110
  const finish = (): IteratorResult<Event> => {
71
111
  if (!done) {
72
112
  done = true
113
+ queue.length = 0
73
114
  onClose()
74
115
  for (const pull of pulls.splice(0)) pull({ value: undefined, done: true })
75
116
  }
@@ -78,14 +119,22 @@ function createSink<Event>(onClose: () => void): Sink<Event> {
78
119
 
79
120
  const iterator: AsyncIterableIterator<Event> = {
80
121
  next(): Promise<IteratorResult<Event>> {
81
- if (queue.length > 0) return Promise.resolve({ value: queue.shift() as Event, done: false })
122
+ if (failure !== undefined) {
123
+ const error = failure
124
+ failure = undefined
125
+ return Promise.reject(error)
126
+ }
82
127
  if (done) return Promise.resolve({ value: undefined, done: true })
128
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: a nonempty queue owns one Event; undefined is itself a valid Event payload.
129
+ if (queue.length > 0) return Promise.resolve({ value: queue.shift() as Event, done: false })
83
130
  return new Promise((resolve) => pulls.push(resolve))
84
131
  },
85
132
  return(): Promise<IteratorResult<Event>> {
133
+ failure = undefined
86
134
  return Promise.resolve(finish())
87
135
  },
88
136
  throw(err?: unknown): Promise<IteratorResult<Event>> {
137
+ failure = undefined
89
138
  finish()
90
139
  return Promise.reject(err)
91
140
  },
package/src/ws.ts CHANGED
@@ -16,6 +16,7 @@
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,
@@ -38,6 +39,7 @@ import {
38
39
  documentMetrics,
39
40
  type GraphqlLimits,
40
41
  graphqlLimits,
42
+ overlapLimitError,
41
43
  requestLimitError,
42
44
  } from "./limits.ts"
43
45
 
@@ -49,6 +51,16 @@ interface GraphqlWsConnection {
49
51
  /** Tear the graphql-ws connection down (graphql-ws's `opened()` return value). */
50
52
  closed?: (code: number, reason: string) => void | Promise<void>
51
53
  readonly subscriptions: Set<string>
54
+ /** One abort controller per running operation id, aborted by the client's complete or a close. */
55
+ operations?: Map<string, AbortController>
56
+ /** Set once graphql-ws sends `connection_ack`, i.e. `connection_init` was admitted. */
57
+ acknowledged?: boolean
58
+ }
59
+
60
+ /** The `extra` graphql-ws hands its hooks for one socket. */
61
+ interface GraphqlWsExtra {
62
+ readonly request: Request
63
+ readonly operations: Map<string, AbortController>
52
64
  }
53
65
 
54
66
  export interface GraphqlWsOptions<
@@ -58,6 +70,11 @@ export interface GraphqlWsOptions<
58
70
  readonly schema: GraphQLSchema
59
71
  /** Build the resolver `contextValue` from the upgrade request. Shared with the HTTP transport. */
60
72
  readonly context?: GraphqlContextBuilder<Context>
73
+ /**
74
+ * Admit a socket from its upgrade request, before {@link onConnect}; return `false` (or throw) to
75
+ * reject the connection. `mountGraphql({ subscriptions: true })` passes its HTTP `authorize` here.
76
+ */
77
+ readonly authorize?: (request: Request) => boolean | Promise<boolean>
61
78
  /**
62
79
  * Called once per connection with the client's `connection_init` payload; return `false` (or throw)
63
80
  * to reject the connection. The seam for authenticating a socket from an init token.
@@ -75,9 +92,14 @@ export interface GraphqlWsOptions<
75
92
  readonly maxAliases?: number
76
93
  readonly maxComplexity?: number
77
94
  readonly maxOperations?: number
95
+ readonly maxFieldOverlaps?: number
78
96
  /** Maximum concurrent subscription operation ids per connection. Default 20. */
79
97
  readonly maxSubscriptions?: number
80
- /** Maximum time for initial execute/subscribe setup. Default 10 seconds. */
98
+ /**
99
+ * Maximum time for initial execute/subscribe setup. Default 10 seconds. Each operation's abort
100
+ * signal, which a resolver reads through `info.getAbortSignal()`, is aborted at this deadline, when
101
+ * the client completes the operation, and when the socket closes.
102
+ */
81
103
  readonly executionTimeoutMs?: number
82
104
  }
83
105
 
@@ -133,11 +155,13 @@ function subscriptionFrameError(
133
155
  } catch (error) {
134
156
  return { id, message: error instanceof GraphQLError ? error.message : "Invalid GraphQL query." }
135
157
  }
158
+ const limitError =
159
+ documentLimitError(documentMetrics(document), limits) ?? overlapLimitError(document, limits)
160
+ if (limitError !== undefined) return { id, message: limitError }
136
161
  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 }
162
+ return validationErrors.length > 0
163
+ ? { id, message: validationErrors[0]?.message ?? "Invalid GraphQL query." }
164
+ : undefined
141
165
  }
142
166
 
143
167
  function maskWsError(error: unknown): unknown {
@@ -155,12 +179,14 @@ function maskWsError(error: unknown): unknown {
155
179
  * so the id stops counting against `maxSubscriptions`: a client is not expected to complete an
156
180
  * operation the server already ended.
157
181
  */
158
- function outboundFrame(message: string, subscriptions: Set<string>): string {
182
+ function outboundFrame(message: string, connection: GraphqlWsConnection): string {
159
183
  try {
160
184
  const parsed = recordOf(JSON.parse(message))
161
185
  if (parsed === undefined) return message
186
+ if (parsed.type === "connection_ack") connection.acknowledged = true
162
187
  if ((parsed.type === "complete" || parsed.type === "error") && typeof parsed.id === "string") {
163
- subscriptions.delete(parsed.id)
188
+ connection.subscriptions.delete(parsed.id)
189
+ connection.operations?.delete(parsed.id)
164
190
  }
165
191
  if (parsed.type === "next") {
166
192
  const payload = recordOf(parsed.payload)
@@ -180,26 +206,68 @@ function outboundFrame(message: string, subscriptions: Set<string>): string {
180
206
  return message
181
207
  }
182
208
 
209
+ /** Close a result that arrived after its operation timed out, if it is a stream nobody will read. */
210
+ function disposeLate(result: unknown): void {
211
+ if (typeof result !== "object" || result === null || !(Symbol.asyncIterator in result)) return
212
+ const iterator = recordOf(result)
213
+ if (iterator === undefined || typeof iterator.return !== "function") return
214
+ Promise.resolve(iterator.return.call(result)).catch(() => {})
215
+ }
216
+
217
+ const wrappedSubscribeFields = new WeakSet<object>()
218
+
219
+ /**
220
+ * graphql drops a subscribe resolver's result that settles after the operation's signal aborted, so
221
+ * a source the resolver opened anyway would stay open. Each root subscription field's resolver is
222
+ * wrapped once to return such a late source; with no signal, or before an abort, it is unchanged.
223
+ */
224
+ function returnLateSources(schema: GraphQLSchema): void {
225
+ const fields = schema.getSubscriptionType()?.getFields() ?? {}
226
+ for (const field of Object.values(fields)) {
227
+ const resolver = field.subscribe
228
+ if (resolver === undefined || wrappedSubscribeFields.has(field)) continue
229
+ wrappedSubscribeFields.add(field)
230
+ field.subscribe = (source, args, context, info) => {
231
+ const result = resolver(source, args, context, info)
232
+ if (!(result instanceof Promise)) return result
233
+ return result.then((value) => {
234
+ if (info.getAbortSignal()?.aborted === true) disposeLate(value)
235
+ return value
236
+ })
237
+ }
238
+ }
239
+ }
240
+
183
241
  /**
184
242
  * 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.
243
+ * subscribe, so a rejection would end the whole socket instead of the one operation. A timeout aborts
244
+ * the operation's `abortSignal`, which resolvers read through `info.getAbortSignal()`, and work that
245
+ * settles after it is not abandoned open: a late subscription stream is returned.
186
246
  */
187
247
  async function withWsTimeout<T>(
188
- work: T | Promise<T>,
248
+ start: (abortSignal: AbortSignal) => T | Promise<T>,
249
+ outer: AbortSignal | null | undefined,
189
250
  timeoutMs: number,
190
251
  ): Promise<T | ExecutionResult> {
191
- if (!(work instanceof Promise) || timeoutMs === 0) return work
252
+ const controller = new AbortController()
253
+ const work = start(outer ? AbortSignal.any([outer, controller.signal]) : controller.signal)
254
+ if (!(work instanceof Promise)) return work
192
255
  let timer: ReturnType<typeof setTimeout> | undefined
256
+ let timedOut = false
193
257
  const timeout = new Promise<ExecutionResult>((resolve) => {
194
- timer = setTimeout(
195
- () => resolve({ errors: [new GraphQLError("Execution timed out.")] }),
196
- timeoutMs,
197
- )
258
+ timer = setTimeout(() => {
259
+ timedOut = true
260
+ const error = new GraphQLError("Execution timed out.")
261
+ // Resolved first: the abort can reject the work, and the race must settle on this result.
262
+ resolve({ errors: [error] })
263
+ controller.abort(error)
264
+ }, timeoutMs)
198
265
  })
199
266
  try {
200
267
  return await Promise.race([work, timeout])
201
268
  } finally {
202
269
  if (timer !== undefined) clearTimeout(timer)
270
+ if (timedOut) work.then(disposeLate, () => {})
203
271
  }
204
272
  }
205
273
 
@@ -226,10 +294,23 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
226
294
  const executionTimeoutMs = options.executionTimeoutMs ?? 10_000
227
295
  assertWsNumber(executionTimeoutMs, "executionTimeoutMs", 0)
228
296
  const executeWithTimeout: typeof execute = (args) =>
229
- withWsTimeout(execute(args), executionTimeoutMs)
297
+ executionTimeoutMs === 0
298
+ ? execute(args)
299
+ : withWsTimeout(
300
+ (abortSignal) => execute({ ...args, abortSignal }),
301
+ args.abortSignal,
302
+ executionTimeoutMs,
303
+ )
230
304
  const subscribeWithTimeout: typeof subscribe = (args) =>
231
- withWsTimeout(subscribe(args), executionTimeoutMs)
232
- const gqlServer = makeServer<Record<string, unknown>, { request: Request }>({
305
+ executionTimeoutMs === 0
306
+ ? subscribe(args)
307
+ : withWsTimeout(
308
+ (abortSignal) => subscribe({ ...args, abortSignal }),
309
+ args.abortSignal,
310
+ executionTimeoutMs,
311
+ )
312
+ returnLateSources(options.schema)
313
+ const gqlServer = makeServer<Record<string, unknown>, GraphqlWsExtra>({
233
314
  // Keep the raw escape hatch for auxiliary graphql-ws hooks, but make the package's safety
234
315
  // controls authoritative: callers cannot accidentally replace the bounded execute/subscribe
235
316
  // wrappers or the context/authentication seams with an unbounded implementation.
@@ -237,22 +318,41 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
237
318
  schema: options.schema,
238
319
  execute: executeWithTimeout,
239
320
  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
321
+ onConnect:
322
+ options.onConnect || options.authorize
323
+ ? async (ctx: GraphqlWsContext<Record<string, unknown>, GraphqlWsExtra>) => {
324
+ try {
325
+ if (options.authorize !== undefined && !(await options.authorize(ctx.extra.request)))
326
+ return false
327
+ return (
328
+ (await options.onConnect?.(
329
+ ctx.connectionParams as ConnectionInitMessage["payload"],
330
+ ctx.extra.request,
331
+ )) ?? true
332
+ )
333
+ } catch {
334
+ return false
335
+ }
249
336
  }
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,
337
+ : undefined,
338
+ // graphql-ws builds the context once per operation, just before executing it, so the operation's
339
+ // abort signal is attached to its execution arguments here.
340
+ context: async (
341
+ ctx: GraphqlWsContext<Record<string, unknown>, GraphqlWsExtra>,
342
+ id: string,
343
+ _payload: unknown,
344
+ args: ExecutionArgs,
345
+ ) => {
346
+ const controller = new AbortController()
347
+ ctx.extra.operations.set(id, controller)
348
+ args.abortSignal =
349
+ args.abortSignal === undefined || args.abortSignal === null
350
+ ? controller.signal
351
+ : AbortSignal.any([args.abortSignal, controller.signal])
352
+ return options.context === undefined
353
+ ? undefined
354
+ : await buildContext(options.context, { request: ctx.extra.request })
355
+ },
256
356
  })
257
357
 
258
358
  return {
@@ -264,11 +364,13 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
264
364
  const protocol =
265
365
  (ws.raw as { protocol?: string } | undefined)?.protocol ?? GRAPHQL_TRANSPORT_WS_PROTOCOL
266
366
  let onFrame: ((message: string) => void | Promise<void>) | undefined
367
+ const operations = new Map<string, AbortController>()
368
+ ws.data.operations = operations
267
369
  const closed = gqlServer.opened(
268
370
  {
269
371
  protocol,
270
372
  send: async (data: string) => {
271
- ws.send(outboundFrame(data, ws.data.subscriptions))
373
+ ws.send(outboundFrame(data, ws.data))
272
374
  },
273
375
  close: (code: number, reason: string) => {
274
376
  ws.close(code, reason)
@@ -277,7 +379,7 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
277
379
  onFrame = cb
278
380
  },
279
381
  },
280
- { request: ws.data.request },
382
+ { request: ws.data.request, operations },
281
383
  )
282
384
  ws.data.closed = closed
283
385
  ws.data.dispatch = async (message: string) => {
@@ -297,7 +399,13 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
297
399
  // this socket - including the client's `Complete` that stops the subscription. graphql-ws drives
298
400
  // its own sends and error frames from inside that loop; we only feed it inbound frames.
299
401
  const message = asText(data)
300
- const rejected = subscriptionFrameError(message, options.schema, limits)
402
+ // Until connection_init is admitted, graphql-ws answers a subscribe frame by closing with 4401
403
+ // without reading the query. Parsing or validating it here would hand an unauthenticated socket
404
+ // schema suggestions and synchronous validation work.
405
+ const rejected =
406
+ ws.data.acknowledged === true
407
+ ? subscriptionFrameError(message, options.schema, limits)
408
+ : undefined
301
409
  if (rejected !== undefined) {
302
410
  ws.send(
303
411
  JSON.stringify({
@@ -324,6 +432,8 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
324
432
  ws.data.subscriptions.add(frame.id)
325
433
  } else if (frame?.type === "complete" && typeof frame.id === "string") {
326
434
  ws.data.subscriptions.delete(frame.id)
435
+ ws.data.operations?.get(frame.id)?.abort(new GraphQLError("Operation completed."))
436
+ ws.data.operations?.delete(frame.id)
327
437
  }
328
438
  } catch {
329
439
  // graphql-ws owns malformed protocol-frame handling; pass it through unchanged.
@@ -333,6 +443,9 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
333
443
 
334
444
  close(ws: NifraWebSocket<GraphqlWsConnection>, code: number, reason: string): void {
335
445
  ws.data.subscriptions.clear()
446
+ for (const controller of ws.data.operations?.values() ?? [])
447
+ controller.abort(new GraphQLError("Socket closed."))
448
+ ws.data.operations?.clear()
336
449
  // The socket is gone: a failing iterator return() or onClose hook has no one left to answer.
337
450
  void Promise.resolve(ws.data.closed?.(code, reason)).catch(() => {})
338
451
  },