@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/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`; GET carries them as query-string params and may run
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
- /** Maximum asynchronous resolver execution time. Default 10 seconds. */
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 body parses and before execution. Return `false` to answer
100
- * `401` with a GraphQL error envelope and skip execution entirely.
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
- const validationErrors = validate(opts.schema, document, specifiedRules)
251
- if (validationErrors.length > 0) {
252
- return requestError("", 400, legacy, cors, validationErrors, opts.formatError)
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
- const execution = execute({
299
- schema: opts.schema,
300
- document,
301
- rootValue: opts.rootValue,
302
- contextValue,
303
- variableValues: params.variables ?? undefined,
304
- operationName: params.operationName ?? undefined,
305
- })
306
- result = (await withExecutionTimeout(execution, executionTimeoutMs)) as ExecutionResult
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
- async function withExecutionTimeout<T>(work: T | Promise<T>, timeoutMs: number): Promise<T> {
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(() => reject(new ExecutionTimeoutError()), timeoutMs)
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 { createPubSub, type GraphqlPubSub } from "./pubsub.ts"
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 the WS options (or `true` to
47
- * reuse `schema`/`context`). Requires the app to have `.use(websocket())` and `graphql-ws` installed.
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
- let wsOptions: GraphqlWsOptions<Record<string, unknown>>
91
- if (options.subscriptions === true) {
92
- const ctx = options.context as GraphqlContextBuilder<Record<string, unknown>> | undefined
93
- wsOptions =
94
- ctx !== undefined ? { schema: options.schema, context: ctx } : { schema: options.schema }
95
- } else {
96
- wsOptions = options.subscriptions
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 { type DocumentNode, type GraphQLError, Kind, type SelectionNode } from "graphql"
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
- /** Create an in-memory {@link GraphqlPubSub}. */
22
- export function createPubSub<Event = unknown>(): GraphqlPubSub<Event> {
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
- /** One subscriber: a bounded push queue bridged to an async iterator with backpressure via awaiting pulls. */
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>(onClose: () => void): Sink<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 (queue.length > 0) return Promise.resolve({ value: queue.shift() as Event, done: false })
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
  },