@nifrajs/graphql 3.3.0 → 4.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/limits.ts ADDED
@@ -0,0 +1,215 @@
1
+ import { type DocumentNode, type GraphQLError, Kind, type SelectionNode } from "graphql"
2
+
3
+ export const DEFAULT_GRAPHQL_LIMITS = Object.freeze({
4
+ maxQueryBytes: 64 * 1024,
5
+ maxVariablesBytes: 256 * 1024,
6
+ maxDepth: 20,
7
+ maxAliases: 100,
8
+ maxComplexity: 1_000,
9
+ maxOperations: 10,
10
+ })
11
+
12
+ export interface GraphqlLimitsOptions {
13
+ readonly maxQueryBytes?: number
14
+ readonly maxVariablesBytes?: number
15
+ readonly maxDepth?: number
16
+ readonly maxAliases?: number
17
+ readonly maxComplexity?: number
18
+ readonly maxOperations?: number
19
+ }
20
+
21
+ export interface GraphqlLimits {
22
+ readonly maxQueryBytes: number
23
+ readonly maxVariablesBytes: number
24
+ readonly maxDepth: number
25
+ readonly maxAliases: number
26
+ readonly maxComplexity: number
27
+ readonly maxOperations: number
28
+ }
29
+
30
+ function limit(value: number | undefined, fallback: number, name: string, minimum: number): number {
31
+ const result = value ?? fallback
32
+ if (!Number.isSafeInteger(result) || result < minimum) {
33
+ throw new RangeError(`GraphQL ${name} must be a safe integer >= ${minimum}`)
34
+ }
35
+ return result
36
+ }
37
+
38
+ export function graphqlLimits(options: GraphqlLimitsOptions = {}): GraphqlLimits {
39
+ return {
40
+ maxQueryBytes: limit(
41
+ options.maxQueryBytes,
42
+ DEFAULT_GRAPHQL_LIMITS.maxQueryBytes,
43
+ "maxQueryBytes",
44
+ 1,
45
+ ),
46
+ maxVariablesBytes: limit(
47
+ options.maxVariablesBytes,
48
+ DEFAULT_GRAPHQL_LIMITS.maxVariablesBytes,
49
+ "maxVariablesBytes",
50
+ 0,
51
+ ),
52
+ maxDepth: limit(options.maxDepth, DEFAULT_GRAPHQL_LIMITS.maxDepth, "maxDepth", 1),
53
+ maxAliases: limit(options.maxAliases, DEFAULT_GRAPHQL_LIMITS.maxAliases, "maxAliases", 0),
54
+ maxComplexity: limit(
55
+ options.maxComplexity,
56
+ DEFAULT_GRAPHQL_LIMITS.maxComplexity,
57
+ "maxComplexity",
58
+ 1,
59
+ ),
60
+ maxOperations: limit(
61
+ options.maxOperations,
62
+ DEFAULT_GRAPHQL_LIMITS.maxOperations,
63
+ "maxOperations",
64
+ 1,
65
+ ),
66
+ }
67
+ }
68
+
69
+ export function utf8Bytes(value: string): number {
70
+ return new TextEncoder().encode(value).byteLength
71
+ }
72
+
73
+ export function variablesBytes(value: Record<string, unknown> | null): number {
74
+ return value === null ? 0 : utf8Bytes(JSON.stringify(value))
75
+ }
76
+
77
+ export interface GraphqlDocumentMetrics {
78
+ readonly operations: number
79
+ readonly depth: number
80
+ readonly aliases: number
81
+ readonly complexity: number
82
+ }
83
+
84
+ type SelectionMetrics = Omit<GraphqlDocumentMetrics, "operations">
85
+
86
+ const NO_SELECTIONS: SelectionMetrics = { depth: 0, aliases: 0, complexity: 0 }
87
+
88
+ /** `depth` counts from the selection set's own level, so one fragment measure holds at every spread. */
89
+ function selectionsMetrics(
90
+ selections: readonly SelectionNode[],
91
+ fragment: (name: string) => SelectionMetrics,
92
+ ): SelectionMetrics {
93
+ let depth = 0
94
+ let aliases = 0
95
+ let complexity = 0
96
+ for (const selection of selections) {
97
+ let nested: SelectionMetrics
98
+ if (selection.kind === Kind.FIELD) {
99
+ complexity += 1
100
+ if (selection.alias !== undefined) aliases += 1
101
+ nested =
102
+ selection.selectionSet === undefined
103
+ ? NO_SELECTIONS
104
+ : selectionsMetrics(selection.selectionSet.selections, fragment)
105
+ depth = Math.max(depth, nested.depth + 1)
106
+ } else {
107
+ nested =
108
+ selection.kind === Kind.INLINE_FRAGMENT
109
+ ? selectionsMetrics(selection.selectionSet.selections, fragment)
110
+ : fragment(selection.name.value)
111
+ depth = Math.max(depth, nested.depth)
112
+ }
113
+ aliases += nested.aliases
114
+ complexity += nested.complexity
115
+ }
116
+ return { depth, aliases, complexity }
117
+ }
118
+
119
+ /**
120
+ * Each fragment is measured once and reused at every spread. Expanding spreads in place costs a visit
121
+ * per expanded field, and a document whose fragments each spread the one before twice turns n short
122
+ * lines into 2^n visits, all of it synchronous before any limit can refuse the document.
123
+ */
124
+ export function documentMetrics(document: DocumentNode): GraphqlDocumentMetrics {
125
+ const definitions = new Map<string, readonly SelectionNode[]>()
126
+ for (const definition of document.definitions) {
127
+ if (definition.kind === Kind.FRAGMENT_DEFINITION) {
128
+ definitions.set(definition.name.value, definition.selectionSet.selections)
129
+ }
130
+ }
131
+ const measured = new Map<string, SelectionMetrics>()
132
+ const measuring = new Set<string>()
133
+ const fragment = (name: string): SelectionMetrics => {
134
+ const known = measured.get(name)
135
+ if (known !== undefined) return known
136
+ const selections = definitions.get(name)
137
+ // An unknown fragment and a cycle back into one being measured, both refused by validation,
138
+ // add nothing.
139
+ if (selections === undefined || measuring.has(name)) return NO_SELECTIONS
140
+ measuring.add(name)
141
+ const metrics = selectionsMetrics(selections, fragment)
142
+ measuring.delete(name)
143
+ measured.set(name, metrics)
144
+ return metrics
145
+ }
146
+ let operations = 0
147
+ let depth = 0
148
+ let aliases = 0
149
+ let complexity = 0
150
+ for (const definition of document.definitions) {
151
+ if (definition.kind !== Kind.OPERATION_DEFINITION) continue
152
+ operations += 1
153
+ const metrics = selectionsMetrics(definition.selectionSet.selections, fragment)
154
+ depth = Math.max(depth, metrics.depth)
155
+ aliases += metrics.aliases
156
+ complexity += metrics.complexity
157
+ }
158
+ return { operations, depth, aliases, complexity }
159
+ }
160
+
161
+ export function requestLimitError(
162
+ query: string,
163
+ variables: Record<string, unknown> | null,
164
+ limits: GraphqlLimits,
165
+ ): string | undefined {
166
+ if (utf8Bytes(query) > limits.maxQueryBytes) {
167
+ return `GraphQL query exceeds the ${limits.maxQueryBytes}-byte limit.`
168
+ }
169
+ if (variablesBytes(variables) > limits.maxVariablesBytes) {
170
+ return `GraphQL variables exceed the ${limits.maxVariablesBytes}-byte limit.`
171
+ }
172
+ return undefined
173
+ }
174
+
175
+ export function documentLimitError(
176
+ metrics: GraphqlDocumentMetrics,
177
+ limits: GraphqlLimits,
178
+ ): string | undefined {
179
+ if (metrics.operations > limits.maxOperations) {
180
+ return `GraphQL document exceeds the ${limits.maxOperations}-operation limit.`
181
+ }
182
+ if (metrics.depth > limits.maxDepth) {
183
+ return `GraphQL document exceeds the depth limit of ${limits.maxDepth}.`
184
+ }
185
+ if (metrics.aliases > limits.maxAliases) {
186
+ return `GraphQL document exceeds the ${limits.maxAliases}-alias limit.`
187
+ }
188
+ if (metrics.complexity > limits.maxComplexity) {
189
+ return `GraphQL document exceeds the complexity limit of ${limits.maxComplexity}.`
190
+ }
191
+ return undefined
192
+ }
193
+
194
+ export function maskExecutionError(error: GraphQLError): Record<string, unknown> {
195
+ return {
196
+ message: "Internal server error.",
197
+ ...(error.locations === undefined ? {} : { locations: error.locations }),
198
+ ...(error.path === undefined ? {} : { path: error.path }),
199
+ }
200
+ }
201
+
202
+ export function safeFormattedError(
203
+ error: GraphQLError,
204
+ phase: "request" | "execution",
205
+ formatter?: (error: GraphQLError, phase: "request" | "execution") => unknown,
206
+ ): unknown {
207
+ if (formatter !== undefined) {
208
+ try {
209
+ return formatter(error, phase)
210
+ } catch {
211
+ return phase === "execution" ? maskExecutionError(error) : error.toJSON()
212
+ }
213
+ }
214
+ return phase === "execution" ? maskExecutionError(error) : error.toJSON()
215
+ }
package/src/ws.ts CHANGED
@@ -14,7 +14,17 @@
14
14
  */
