@nifrajs/graphql 3.3.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/src/limits.ts ADDED
@@ -0,0 +1,212 @@
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
+ function selectionsMetrics(
85
+ selections: readonly SelectionNode[],
86
+ fragments: ReadonlyMap<string, readonly SelectionNode[]>,
87
+ depth: number,
88
+ activeFragments: ReadonlySet<string>,
89
+ ): Omit<GraphqlDocumentMetrics, "operations"> {
90
+ let maxDepth = depth
91
+ let aliases = 0
92
+ let complexity = 0
93
+ for (const selection of selections) {
94
+ if (selection.kind === Kind.FIELD) {
95
+ complexity += 1
96
+ if (selection.alias !== undefined) aliases += 1
97
+ if (selection.selectionSet !== undefined) {
98
+ const nested = selectionsMetrics(
99
+ selection.selectionSet.selections,
100
+ fragments,
101
+ depth + 1,
102
+ activeFragments,
103
+ )
104
+ maxDepth = Math.max(maxDepth, nested.depth)
105
+ aliases += nested.aliases
106
+ complexity += nested.complexity
107
+ } else {
108
+ maxDepth = Math.max(maxDepth, depth + 1)
109
+ }
110
+ continue
111
+ }
112
+ if (selection.kind === Kind.INLINE_FRAGMENT) {
113
+ const nested = selectionsMetrics(
114
+ selection.selectionSet.selections,
115
+ fragments,
116
+ depth,
117
+ activeFragments,
118
+ )
119
+ maxDepth = Math.max(maxDepth, nested.depth)
120
+ aliases += nested.aliases
121
+ complexity += nested.complexity
122
+ continue
123
+ }
124
+ const nestedSelections = fragments.get(selection.name.value)
125
+ if (nestedSelections === undefined || activeFragments.has(selection.name.value)) continue
126
+ const nextActive = new Set(activeFragments)
127
+ nextActive.add(selection.name.value)
128
+ const nested = selectionsMetrics(nestedSelections, fragments, depth, nextActive)
129
+ maxDepth = Math.max(maxDepth, nested.depth)
130
+ aliases += nested.aliases
131
+ complexity += nested.complexity
132
+ }
133
+ return { depth: maxDepth, aliases, complexity }
134
+ }
135
+
136
+ export function documentMetrics(document: DocumentNode): GraphqlDocumentMetrics {
137
+ const fragments = new Map<string, readonly SelectionNode[]>()
138
+ for (const definition of document.definitions) {
139
+ if (definition.kind === Kind.FRAGMENT_DEFINITION) {
140
+ fragments.set(definition.name.value, definition.selectionSet.selections)
141
+ }
142
+ }
143
+ let operations = 0
144
+ let depth = 0
145
+ let aliases = 0
146
+ let complexity = 0
147
+ for (const definition of document.definitions) {
148
+ if (definition.kind !== Kind.OPERATION_DEFINITION) continue
149
+ operations += 1
150
+ const metrics = selectionsMetrics(definition.selectionSet.selections, fragments, 0, new Set())
151
+ depth = Math.max(depth, metrics.depth)
152
+ aliases += metrics.aliases
153
+ complexity += metrics.complexity
154
+ }
155
+ return { operations, depth, aliases, complexity }
156
+ }
157
+
158
+ export function requestLimitError(
159
+ query: string,
160
+ variables: Record<string, unknown> | null,
161
+ limits: GraphqlLimits,
162
+ ): string | undefined {
163
+ if (utf8Bytes(query) > limits.maxQueryBytes) {
164
+ return `GraphQL query exceeds the ${limits.maxQueryBytes}-byte limit.`
165
+ }
166
+ if (variablesBytes(variables) > limits.maxVariablesBytes) {
167
+ return `GraphQL variables exceed the ${limits.maxVariablesBytes}-byte limit.`
168
+ }
169
+ return undefined
170
+ }
171
+
172
+ export function documentLimitError(
173
+ metrics: GraphqlDocumentMetrics,
174
+ limits: GraphqlLimits,
175
+ ): string | undefined {
176
+ if (metrics.operations > limits.maxOperations) {
177
+ return `GraphQL document exceeds the ${limits.maxOperations}-operation limit.`
178
+ }
179
+ if (metrics.depth > limits.maxDepth) {
180
+ return `GraphQL document exceeds the depth limit of ${limits.maxDepth}.`
181
+ }
182
+ if (metrics.aliases > limits.maxAliases) {
183
+ return `GraphQL document exceeds the ${limits.maxAliases}-alias limit.`
184
+ }
185
+ if (metrics.complexity > limits.maxComplexity) {
186
+ return `GraphQL document exceeds the complexity limit of ${limits.maxComplexity}.`
187
+ }
188
+ return undefined
189
+ }
190
+
191
+ export function maskExecutionError(error: GraphQLError): Record<string, unknown> {
192
+ return {
193
+ message: "Internal server error.",
194
+ ...(error.locations === undefined ? {} : { locations: error.locations }),
195
+ ...(error.path === undefined ? {} : { path: error.path }),
196
+ }
197
+ }
198
+
199
+ export function safeFormattedError(
200
+ error: GraphQLError,
201
+ phase: "request" | "execution",
202
+ formatter?: (error: GraphQLError, phase: "request" | "execution") => unknown,
203
+ ): unknown {
204
+ if (formatter !== undefined) {
205
+ try {
206
+ return formatter(error, phase)
207
+ } catch {
208
+ return phase === "execution" ? maskExecutionError(error) : error.toJSON()
209
+ }
210
+ }
211
+ return phase === "execution" ? maskExecutionError(error) : error.toJSON()
212
+ }
package/src/ws.ts CHANGED
@@ -14,7 +14,16 @@
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
+ execute,
20
+ GraphQLError,
21
+ type GraphQLSchema,
22
+ parse,
23
+ specifiedRules,
24
+ subscribe,
25
+ validate,
26
+ } from "graphql"
18
27
  import {
19
28
  type ConnectionInitMessage,
20
29
  GRAPHQL_TRANSPORT_WS_PROTOCOL,
@@ -23,6 +32,13 @@ import {
23
32
  type ServerOptions,
24
33
  } from "graphql-ws"
25
34
  import { buildContext, type GraphqlContextBuilder } from "./context.ts"
35
+ import {
36
+ documentLimitError,
37
+ documentMetrics,
38
+ type GraphqlLimits,
39
+ graphqlLimits,
40
+ requestLimitError,
41
+ } from "./limits.ts"
26
42
 
27
43
  /** Per-connection state carried on `ws.data` for the graphql-ws bridge. */
28
44
  interface GraphqlWsConnection {
@@ -31,6 +47,7 @@ interface GraphqlWsConnection {
31
47
  dispatch?: (message: string) => void | Promise<void>
32
48
  /** Tear the graphql-ws connection down (graphql-ws's `opened()` return value). */
33
49
  closed?: (code: number, reason: string) => void | Promise<void>
50
+ readonly subscriptions: Set<string>
34
51
  }
35
52
 
36
53
  export interface GraphqlWsOptions<
@@ -50,6 +67,17 @@ export interface GraphqlWsOptions<
50
67
  ) => boolean | Promise<boolean>
51
68
  /** Additional raw `graphql-ws` server options merged last (advanced escape hatch). */
52
69
  readonly serverOptions?: Partial<ServerOptions>
70
+ /** Same query/complexity limits as the HTTP transport. */
71
+ readonly maxQueryBytes?: number
72
+ readonly maxVariablesBytes?: number
73
+ readonly maxDepth?: number
74
+ readonly maxAliases?: number
75
+ readonly maxComplexity?: number
76
+ readonly maxOperations?: number
77
+ /** Maximum concurrent subscription operation ids per connection. Default 20. */
78
+ readonly maxSubscriptions?: number
79
+ /** Maximum time for initial execute/subscribe setup. Default 10 seconds. */
80
+ readonly executionTimeoutMs?: number
53
81
  }
