@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/http.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* (`@nifrajs/core/edge-kit`'s {@link readBodyFramed}).
|
|
7
7
|
*
|
|
8
8
|
* Spec: https://graphql.github.io/graphql-over-http/draft/. POST carries `{query, operationName?,
|
|
9
|
-
* variables?, extensions?}` as `application/json
|
|
9
|
+
* variables?, extensions?}` as `application/json` (any other media type answers 415, so a cross-site
|
|
10
|
+
* form cannot post a mutation); GET carries them as query-string params and may run
|
|
10
11
|
* **queries only** (never a mutation) so it stays a safe, cacheable idempotent method. Responses use the
|
|
11
12
|
* `application/graphql-response+json` media type with spec status codes by default; set
|
|
12
13
|
* `legacyJsonResponse` for the old always-200 `application/json` shape older clients expect.
|
|
@@ -35,7 +36,6 @@ import {
|
|
|
35
36
|
getOperationAST,
|
|
36
37
|
parse,
|
|
37
38
|
Source,
|
|
38
|
-
specifiedRules,
|
|
39
39
|
validate,
|
|
40
40
|
} from "graphql"
|
|
41
41
|
import {
|
|
@@ -49,8 +49,10 @@ import {
|
|
|
49
49
|
documentMetrics,
|
|
50
50
|
type GraphqlLimits,
|
|
51
51
|
graphqlLimits,
|
|
52
|
+
overlapLimitError,
|
|
52
53
|
requestLimitError,
|
|
53
54
|
safeFormattedError,
|
|
55
|
+
validationRules,
|
|
54
56
|
} from "./limits.ts"
|
|
55
57
|
|
|
56
58
|
const DEFAULT_MAX_BODY_BYTES = 1_000_000
|
|
@@ -84,10 +86,24 @@ export interface GraphqlHttpOptions<Context = unknown, Env = unknown> {
|
|
|
84
86
|
readonly maxComplexity?: number
|
|
85
87
|
/** Maximum operation definitions in one document. Default 10. */
|
|
86
88
|
readonly maxOperations?: number
|
|
87
|
-
/**
|
|
89
|
+
/**
|
|
90
|
+
* Maximum pairs of fields sharing a response name in one selection set, counted through fragments.
|
|
91
|
+
* Validation compares every such pair, so this bounds its cost. Default 2,000.
|
|
92
|
+
*/
|
|
93
|
+
readonly maxFieldOverlaps?: number
|
|
94
|
+
/**
|
|
95
|
+
* Maximum asynchronous resolver execution time. Default 10 seconds. The execution's abort signal,
|
|
96
|
+
* which a resolver reads through `info.getAbortSignal()`, is aborted at this deadline and when the
|
|
97
|
+
* request itself is aborted.
|
|
98
|
+
*/
|
|
88
99
|
readonly executionTimeoutMs?: number
|
|
89
100
|
/** Prototype-pollution policy for the parsed JSON body. Default `"reject"`. */
|
|
90
101
|
readonly protoPoisoning?: ProtoPoisoning
|
|
102
|
+
/**
|
|
103
|
+
* Answer introspection queries (`__schema`, `__type`). Default `true`. Set `false` to refuse them
|
|
104
|
+
* as a validation error (400); `__typename` still resolves. Shared with `subscriptions`.
|
|
105
|
+
*/
|
|
106
|
+
readonly introspection?: boolean
|
|
91
107
|
/** Optional root value passed to the executor. */
|
|
92
108
|
readonly rootValue?: unknown
|
|
93
109
|
/**
|
|
@@ -96,8 +112,8 @@ export interface GraphqlHttpOptions<Context = unknown, Env = unknown> {
|
|
|
96
112
|
*/
|
|
97
113
|
readonly allowedOrigins?: readonly string[]
|
|
98
114
|
/**
|
|
99
|
-
* Per-request authorization, run after the
|
|
100
|
-
* `401` with a GraphQL error envelope
|
|
115
|
+
* Per-request authorization, run after the origin check and before the body is read or parsed; a
|
|
116
|
+
* CORS preflight (`OPTIONS`) skips it. Return `false` to answer `401` with a GraphQL error envelope.
|
|
101
117
|
*/
|
|
102
118
|
readonly authorize?: (request: Request) => boolean | Promise<boolean>
|
|
103
119
|
/** Emit the legacy always-200 `application/json` response shape instead of `application/graphql-response+json`. */
|
|
@@ -195,6 +211,12 @@ function parseVariables(raw: unknown): Record<string, unknown> | null {
|
|
|
195
211
|
return parsed as Record<string, unknown>
|
|
196
212
|
}
|
|
197
213
|
|
|
214
|
+
/** `application/json` or a structured `+json` type, parameters ignored. */
|
|
215
|
+
function isJsonMediaType(header: string | null): boolean {
|
|
216
|
+
const essence = header?.split(";", 1)[0]?.trim().toLowerCase() ?? ""
|
|
217
|
+
return essence === "application/json" || /^application\/[a-z0-9.+-]+\+json$/.test(essence)
|
|
218
|
+
}
|
|
219
|
+
|
|
198
220
|
/** Read `{query, operationName, variables}` from a parsed POST body. Throws GraphQLError when malformed. */
|
|
199
221
|
function paramsFromBody(body: unknown): GraphqlParams {
|
|
200
222
|
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
@@ -247,12 +269,10 @@ async function run<Context, Env>(
|
|
|
247
269
|
return requestError("", 400, legacy, cors, [gqlErr], opts.formatError)
|
|
248
270
|
}
|
|
249
271
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const documentLimit = documentLimitError(documentMetrics(document), limits)
|
|
272
|
+
// Every limit runs before validate(): its overlap rule is quadratic, so a small document within the
|
|
273
|
+
// byte limit can hold the event loop for seconds.
|
|
274
|
+
const documentLimit =
|
|
275
|
+
documentLimitError(documentMetrics(document), limits) ?? overlapLimitError(document, limits)
|
|
256
276
|
if (documentLimit !== undefined) {
|
|
257
277
|
return requestError(
|
|
258
278
|
documentLimit,
|
|
@@ -264,6 +284,11 @@ async function run<Context, Env>(
|
|
|
264
284
|
)
|
|
265
285
|
}
|
|
266
286
|
|
|
287
|
+
const validationErrors = validate(opts.schema, document, validationRules(opts.introspection))
|
|
288
|
+
if (validationErrors.length > 0) {
|
|
289
|
+
return requestError("", 400, legacy, cors, validationErrors, opts.formatError)
|
|
290
|
+
}
|
|
291
|
+
|
|
267
292
|
// GET is idempotent and cacheable, so per the GraphQL-over-HTTP spec it may run queries only.
|
|
268
293
|
const operationAst = getOperationAST(document, params.operationName ?? undefined)
|
|
269
294
|
if (isGet && operationAst != null && operationAst.operation !== "query") {
|
|
@@ -295,15 +320,20 @@ async function run<Context, Env>(
|
|
|
295
320
|
|
|
296
321
|
let result: ExecutionResult
|
|
297
322
|
try {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
323
|
+
result = await withExecutionTimeout(
|
|
324
|
+
(abortSignal) =>
|
|
325
|
+
execute({
|
|
326
|
+
schema: opts.schema,
|
|
327
|
+
document,
|
|
328
|
+
rootValue: opts.rootValue,
|
|
329
|
+
contextValue,
|
|
330
|
+
variableValues: params.variables ?? undefined,
|
|
331
|
+
operationName: params.operationName ?? undefined,
|
|
332
|
+
abortSignal,
|
|
333
|
+
}),
|
|
334
|
+
request.signal,
|
|
335
|
+
executionTimeoutMs,
|
|
336
|
+
)
|
|
307
337
|
} catch (err) {
|
|
308
338
|
const gqlErr =
|
|
309
339
|
err instanceof GraphQLError
|
|
@@ -342,11 +372,23 @@ class ExecutionTimeoutError extends Error {
|
|
|
342
372
|
}
|
|
343
373
|
}
|
|
344
374
|
|
|
345
|
-
|
|
375
|
+
/** Run `start` under a deadline. A timeout rejects and aborts the signal resolvers were given. */
|
|
376
|
+
async function withExecutionTimeout<T>(
|
|
377
|
+
start: (abortSignal: AbortSignal) => T | Promise<T>,
|
|
378
|
+
outer: AbortSignal,
|
|
379
|
+
timeoutMs: number,
|
|
380
|
+
): Promise<T> {
|
|
381
|
+
const controller = new AbortController()
|
|
382
|
+
const work = start(AbortSignal.any([outer, controller.signal]))
|
|
346
383
|
if (!(work instanceof Promise) || timeoutMs === 0) return work
|
|
347
384
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
348
385
|
const timeout = new Promise<T>((_, reject) => {
|
|
349
|
-
timer = setTimeout(() =>
|
|
386
|
+
timer = setTimeout(() => {
|
|
387
|
+
const error = new ExecutionTimeoutError()
|
|
388
|
+
// Rejected first: the abort can reject the work, and the race must settle on the timeout.
|
|
389
|
+
reject(error)
|
|
390
|
+
controller.abort(error)
|
|
391
|
+
}, timeoutMs)
|
|
350
392
|
})
|
|
351
393
|
try {
|
|
352
394
|
return await Promise.race([work, timeout])
|
|
@@ -412,6 +454,19 @@ export async function respondGraphql<Context = unknown, Env = unknown>(
|
|
|
412
454
|
)
|
|
413
455
|
}
|
|
414
456
|
|
|
457
|
+
// Checked before the body is read: urlencoded, multipart and text/plain are what a browser sends
|
|
458
|
+
// cross-site without a preflight, so accepting one would let a form run a mutation with cookies.
|
|
459
|
+
if (!isJsonMediaType(request.headers.get("content-type"))) {
|
|
460
|
+
return requestError(
|
|
461
|
+
"Unsupported media type.",
|
|
462
|
+
415,
|
|
463
|
+
legacy,
|
|
464
|
+
cors,
|
|
465
|
+
[new GraphQLError("A POST body must be sent as application/json.")],
|
|
466
|
+
options.formatError,
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
|
|
415
470
|
// Read the JSON body through core's single bounded + proto-guarded framing lane.
|
|
416
471
|
return readBodyFramed<Response>(
|
|
417
472
|
request,
|
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,22 @@ 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 given = options.subscriptions === true ? sharedWsOptions(options) : options.subscriptions
|
|
126
|
+
// A schema hidden over HTTP stays hidden over the socket unless the WS options say otherwise.
|
|
127
|
+
const wsOptions =
|
|
128
|
+
options.introspection === false && given.introspection === undefined
|
|
129
|
+
? { ...given, introspection: false }
|
|
130
|
+
: given
|
|
131
|
+
if (
|
|
132
|
+
options.authorize !== undefined &&
|
|
133
|
+
wsOptions.authorize === undefined &&
|
|
134
|
+
wsOptions.onConnect === undefined
|
|
135
|
+
) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
"mountGraphql: `authorize` guards the HTTP endpoint only, and these subscription options " +
|
|
138
|
+
"admit every socket. Give them their own `authorize` or `onConnect`, or pass " +
|
|
139
|
+
"`subscriptions: true` to reuse `authorize`.",
|
|
140
|
+
)
|
|
97
141
|
}
|
|
98
142
|
app.ws(path, graphqlWebSocket(wsOptions))
|
|
99
143
|
}
|
package/src/limits.ts
CHANGED
|
@@ -1,4 +1,25 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type DocumentNode,
|
|
3
|
+
type GraphQLError,
|
|
4
|
+
Kind,
|
|
5
|
+
NoSchemaIntrospectionCustomRule,
|
|
6
|
+
type SelectionNode,
|
|
7
|
+
type SelectionSetNode,
|
|
8
|
+
specifiedRules,
|
|
9
|
+
type ValidationRule,
|
|
10
|
+
visit,
|
|
11
|
+
} from "graphql"
|
|
12
|
+
|
|
13
|
+
const RULES_WITHOUT_INTROSPECTION: readonly ValidationRule[] = [
|
|
14
|
+
...specifiedRules,
|
|
15
|
+
NoSchemaIntrospectionCustomRule,
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
/** The rules a transport validates with: graphql's own, plus a refusal of `__schema` and `__type`
|
|
19
|
+
* fields when `introspection` is `false`. */
|
|
20
|
+
export function validationRules(introspection: boolean | undefined): readonly ValidationRule[] {
|
|
21
|
+
return introspection === false ? RULES_WITHOUT_INTROSPECTION : specifiedRules
|
|
22
|
+
}
|
|
2
23
|
|
|
3
24
|
export const DEFAULT_GRAPHQL_LIMITS = Object.freeze({
|
|
4
25
|
maxQueryBytes: 64 * 1024,
|
|
@@ -7,6 +28,7 @@ export const DEFAULT_GRAPHQL_LIMITS = Object.freeze({
|
|
|
7
28
|
maxAliases: 100,
|
|
8
29
|
maxComplexity: 1_000,
|
|
9
30
|
maxOperations: 10,
|
|
31
|
+
maxFieldOverlaps: 2_000,
|
|
10
32
|
})
|
|
11
33
|
|
|
12
34
|
export interface GraphqlLimitsOptions {
|
|
@@ -16,6 +38,7 @@ export interface GraphqlLimitsOptions {
|
|
|
16
38
|
readonly maxAliases?: number
|
|
17
39
|
readonly maxComplexity?: number
|
|
18
40
|
readonly maxOperations?: number
|
|
41
|
+
readonly maxFieldOverlaps?: number
|
|
19
42
|
}
|
|
20
43
|
|
|
21
44
|
export interface GraphqlLimits {
|
|
@@ -25,6 +48,7 @@ export interface GraphqlLimits {
|
|
|
25
48
|
readonly maxAliases: number
|
|
26
49
|
readonly maxComplexity: number
|
|
27
50
|
readonly maxOperations: number
|
|
51
|
+
readonly maxFieldOverlaps: number
|
|
28
52
|
}
|
|
29
53
|
|
|
30
54
|
function limit(value: number | undefined, fallback: number, name: string, minimum: number): number {
|
|
@@ -63,6 +87,12 @@ export function graphqlLimits(options: GraphqlLimitsOptions = {}): GraphqlLimits
|
|
|
63
87
|
"maxOperations",
|
|
64
88
|
1,
|
|
65
89
|
),
|
|
90
|
+
maxFieldOverlaps: limit(
|
|
91
|
+
options.maxFieldOverlaps,
|
|
92
|
+
DEFAULT_GRAPHQL_LIMITS.maxFieldOverlaps,
|
|
93
|
+
"maxFieldOverlaps",
|
|
94
|
+
0,
|
|
95
|
+
),
|
|
66
96
|
}
|
|
67
97
|
}
|
|
68
98
|
|
|
@@ -172,6 +202,113 @@ export function requestLimitError(
|
|
|
172
202
|
return undefined
|
|
173
203
|
}
|
|
174
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Pairs of same-named fields across every selection set, the work graphql-js's
|
|
207
|
+
* OverlappingFieldsCanBeMerged rule does: it compares every two fields that share a response name in
|
|
208
|
+
* one selection set, counting the fields its fragment spreads and inline fragments bring in. A few
|
|
209
|
+
* thousand bytes of `b(x: 1) b(x: 2) ...` hold within the depth and complexity limits yet cost seconds
|
|
210
|
+
* of synchronous validation, so this is counted, and refused, before `validate()` runs. Measured
|
|
211
|
+
* once {@link documentLimitError} passes and no fragment is unused, the fields it walks are bounded
|
|
212
|
+
* by `maxComplexity`.
|
|
213
|
+
*/
|
|
214
|
+
export function fieldOverlaps(document: DocumentNode): number {
|
|
215
|
+
const fragments = new Map<string, readonly SelectionNode[]>()
|
|
216
|
+
for (const definition of document.definitions) {
|
|
217
|
+
if (definition.kind === Kind.FRAGMENT_DEFINITION) {
|
|
218
|
+
fragments.set(definition.name.value, definition.selectionSet.selections)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const fragmentNames = new Map<string, ReadonlyMap<string, number>>()
|
|
222
|
+
const expanding = new Set<string>()
|
|
223
|
+
const add = (
|
|
224
|
+
selections: readonly SelectionNode[],
|
|
225
|
+
names: Map<string, number>,
|
|
226
|
+
spread: Set<string>,
|
|
227
|
+
): void => {
|
|
228
|
+
for (const selection of selections) {
|
|
229
|
+
if (selection.kind === Kind.FIELD) {
|
|
230
|
+
const name = selection.alias?.value ?? selection.name.value
|
|
231
|
+
names.set(name, (names.get(name) ?? 0) + 1)
|
|
232
|
+
} else if (selection.kind === Kind.INLINE_FRAGMENT) {
|
|
233
|
+
add(selection.selectionSet.selections, names, spread)
|
|
234
|
+
} else if (!spread.has(selection.name.value)) {
|
|
235
|
+
spread.add(selection.name.value)
|
|
236
|
+
for (const [name, count] of fragmentNamesOf(selection.name.value)) {
|
|
237
|
+
names.set(name, (names.get(name) ?? 0) + count)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// An unknown fragment and a cycle, both refused by validation, contribute nothing.
|
|
243
|
+
const fragmentNamesOf = (fragment: string): ReadonlyMap<string, number> => {
|
|
244
|
+
const known = fragmentNames.get(fragment)
|
|
245
|
+
if (known !== undefined) return known
|
|
246
|
+
const selections = fragments.get(fragment)
|
|
247
|
+
if (selections === undefined || expanding.has(fragment)) return new Map()
|
|
248
|
+
expanding.add(fragment)
|
|
249
|
+
const names = new Map<string, number>()
|
|
250
|
+
add(selections, names, new Set([fragment]))
|
|
251
|
+
expanding.delete(fragment)
|
|
252
|
+
fragmentNames.set(fragment, names)
|
|
253
|
+
return names
|
|
254
|
+
}
|
|
255
|
+
let pairs = 0
|
|
256
|
+
visit(document, {
|
|
257
|
+
SelectionSet(node: SelectionSetNode) {
|
|
258
|
+
const names = new Map<string, number>()
|
|
259
|
+
add(node.selections, names, new Set())
|
|
260
|
+
for (const count of names.values()) pairs += (count * (count - 1)) / 2
|
|
261
|
+
},
|
|
262
|
+
})
|
|
263
|
+
return pairs
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The first fragment no operation reaches; graphql-js refuses it, and no limit above counts it. */
|
|
267
|
+
function unusedFragment(document: DocumentNode): string | undefined {
|
|
268
|
+
const spreads = new Map<string, readonly string[]>()
|
|
269
|
+
const pending: string[] = []
|
|
270
|
+
const spreadsIn = (node: DocumentNode["definitions"][number]): string[] => {
|
|
271
|
+
const names: string[] = []
|
|
272
|
+
visit(node, {
|
|
273
|
+
FragmentSpread(spread) {
|
|
274
|
+
names.push(spread.name.value)
|
|
275
|
+
},
|
|
276
|
+
})
|
|
277
|
+
return names
|
|
278
|
+
}
|
|
279
|
+
for (const definition of document.definitions) {
|
|
280
|
+
if (definition.kind === Kind.FRAGMENT_DEFINITION) {
|
|
281
|
+
spreads.set(definition.name.value, spreadsIn(definition))
|
|
282
|
+
} else if (definition.kind === Kind.OPERATION_DEFINITION) {
|
|
283
|
+
pending.push(...spreadsIn(definition))
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const reached = new Set<string>()
|
|
287
|
+
for (let name = pending.pop(); name !== undefined; name = pending.pop()) {
|
|
288
|
+
if (reached.has(name)) continue
|
|
289
|
+
reached.add(name)
|
|
290
|
+
pending.push(...(spreads.get(name) ?? []))
|
|
291
|
+
}
|
|
292
|
+
for (const name of spreads.keys()) if (!reached.has(name)) return name
|
|
293
|
+
return undefined
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Refuses, before `validate()`, the documents whose overlap validation is expensive: too many
|
|
298
|
+
* same-named field pairs, or a fragment no operation uses (its fields escape `maxComplexity`). Run it
|
|
299
|
+
* after {@link documentLimitError} passes.
|
|
300
|
+
*/
|
|
301
|
+
export function overlapLimitError(
|
|
302
|
+
document: DocumentNode,
|
|
303
|
+
limits: GraphqlLimits,
|
|
304
|
+
): string | undefined {
|
|
305
|
+
const unused = unusedFragment(document)
|
|
306
|
+
if (unused !== undefined) return `Fragment "${unused}" is never used.`
|
|
307
|
+
return fieldOverlaps(document) > limits.maxFieldOverlaps
|
|
308
|
+
? `GraphQL document repeats field names in one selection set beyond the limit of ${limits.maxFieldOverlaps} same-named field pairs.`
|
|
309
|
+
: undefined
|
|
310
|
+
}
|
|
311
|
+
|
|
175
312
|
export function documentLimitError(
|
|
176
313
|
metrics: GraphqlDocumentMetrics,
|
|
177
314
|
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
|
},
|