15
15
 
16
16
  import type { NifraWebSocket, WebSocketContext, WebSocketHandler } from "@nifrajs/core/ws"
17
- import { execute, type GraphQLSchema, subscribe } from "graphql"
17
+ import {
18
+ type DocumentNode,
19
+ type ExecutionResult,
20
+ execute,
21
+ GraphQLError,
22
+ type GraphQLSchema,
23
+ parse,
24
+ specifiedRules,
25
+ subscribe,
26
+ validate,
27
+ } from "graphql"
18
28
  import {
19
29
  type ConnectionInitMessage,
20
30
  GRAPHQL_TRANSPORT_WS_PROTOCOL,
@@ -23,6 +33,13 @@ import {
23
33
  type ServerOptions,
24
34
  } from "graphql-ws"
25
35
  import { buildContext, type GraphqlContextBuilder } from "./context.ts"
36
+ import {
37
+ documentLimitError,
38
+ documentMetrics,
39
+ type GraphqlLimits,
40
+ graphqlLimits,
41
+ requestLimitError,
42
+ } from "./limits.ts"
26
43
 
27
44
  /** Per-connection state carried on `ws.data` for the graphql-ws bridge. */
28
45
  interface GraphqlWsConnection {
@@ -31,6 +48,7 @@ interface GraphqlWsConnection {
31
48
  dispatch?: (message: string) => void | Promise<void>
32
49
  /** Tear the graphql-ws connection down (graphql-ws's `opened()` return value). */
33
50
  closed?: (code: number, reason: string) => void | Promise<void>
51
+ readonly subscriptions: Set<string>
34
52
  }
35
53
 
36
54
  export interface GraphqlWsOptions<
@@ -50,6 +68,17 @@ export interface GraphqlWsOptions<
50
68
  ) => boolean | Promise<boolean>
51
69
  /** Additional raw `graphql-ws` server options merged last (advanced escape hatch). */
52
70
  readonly serverOptions?: Partial<ServerOptions>
71
+ /** Same query/complexity limits as the HTTP transport. */
72
+ readonly maxQueryBytes?: number
73
+ readonly maxVariablesBytes?: number
74
+ readonly maxDepth?: number
75
+ readonly maxAliases?: number
76
+ readonly maxComplexity?: number
77
+ readonly maxOperations?: number
78
+ /** Maximum concurrent subscription operation ids per connection. Default 20. */
79
+ readonly maxSubscriptions?: number
80
+ /** Maximum time for initial execute/subscribe setup. Default 10 seconds. */
81
+ readonly executionTimeoutMs?: number
53
82
  }