54
82
 
55
83
  /** A frame is a string for JSON control messages; graphql-ws only speaks text frames. */
@@ -57,6 +85,105 @@ function asText(data: string | Uint8Array): string {
57
85
  return typeof data === "string" ? data : new TextDecoder().decode(data)
58
86
  }
59
87
 
88
+ function recordOf(value: unknown): Record<string, unknown> | undefined {
89
+ return typeof value === "object" && value !== null && !Array.isArray(value)
90
+ ? (value as Record<string, unknown>)
91
+ : undefined
92
+ }
93
+
94
+ function graphqlWsLimits(options: GraphqlWsOptions): GraphqlLimits {
95
+ return graphqlLimits(options)
96
+ }
97
+
98
+ function assertWsNumber(value: number, name: string, minimum: number): void {
99
+ if (!Number.isSafeInteger(value) || value < minimum) {
100
+ throw new RangeError(`GraphQL ${name} must be a safe integer >= ${minimum}`)
101
+ }
102
+ }
103
+
104
+ function subscriptionFrameError(
105
+ message: string,
106
+ schema: GraphQLSchema,
107
+ limits: GraphqlLimits,
108
+ ): { readonly id: unknown; readonly message: string } | undefined {
109
+ let frame: unknown
110
+ try {
111
+ frame = JSON.parse(message)
112
+ } catch {
113
+ return undefined
114
+ }
115
+ const envelope = recordOf(frame)
116
+ if (envelope?.type !== "subscribe") return undefined
117
+ const id = envelope.id
118
+ const payload = recordOf(envelope.payload)
119
+ const query = payload?.query
120
+ if (typeof query !== "string") return { id, message: "A subscription query is required." }
121
+ const variablesValue = payload?.variables
122
+ const variables =
123
+ variablesValue === undefined || variablesValue === null ? null : recordOf(variablesValue)
124
+ if (variablesValue !== undefined && variablesValue !== null && variables === undefined) {
125
+ return { id, message: "Subscription variables must be an object." }
126
+ }
127
+ const requestError = requestLimitError(query, variables ?? null, limits)
128
+ if (requestError !== undefined) return { id, message: requestError }
129
+ let document: DocumentNode
130
+ try {
131
+ document = parse(query)
132
+ } catch (error) {
133
+ return { id, message: error instanceof GraphQLError ? error.message : "Invalid GraphQL query." }
134
+ }
135
+ const validationErrors = validate(schema, document, specifiedRules)
136
+ if (validationErrors.length > 0)
137
+ return { id, message: validationErrors[0]?.message ?? "Invalid GraphQL query." }
138
+ const limitError = documentLimitError(documentMetrics(document), limits)
139
+ return limitError === undefined ? undefined : { id, message: limitError }
140
+ }
141
+
142
+ function maskWsError(error: unknown): unknown {
143
+ const record = recordOf(error)
144
+ if (record === undefined) return { message: "Internal server error." }
145
+ return {
146
+ message: "Internal server error.",
147
+ ...(record.locations === undefined ? {} : { locations: record.locations }),
148
+ ...(Array.isArray(record.path) ? { path: record.path } : {}),
149
+ }
150
+ }
151
+
152
+ function maskWsFrame(message: string): string {
153
+ try {
154
+ const parsed = recordOf(JSON.parse(message))
155
+ if (parsed === undefined) return message
156
+ if (parsed.type === "next") {
157
+ const payload = recordOf(parsed.payload)
158
+ if (payload !== undefined && Array.isArray(payload.errors)) {
159
+ return JSON.stringify({
160
+ ...parsed,
161
+ payload: { ...payload, errors: payload.errors.map(maskWsError) },
162
+ })
163
+ }
164
+ }
165
+ if (parsed.type === "error" && Array.isArray(parsed.payload)) {
166
+ return JSON.stringify({ ...parsed, payload: parsed.payload.map(maskWsError) })
167
+ }
168
+ } catch {
169
+ return message
170
+ }
171
+ return message
172
+ }
173
+
174
+ async function withWsTimeout<T>(work: T | Promise<T>, timeoutMs: number): Promise<T> {
175
+ if (!(work instanceof Promise) || timeoutMs === 0) return work
176
+ let timer: ReturnType<typeof setTimeout> | undefined
177
+ const timeout = new Promise<T>((_, reject) => {
178
+ timer = setTimeout(() => reject(new GraphQLError("Execution timed out.")), timeoutMs)
179
+ })
180
+ try {
181
+ return await Promise.race([work, timeout])
182
+ } finally {
183
+ if (timer !== undefined) clearTimeout(timer)
184
+ }
185
+ }
186
+
60
187
  /**
61
188
  * Build a nifra {@link WebSocketHandler} that terminates the `graphql-transport-ws` protocol. Register
62
189
  * it on a route enabled with `.use(websocket())`:
@@ -74,10 +201,23 @@ function asText(data: string | Uint8Array): string {
74
201
  export function graphqlWebSocket<Context extends Record<string, unknown> = Record<string, unknown>>(
75
202
  options: GraphqlWsOptions<Context>,
76
203
  ): WebSocketHandler<GraphqlWsConnection> {
204
+ const limits = graphqlWsLimits(options)
205
+ const maxSubscriptions = options.maxSubscriptions ?? 20
206
+ assertWsNumber(maxSubscriptions, "maxSubscriptions", 1)
207
+ const executionTimeoutMs = options.executionTimeoutMs ?? 10_000
208
+ assertWsNumber(executionTimeoutMs, "executionTimeoutMs", 0)
209
+ const executeWithTimeout: typeof execute = (args) =>
210
+ withWsTimeout(execute(args), executionTimeoutMs)
211
+ const subscribeWithTimeout: typeof subscribe = (args) =>
212
+ withWsTimeout(subscribe(args), executionTimeoutMs)
77
213
  const gqlServer = makeServer<Record<string, unknown>, { request: Request }>({
214
+ // Keep the raw escape hatch for auxiliary graphql-ws hooks, but make the package's safety
215
+ // controls authoritative: callers cannot accidentally replace the bounded execute/subscribe
216
+ // wrappers or the context/authentication seams with an unbounded implementation.
217
+ ...options.serverOptions,
78
218
  schema: options.schema,
79
- execute,
80
- subscribe,
219
+ execute: executeWithTimeout,
220
+ subscribe: subscribeWithTimeout,
81
221
  onConnect: options.onConnect
82
222
  ? async (ctx: GraphqlWsContext<Record<string, unknown>, { request: Request }>) => {
83
223
  return await options.onConnect?.(
@@ -90,12 +230,11 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
90
230
  ? async (ctx: GraphqlWsContext<Record<string, unknown>, { request: Request }>) =>
91
231
  await buildContext(options.context, { request: ctx.extra.request })
92
232
  : undefined,
93
- ...options.serverOptions,
94
233
  })
95
234
 
96
235
  return {
97
236
  upgrade(c: WebSocketContext): GraphqlWsConnection {
98
- return { request: c.req }
237
+ return { request: c.req, subscriptions: new Set() }
99
238
  },
100
239
 
101
240
  open(ws: NifraWebSocket<GraphqlWsConnection>): void {
@@ -106,7 +245,7 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
106
245
  {
107
246
  protocol,
108
247
  send: async (data: string) => {
109
- ws.send(data)
248
+ ws.send(maskWsFrame(data))
110
249
  },
111
250
  close: (code: number, reason: string) => {
112
251
  ws.close(code, reason)
@@ -126,10 +265,43 @@ export function graphqlWebSocket<Context extends Record<string, unknown> = Recor
126
265
  // iterator completes (`await for await`), so awaiting it here would block every later frame on
127
266
  // this socket - including the client's `Complete` that stops the subscription. graphql-ws drives
128
267
  // its own sends and error frames from inside that loop; we only feed it inbound frames.
129
- void ws.data.dispatch?.(asText(data))
268
+ const message = asText(data)
269
+ const rejected = subscriptionFrameError(message, options.schema, limits)
270
+ if (rejected !== undefined) {
271
+ ws.send(
272
+ JSON.stringify({
273
+ id: rejected.id,
274
+ type: "error",
275
+ payload: [{ message: rejected.message }],
276
+ }),
277
+ )
278
+ return
279
+ }
280
+ try {
281
+ const frame = recordOf(JSON.parse(message))
282
+ if (frame?.type === "subscribe" && typeof frame.id === "string") {
283
+ if (ws.data.subscriptions.size >= maxSubscriptions) {
284
+ ws.send(
285
+ JSON.stringify({
286
+ id: frame.id,
287
+ type: "error",
288
+ payload: [{ message: "Subscription limit exceeded." }],
289
+ }),
290
+ )
291
+ return
292
+ }
293
+ ws.data.subscriptions.add(frame.id)
294
+ } else if (frame?.type === "complete" && typeof frame.id === "string") {
295
+ ws.data.subscriptions.delete(frame.id)
296
+ }
297
+ } catch {
298
+ // graphql-ws owns malformed protocol-frame handling; pass it through unchanged.
299
+ }
300
+ void ws.data.dispatch?.(message)
130
301
  },
131
302
 
132
303
  close(ws: NifraWebSocket<GraphqlWsConnection>, code: number, reason: string): void {
304
+ ws.data.subscriptions.clear()
133
305
  void ws.data.closed?.(code, reason)
134
306
  },
135
307
  }