@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/LICENSE +21 -0
- package/README.md +14 -2
- package/dist/http.d.ts +19 -4
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +34 -13
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +37 -9
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +23 -1
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +119 -1
- package/dist/limits.js.map +1 -1
- package/dist/pubsub.d.ts +12 -2
- package/dist/pubsub.d.ts.map +1 -1
- package/dist/pubsub.js +41 -6
- package/dist/pubsub.js.map +1 -1
- package/dist/ws.d.ts +20 -1
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +106 -24
- package/dist/ws.js.map +1 -1
- package/package.json +2 -2
- package/src/http.ts +77 -22
- package/src/index.ts +54 -10
- package/src/limits.ts +138 -1
- package/src/pubsub.ts +55 -6
- package/src/ws.ts +158 -37
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
|
-
/**
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
return
|
|
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,
|
|
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
|
-
|
|
255
|
+
start: (abortSignal: AbortSignal) => T | Promise<T>,
|
|
256
|
+
outer: AbortSignal | null | undefined,
|
|
189
257
|
timeoutMs: number,
|
|
190
258
|
): Promise<T | ExecutionResult> {
|
|
191
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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:
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
:
|
|
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
|
|
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
|
-
|
|
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
|
},
|