54
83
 
55
84
  /** A frame is a string for JSON control messages; graphql-ws only speaks text frames. */
@@ -57,6 +86,123 @@ function asText(data: string | Uint8Array): string {
57
86
  return typeof data === "string" ? data : new TextDecoder().decode(data)
58
87
  }
59
88
 
89
+ function recordOf(value: unknown): Record<string, unknown> | undefined {
90
+ return typeof value === "object" && value !== null && !Array.isArray(value)
91
+ ? (value as Record<string, unknown>)
92
+ : undefined
93
+ }
94
+
95
+ function graphqlWsLimits(options: GraphqlWsOptions): GraphqlLimits {
96
+ return graphqlLimits(options)
97
+ }
98
+
99
+ function assertWsNumber(value: number, name: string, minimum: number): void {
100
+ if (!Number.isSafeInteger(value) || value < minimum) {
101
+ throw new RangeError(`GraphQL ${name} must be a safe integer >= ${minimum}`)
102
+ }
103
+ }
104
+
105
+ function subscriptionFrameError(
106
+ message: string,
107
+ schema: GraphQLSchema,
108
+ limits: GraphqlLimits,
109
+ ): { readonly id: unknown; readonly message: string } | undefined {
110
+ let frame: unknown
111
+ try {
112
+ frame = JSON.parse(message)
113
+ } catch {
114
+ return undefined
115
+ }
116
+ const envelope = recordOf(frame)
117
+ if (envelope?.type !== "subscribe") return undefined
118
+ const id = envelope.id
119
+ const payload = recordOf(envelope.payload)
120
+ const query = payload?.query
121
+ if (typeof query !== "string") return { id, message: "A subscription query is required." }
122
+ const variablesValue = payload?.variables
123
+ const variables =
124
+ variablesValue === undefined || variablesValue === null ? null : recordOf(variablesValue)
125
+ if (variablesValue !== undefined && variablesValue !== null && variables === undefined) {
126
+ return { id, message: "Subscription variables must be an object." }
127
+ }
128
+ const requestError = requestLimitError(query, variables ?? null, limits)
129
+ if (requestError !== undefined) return { id, message: requestError }
130
+ let document: DocumentNode
131
+ try {
132
+ document = parse(query)
133
+ } catch (error) {
134
+ return { id, message: error instanceof GraphQLError ? error.message : "Invalid GraphQL query." }
135
+ }
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 }
141
+ }
142
+
143
+ function maskWsError(error: unknown): unknown {
144
+ const record = recordOf(error)
145
+ if (record === undefined) return { message: "Internal server error." }
146
+ return {
147
+ message: "Internal server error.",
148
+ ...(record.locations === undefined ? {} : { locations: record.locations }),
149
+ ...(Array.isArray(record.path) ? { path: record.path } : {}),
150
+ }
151
+ }
152
+
153
+ /**
154
+ * A frame graphql-ws sends, execution errors masked. A `complete` or `error` frame ends its operation,
155
+ * so the id stops counting against `maxSubscriptions`: a client is not expected to complete an
156
+ * operation the server already ended.
157
+ */
158
+ function outboundFrame(message: string, subscriptions: Set<string>): string {
159
+ try {
160
+ const parsed = recordOf(JSON.parse(message))
161
+ if (parsed === undefined) return message
162
+ if ((parsed.type === "complete" || parsed.type === "error") && typeof parsed.id === "string") {
163
+ subscriptions.delete(parsed.id)
164
+ }
165
+ if (parsed.type === "next") {
166
+ const payload = recordOf(parsed.payload)
167
+ if (payload !== undefined && Array.isArray(payload.errors)) {
168
+ return JSON.stringify({
169
+ ...parsed,
170
+ payload: { ...payload, errors: payload.errors.map(maskWsError) },
171
+ })
172
+ }
173
+ }
174
+ if (parsed.type === "error" && Array.isArray(parsed.payload)) {
175
+ return JSON.stringify({ ...parsed, payload: parsed.payload.map(maskWsError) })
176
+ }
177
+ } catch {
178
+ return message
179
+ }
180
+ return message
181
+ }
182
+
183
+ /**
184
+ * 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.
186
+ */
187
+ async function withWsTimeout<T>(
188
+ work: T | Promise<T>,
189
+ timeoutMs: number,
190
+ ): Promise<T | ExecutionResult> {
191
+ if (!(work instanceof Promise) || timeoutMs === 0) return work
192
+ let timer: ReturnType<typeof setTimeout> | undefined
193
+ const timeout = new Promise<ExecutionResult>((resolve) => {
194
+ timer = setTimeout(
195
+ () => resolve({ errors: [new GraphQLError("Execution timed out.")] }),
196
+ timeoutMs,
197
+ )
198
+ })
199
+ try {
200
+ return await Promise.race([work, timeout])
201
+ } finally {
202
+ if (timer !== undefined) clearTimeout(timer)
203
+ }
204
+ }
205
+
60
206
  /**
61
207
  * Build a nifra {@link WebSocketHandler} that terminates the `graphql-transport-ws` protocol. Register
62
208
  * it on a route enabled with `.use(websocket())`:
@@ -74,28 +220,44 @@ function asText(data: string | Uint8Array): string {
74
220
  export function graphqlWebSocket<Context extends Record<string, unknown> = Record<string, unknown>>(
75
221
  options: GraphqlWsOptions<Context>,
76
222
  ): WebSocketHandler<GraphqlWsConnection> {
223
+ const limits = graphqlWsLimits(options)
224
+ const maxSubscriptions = options.maxSubscriptions ?? 20
225
+ assertWsNumber(maxSubscriptions, "maxSubscriptions", 1)
226
+ const executionTimeoutMs = options.executionTimeoutMs ?? 10_000
227
+ assertWsNumber(executionTimeoutMs, "executionTimeoutMs", 0)
228
+ const executeWithTimeout: typeof execute = (args) =>
229
+ withWsTimeout(execute(args), executionTimeoutMs)
230
+ const subscribeWithTimeout: typeof subscribe = (args) =>
231
+ withWsTimeout(subscribe(args), executionTimeoutMs)
77
232
  const gqlServer = makeServer<Record<string, unknown>, { request: Request }>({
233
+ // Keep the raw escape hatch for auxiliary graphql-ws hooks, but make the package's safety
234
+ // controls authoritative: callers cannot accidentally replace the bounded execute/subscribe
235
+ // wrappers or the context/authentication seams with an unbounded implementation.
236
+ ...options.serverOptions,
78
237
  schema: options.schema,
79
- execute,
80
- subscribe,
238
+ execute: executeWithTimeout,
239
+ subscribe: subscribeWithTimeout,
81
240
  onConnect: options.onConnect
82
241
  ? async (ctx: GraphqlWsContext<Record<string, unknown>, { request: Request }>) => {
83
- return await options.onConnect?.(
84
- ctx.connectionParams as ConnectionInitMessage["payload"],
85
- ctx.extra.request,
86
- )
242
+ try {
243
+ return await options.onConnect?.(
244
+ ctx.connectionParams as ConnectionInitMessage["payload"],
245
+ ctx.extra.request,
246
+ )
247
+ } catch {
248
+ return false
249
+ }
87
250
  }
88
251
  : undefined,
89
252
  context: options.context
90
253
  ? async (ctx: GraphqlWsContext<Record<string, unknown>, { request: Request }>) =>
91
254
  await buildContext(options.context, { request: ctx.extra.request })
92
255
  : undefined,
93
- ...options.serverOptions,
94
256
  })
95
257
 
96
258
  return {
97
259
  upgrade(c: WebSocketContext): GraphqlWsConnection {
98
- return { request: c.req }
260
+ return { request: c.req, subscriptions: new Set() }
99
261
  },
100
262
 
101
263
  open(ws: NifraWebSocket<GraphqlWsConnection>): void {
@@ -106,7 +268,7 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
106
268
  {
107
269
  protocol,
108
270
  send: async (data: string) => {
109
- ws.send(data)
271
+ ws.send(outboundFrame(data, ws.data.subscriptions))
110
272
  },
111
273
  close: (code: number, reason: string) => {
112
274
  ws.close(code, reason)
@@ -118,7 +280,15 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
118
280
  { request: ws.data.request },
119
281
  )
120
282
  ws.data.closed = closed
121
- ws.data.dispatch = (message: string) => onFrame?.(message)
283
+ ws.data.dispatch = async (message: string) => {
284
+ try {
285
+ await onFrame?.(message)
286
+ } catch {
287
+ // graphql-ws leaves a failed frame (a throwing context builder, say) to its socket adapter.
288
+ // Unhandled, the rejection would end a Bun or Node process; the protocol closes one socket.
289
+ ws.close(4500, "Internal server error")
290
+ }
291
+ }
122
292
  },
123
293
 
124
294
  message(ws: NifraWebSocket<GraphqlWsConnection>, data: string | Uint8Array): void {
@@ -126,11 +296,45 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
126
296
  // iterator completes (`await for await`), so awaiting it here would block every later frame on
127
297
  // this socket - including the client's `Complete` that stops the subscription. graphql-ws drives
128
298
  // its own sends and error frames from inside that loop; we only feed it inbound frames.
129
- void ws.data.dispatch?.(asText(data))
299
+ const message = asText(data)
300
+ const rejected = subscriptionFrameError(message, options.schema, limits)
301
+ if (rejected !== undefined) {
302
+ ws.send(
303
+ JSON.stringify({
304
+ id: rejected.id,
305
+ type: "error",
306
+ payload: [{ message: rejected.message }],
307
+ }),
308
+ )
309
+ return
310
+ }
311
+ try {
312
+ const frame = recordOf(JSON.parse(message))
313
+ if (frame?.type === "subscribe" && typeof frame.id === "string") {
314
+ if (ws.data.subscriptions.size >= maxSubscriptions) {
315
+ ws.send(
316
+ JSON.stringify({
317
+ id: frame.id,
318
+ type: "error",
319
+ payload: [{ message: "Subscription limit exceeded." }],
320
+ }),
321
+ )
322
+ return
323
+ }
324
+ ws.data.subscriptions.add(frame.id)
325
+ } else if (frame?.type === "complete" && typeof frame.id === "string") {
326
+ ws.data.subscriptions.delete(frame.id)
327
+ }
328
+ } catch {
329
+ // graphql-ws owns malformed protocol-frame handling; pass it through unchanged.
330
+ }
331
+ void ws.data.dispatch?.(message)
130
332
  },
131
333
 
132
334
  close(ws: NifraWebSocket<GraphqlWsConnection>, code: number, reason: string): void {
133
- void ws.data.closed?.(code, reason)
335
+ ws.data.subscriptions.clear()
336
+ // The socket is gone: a failing iterator return() or onClose hook has no one left to answer.
337
+ void Promise.resolve(ws.data.closed?.(code, reason)).catch(() => {})
134
338
  },
135
339
  }
136
340
  }