@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/README.md +13 -2
- package/dist/http.d.ts +14 -4
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +32 -11
- 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 +33 -9
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +19 -0
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +110 -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 +15 -1
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +102 -21
- package/dist/ws.js.map +1 -1
- package/package.json +2 -2
- package/src/http.ts +71 -21
- package/src/index.ts +50 -10
- package/src/limits.ts +124 -1
- package/src/pubsub.ts +55 -6
- package/src/ws.ts +148 -35
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 {
|
|
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
|
|
47
|
-
*
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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 {
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
/**
|
|
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>(
|
|
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 (
|
|
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
|
-
/**
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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,
|
|
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
|
-
|
|
248
|
+
start: (abortSignal: AbortSignal) => T | Promise<T>,
|
|
249
|
+
outer: AbortSignal | null | undefined,
|
|
189
250
|
timeoutMs: number,
|
|
190
251
|
): Promise<T | ExecutionResult> {
|
|
191
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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:
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
:
|
|
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
|
|
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
|
-
|
|
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
|
},
|