@znight/graphql 0.0.0 → 0.2.1

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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1346 -0
  3. package/dist/compiler.d.mts +114 -0
  4. package/dist/compiler.mjs +4599 -0
  5. package/dist/compiler.mjs.map +1 -0
  6. package/dist/index.d.mts +583 -0
  7. package/dist/index.mjs +134 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/dist/names-BlzDM5cF.mjs +111 -0
  10. package/dist/names-BlzDM5cF.mjs.map +1 -0
  11. package/dist/plugin-CR_wVfRU.mjs +3445 -0
  12. package/dist/plugin-CR_wVfRU.mjs.map +1 -0
  13. package/dist/testing.d.mts +101 -0
  14. package/dist/testing.mjs +306 -0
  15. package/dist/testing.mjs.map +1 -0
  16. package/dist/types-DQ713UgI.d.mts +266 -0
  17. package/package.json +97 -1
  18. package/src/compiler/analyze.ts +727 -0
  19. package/src/compiler/builder.ts +1561 -0
  20. package/src/compiler/checks.ts +105 -0
  21. package/src/compiler/conformance.ts +1068 -0
  22. package/src/compiler/constants.ts +80 -0
  23. package/src/compiler/data.ts +83 -0
  24. package/src/compiler/defaults.ts +92 -0
  25. package/src/compiler/discriminators.ts +53 -0
  26. package/src/compiler/docs.ts +55 -0
  27. package/src/compiler/generate.ts +419 -0
  28. package/src/compiler/graphql-js.ts +42 -0
  29. package/src/compiler/ids.ts +128 -0
  30. package/src/compiler/index.ts +98 -0
  31. package/src/compiler/locate.ts +82 -0
  32. package/src/compiler/model.ts +137 -0
  33. package/src/compiler/options.ts +85 -0
  34. package/src/compiler/scalars.ts +138 -0
  35. package/src/compiler/schema-first.ts +595 -0
  36. package/src/compiler/sdl-source.ts +529 -0
  37. package/src/compiler/sdl-view.ts +61 -0
  38. package/src/compiler/suggest.ts +39 -0
  39. package/src/context.ts +181 -0
  40. package/src/decisions.ts +169 -0
  41. package/src/decorators.ts +202 -0
  42. package/src/errors.ts +89 -0
  43. package/src/execution.ts +1064 -0
  44. package/src/format.ts +285 -0
  45. package/src/index.ts +89 -0
  46. package/src/limits.ts +177 -0
  47. package/src/loaders.ts +149 -0
  48. package/src/memory-pubsub.ts +133 -0
  49. package/src/metadata.ts +222 -0
  50. package/src/names.ts +47 -0
  51. package/src/options.ts +331 -0
  52. package/src/pipeline.ts +338 -0
  53. package/src/plugin.ts +579 -0
  54. package/src/pubsub.ts +36 -0
  55. package/src/results.ts +206 -0
  56. package/src/scalars.ts +138 -0
  57. package/src/schema-hash.ts +18 -0
  58. package/src/schema.ts +635 -0
  59. package/src/sdl.ts +530 -0
  60. package/src/testing.ts +454 -0
  61. package/src/tokens.ts +22 -0
  62. package/src/transport/abort.ts +80 -0
  63. package/src/transport/accept.ts +155 -0
  64. package/src/transport/graphiql.ts +106 -0
  65. package/src/transport/http.ts +373 -0
  66. package/src/transport/sse.ts +45 -0
  67. package/src/type-ref.ts +89 -0
  68. package/src/types.ts +225 -0
  69. package/src/validate-args.ts +48 -0
package/src/testing.ts ADDED
@@ -0,0 +1,454 @@
1
+ /**
2
+ * `@znight/graphql/testing`: test helpers for GraphQL applications (RFC 0005 §5.12).
3
+ *
4
+ * - `executeOperation()` sends an operation through the whole HTTP pipeline of a `TestApp`
5
+ * (`app.inject()`: guards, hooks, every adapter) or, without HTTP transport, through
6
+ * `GraphQLPlugin.execute()`;
7
+ * - `subscribeOperation()` streams an operation over GraphQL over SSE in memory, or through
8
+ * `GraphQLPlugin.subscribe()` without HTTP transport;
9
+ * - `fakeLoader()` builds a `Loader<L>` whose `load` / `loadMany` are spies of the running test
10
+ * framework (`bun:test`, Vitest, Jest, or the built-in spy of `@znight/testing`).
11
+ *
12
+ * Needs `@znight/testing` (an optional peer dependency of `@znight/graphql`).
13
+ */
14
+ import type { Plugin } from '@znight/core'
15
+ import type { SseItem } from '@znight/http'
16
+ import type { MockFunction, TestApp } from '@znight/testing'
17
+ import type { FormattedExecutionResult, GraphQLFormattedError } from 'graphql'
18
+ import type { GraphQLRequest, GraphQLResultStream } from './execution.ts'
19
+ import type { BatchLoad, BatchLoader, LoaderKey, LoaderValue } from './types.ts'
20
+
21
+ import { parseSseStream } from '@znight/http'
22
+ import { resolveMockFn } from '@znight/testing'
23
+
24
+ import { endpointOf, executorOf, type GraphQLPlugin } from './plugin.ts'
25
+
26
+ /** An operation for {@link executeOperation} and {@link subscribeOperation}. */
27
+ export interface OperationRequest {
28
+ readonly query: string
29
+ readonly variables?: Readonly<Record<string, unknown>>
30
+ readonly operationName?: string
31
+ readonly extensions?: Readonly<Record<string, unknown>>
32
+ /**
33
+ * Request headers (`x-api-key`, `accept`...), merged over the defaults (`content-type:
34
+ * application/json`, and `accept: application/graphql-response+json, application/json;q=0.9`
35
+ * or `text/event-stream`). Ignored without HTTP transport.
36
+ */
37
+ readonly headers?: Readonly<Record<string, string>>
38
+ }
39
+
40
+ /** A GraphQL result: `data`, `errors` and `extensions` when present. */
41
+ export interface OperationResult<TData = Record<string, unknown>> {
42
+ readonly data?: TData | null
43
+ readonly errors?: readonly GraphQLFormattedError[]
44
+ readonly extensions?: Readonly<Record<string, unknown>>
45
+ }
46
+
47
+ /** What {@link executeOperation} resolves with. */
48
+ export interface OperationResponse<TData = Record<string, unknown>> extends OperationResult<TData> {
49
+ /**
50
+ * The HTTP status: 200 for an executed operation (field errors included), 400 for a request
51
+ * error (the default `accept` is `application/graphql-response+json`), 403 for an HTTP guard...
52
+ * Without HTTP transport, the status the endpoint would answer.
53
+ */
54
+ readonly status: number
55
+ readonly headers: Readonly<Record<string, string | readonly string[] | undefined>>
56
+ /** The raw body (an HTTP error body is not a GraphQL result: `data` and `errors` are then absent). */
57
+ readonly body: string
58
+ }
59
+
60
+ /** Options of {@link OperationStream.next}. */
61
+ export interface OperationStreamWaitOptions {
62
+ /** Maximum wait for the next result (ms). Default 2 000. */
63
+ readonly timeoutMs?: number
64
+ }
65
+
66
+ /**
67
+ * The results of {@link subscribeOperation}, one per `next` event. Ends after `complete` (or the
68
+ * single result of a query, or a request error). `return()` disconnects: the server sees a client
69
+ * abort (`ctx.signal`, then the source's `return()`).
70
+ */
71
+ export interface OperationStream<TData = Record<string, unknown>>
72
+ extends AsyncIterableIterator<OperationResult<TData>>,
73
+ AsyncDisposable {
74
+ /** HTTP status of the stream (200), or of a request error (400, 405, 503...). */
75
+ readonly status: number
76
+ readonly headers: Readonly<Record<string, string | readonly string[] | undefined>>
77
+ /** The next result. Rejects when none arrives within `timeoutMs` (2 000 ms by default). */
78
+ next(options?: OperationStreamWaitOptions): Promise<IteratorResult<OperationResult<TData>, undefined>>
79
+ /** Ends the stream (disconnects). */
80
+ return(): Promise<IteratorResult<OperationResult<TData>, undefined>>
81
+ }
82
+
83
+ /** The application `executeOperation()` and `subscribeOperation()` run against: a `TestApp`. */
84
+ export type GraphQLTestApp = Pick<TestApp, 'app' | 'inject' | 'injectStream'>
85
+
86
+ const DEFAULT_TIMEOUT_MS = 2_000
87
+ const RESPONSE_ACCEPT = 'application/graphql-response+json, application/json;q=0.9'
88
+ const DONE: IteratorResult<never, undefined> = Object.freeze({ done: true, value: undefined })
89
+
90
+ /**
91
+ * Executes a query or a mutation through the whole HTTP pipeline (`app.inject()`: `POST` on the
92
+ * endpoint), or through `GraphQLPlugin` without HTTP transport.
93
+ *
94
+ * ```ts
95
+ * const res = await executeOperation(app, { query: '{ books { title } }', headers: { 'x-api-key': KEY } })
96
+ * expect(res.status).toBe(200)
97
+ * expect(res.errors).toBeUndefined()
98
+ * ```
99
+ */
100
+ export async function executeOperation<TData = Record<string, unknown>>(
101
+ app: GraphQLTestApp,
102
+ request: OperationRequest,
103
+ ): Promise<OperationResponse<TData>> {
104
+ const plugin = graphqlPlugin(app, 'executeOperation()')
105
+ const body = operationBody(request, 'executeOperation()')
106
+ const endpoint = endpointOf(plugin)
107
+ if (endpoint === undefined) {
108
+ const response = await executorOf(plugin).execute(body)
109
+ return {
110
+ ...pick<TData>(response.result),
111
+ status: response.status,
112
+ headers: { ...response.headers },
113
+ body: JSON.stringify(response.result),
114
+ }
115
+ }
116
+ const response = await app.inject({
117
+ method: 'POST',
118
+ url: endpoint,
119
+ headers: headers(request, RESPONSE_ACCEPT),
120
+ body: JSON.stringify(body),
121
+ })
122
+ return {
123
+ ...parseResult<TData>(response.body),
124
+ status: response.status,
125
+ headers: response.headers,
126
+ body: response.body,
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Starts an operation as a stream: GraphQL over SSE in memory (`app.injectStream()`: `POST` with
132
+ * `accept: text/event-stream`), or `GraphQLPlugin.subscribe()` without HTTP transport (no HTTP
133
+ * guards then). Resolves once the subscription is established: its source has started (a resolver
134
+ * written as an async generator has subscribed to the PubSub, when it subscribes before its first
135
+ * `await`), so events published from then on
136
+ * are received, even before the first `next()`.
137
+ *
138
+ * ```ts
139
+ * await using events = await subscribeOperation(app, { query: 'subscription { bookAdded { title } }' })
140
+ * await executeOperation(app, { query: ADD_DUNE })
141
+ * expect((await events.next()).value).toEqual({ data: { bookAdded: { title: 'Dune' } } })
142
+ * ```
143
+ */
144
+ export async function subscribeOperation<TData = Record<string, unknown>>(
145
+ app: GraphQLTestApp,
146
+ request: OperationRequest,
147
+ ): Promise<OperationStream<TData>> {
148
+ const plugin = graphqlPlugin(app, 'subscribeOperation()')
149
+ const body = operationBody(request, 'subscribeOperation()')
150
+ const endpoint = endpointOf(plugin)
151
+ const label = describeOperation(body.query)
152
+ if (endpoint === undefined) {
153
+ const outcome = await executorOf(plugin).subscribe(body)
154
+ if ('status' in outcome) return new SingleResult(outcome.status, {}, pick<TData>(outcome.result))
155
+ return new Results<TData>(200, {}, directSource(outcome), label)
156
+ }
157
+ const response = await app.injectStream({
158
+ method: 'POST',
159
+ url: endpoint,
160
+ headers: headers(request, 'text/event-stream'),
161
+ body: JSON.stringify(body),
162
+ })
163
+ const type = response.headers['content-type']
164
+ if (typeof type !== 'string' || !type.startsWith('text/event-stream')) {
165
+ const text = await new Response(response.body).text()
166
+ return new SingleResult(response.status, response.headers, parseResult<TData>(text))
167
+ }
168
+ return new Results<TData>(response.status, response.headers, sseSource(parseSseStream(response.body)), label)
169
+ }
170
+
171
+ /** Options of {@link fakeLoader}: what a key resolves to. */
172
+ export type FakeLoaderValues<K, V> =
173
+ | Readonly<Record<string, V | Error>>
174
+ | ReadonlyMap<K, V | Error>
175
+ | ((key: K) => V | Error | Promise<V | Error>)
176
+
177
+ /** A `Loader<L>` built by {@link fakeLoader}: `load` and `loadMany` are spies. */
178
+ export interface FakeLoader<K, V> extends BatchLoader<K, V> {
179
+ readonly load: MockFunction<(key: K) => Promise<V>> & ((key: K) => Promise<V>)
180
+ readonly loadMany: MockFunction<(keys: readonly K[]) => Promise<(V | Error)[]>> &
181
+ ((keys: readonly K[]) => Promise<(V | Error)[]>)
182
+ }
183
+
184
+ /**
185
+ * A `Loader<L>` for unit tests of resolvers (with `TestBed`), answering from `values` (a record by
186
+ * key, a map, or a function of the key; an `Error` value fails that key). `load` and `loadMany` are
187
+ * spies; a missing key rejects.
188
+ *
189
+ * ```ts
190
+ * const authors = fakeLoader<AuthorsById>({ [herbert.id]: herbert })
191
+ * expect(await instance.author(dune, authors)).toBe(herbert)
192
+ * expect(authors.load).toHaveBeenCalledWith(herbert.id)
193
+ * ```
194
+ */
195
+ export function fakeLoader<L extends BatchLoad<unknown, unknown>>(
196
+ values: FakeLoaderValues<LoaderKey<L>, LoaderValue<L>>,
197
+ ): FakeLoader<LoaderKey<L>, LoaderValue<L>> {
198
+ type K = LoaderKey<L>
199
+ type V = LoaderValue<L>
200
+ const resolve = lookup<K, V>(values)
201
+ const cache = new Map<unknown, Promise<V>>()
202
+ const loadOne = (key: K): Promise<V> => {
203
+ const cached = cache.get(key)
204
+ if (cached !== undefined) return cached
205
+ const loaded = Promise.resolve()
206
+ .then(() => resolve(key))
207
+ .then(value => (value instanceof Error ? Promise.reject(value) : value))
208
+ cache.set(key, loaded)
209
+ return loaded
210
+ }
211
+ const create = resolveMockFn().create
212
+ const load = create((key: K) => loadOne(key)) as FakeLoader<K, V>['load']
213
+ const loadMany = create((keys: readonly K[]) =>
214
+ Promise.all(keys.map(key => loadOne(key).catch((error: unknown) => asError(error)))),
215
+ ) as FakeLoader<K, V>['loadMany']
216
+ const loader: FakeLoader<K, V> = {
217
+ load,
218
+ loadMany,
219
+ clear(key: K) {
220
+ cache.delete(key)
221
+ return this
222
+ },
223
+ clearAll() {
224
+ cache.clear()
225
+ return this
226
+ },
227
+ prime(key: K, value: V | PromiseLike<V> | Error) {
228
+ if (!cache.has(key)) {
229
+ const primed = value instanceof Error ? Promise.reject(value) : Promise.resolve(value)
230
+ primed.catch(() => undefined)
231
+ cache.set(key, primed)
232
+ }
233
+ return this
234
+ },
235
+ }
236
+ return loader
237
+ }
238
+
239
+ // ------------------------------------------------------------------------------------------------
240
+
241
+ function graphqlPlugin(app: GraphQLTestApp, what: string): GraphQLPlugin {
242
+ const plugin = app?.app?.getPlugin<Plugin>('graphql')
243
+ if (plugin === undefined) {
244
+ throw new TypeError(`${what} expects a TestApp whose plugins include GraphQLPlugin.configure()`)
245
+ }
246
+ return plugin as GraphQLPlugin
247
+ }
248
+
249
+ function operationBody(request: OperationRequest, what: string): GraphQLRequest {
250
+ if (request === null || typeof request !== 'object' || typeof request.query !== 'string') {
251
+ throw new TypeError(`${what} expects { query, variables?, operationName?, extensions?, headers? }`)
252
+ }
253
+ return {
254
+ query: request.query,
255
+ ...(request.variables === undefined ? {} : { variables: request.variables }),
256
+ ...(request.operationName === undefined ? {} : { operationName: request.operationName }),
257
+ ...(request.extensions === undefined ? {} : { extensions: request.extensions }),
258
+ }
259
+ }
260
+
261
+ /** The request headers: the defaults, then the operation's (names lower-cased, so they replace the defaults). */
262
+ function headers(request: OperationRequest, accept: string): Record<string, string> {
263
+ const result: Record<string, string> = { 'content-type': 'application/json', accept }
264
+ for (const [name, value] of Object.entries(request.headers ?? {})) result[name.toLowerCase()] = value
265
+ return result
266
+ }
267
+
268
+ function pick<TData>(result: FormattedExecutionResult): OperationResult<TData> {
269
+ return {
270
+ ...('data' in result ? { data: result.data as TData | null } : {}),
271
+ ...(result.errors === undefined ? {} : { errors: result.errors }),
272
+ ...(result.extensions === undefined ? {} : { extensions: result.extensions }),
273
+ }
274
+ }
275
+
276
+ /** The GraphQL result of a body; `{}` when it is not one (an HTTP error body, a page). */
277
+ function parseResult<TData>(body: string): OperationResult<TData> {
278
+ let parsed: unknown
279
+ try {
280
+ parsed = JSON.parse(body)
281
+ } catch {
282
+ return {}
283
+ }
284
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
285
+ const result = parsed as FormattedExecutionResult
286
+ if (!('data' in result) && !Array.isArray(result.errors)) return {}
287
+ return pick<TData>(result)
288
+ }
289
+
290
+ function describeOperation(query: string): string {
291
+ const text = query.replace(/\s+/g, ' ').trim()
292
+ return text.length > 60 ? `${text.slice(0, 60)}…` : text
293
+ }
294
+
295
+ function asError(error: unknown): Error {
296
+ return error instanceof Error ? error : new Error(String(error))
297
+ }
298
+
299
+ function lookup<K, V>(values: FakeLoaderValues<K, V>): (key: K) => V | Error | Promise<V | Error> {
300
+ if (typeof values === 'function') return values
301
+ if (values instanceof Map) {
302
+ return key => (values.has(key) ? (values.get(key) as V | Error) : missing(key))
303
+ }
304
+ const record = values as Readonly<Record<string, V | Error>>
305
+ return key => (Object.hasOwn(record, String(key)) ? (record[String(key)] as V | Error) : missing(key))
306
+ }
307
+
308
+ function missing(key: unknown): Error {
309
+ return new Error(`fakeLoader(): no value for the key ${JSON.stringify(key) ?? String(key)}`)
310
+ }
311
+
312
+ /** A source of results: `undefined` when it ended. */
313
+ interface ResultSource {
314
+ next(): Promise<FormattedExecutionResult | undefined>
315
+ close(): Promise<void>
316
+ }
317
+
318
+ function directSource(stream: GraphQLResultStream): ResultSource {
319
+ return {
320
+ next: async () => {
321
+ const step = await stream.next()
322
+ return step.done === true ? undefined : step.value
323
+ },
324
+ close: async () => {
325
+ await stream.return()
326
+ },
327
+ }
328
+ }
329
+
330
+ /** The `next` events of an event stream, until `complete` (comments, `retry` and other events skipped). */
331
+ function sseSource(items: AsyncIterableIterator<SseItem>): ResultSource {
332
+ return {
333
+ next: async () => {
334
+ for (;;) {
335
+ const step = await items.next()
336
+ if (step.done === true) return undefined
337
+ const item = step.value
338
+ if (item.type !== 'event') continue
339
+ if (item.event === 'complete') return undefined
340
+ if (item.event === 'next') return JSON.parse(item.data) as FormattedExecutionResult
341
+ }
342
+ },
343
+ close: async () => {
344
+ await items.return?.()
345
+ },
346
+ }
347
+ }
348
+
349
+ /** The stream of a subscription (or of a query sent as a stream). */
350
+ class Results<TData> implements OperationStream<TData> {
351
+ readonly status: number
352
+ readonly headers: Readonly<Record<string, string | readonly string[] | undefined>>
353
+ readonly #source: ResultSource
354
+ readonly #label: string
355
+ /** A read still pending after a timeout: the next call waits for it, so no result is lost. */
356
+ #pending: Promise<FormattedExecutionResult | undefined> | undefined
357
+ #ended = false
358
+
359
+ constructor(
360
+ status: number,
361
+ headers: Readonly<Record<string, string | readonly string[] | undefined>>,
362
+ source: ResultSource,
363
+ label: string,
364
+ ) {
365
+ this.status = status
366
+ this.headers = headers
367
+ this.#source = source
368
+ this.#label = label
369
+ }
370
+
371
+ async next(options: OperationStreamWaitOptions = {}): Promise<IteratorResult<OperationResult<TData>, undefined>> {
372
+ if (this.#ended) return DONE
373
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
374
+ this.#pending ??= this.#source.next()
375
+ const pending = this.#pending
376
+ let timer: ReturnType<typeof setTimeout> | undefined
377
+ const timeout = new Promise<typeof TIMEOUT>(resolve => {
378
+ timer = setTimeout(() => resolve(TIMEOUT), timeoutMs)
379
+ })
380
+ let result: FormattedExecutionResult | undefined | typeof TIMEOUT
381
+ try {
382
+ result = await Promise.race([pending, timeout])
383
+ } catch (error) {
384
+ this.#pending = undefined
385
+ await this.return()
386
+ throw error
387
+ } finally {
388
+ clearTimeout(timer)
389
+ }
390
+ if (result === TIMEOUT) {
391
+ throw new Error(`subscribeOperation(): no result within ${timeoutMs} ms for ${this.#label}`)
392
+ }
393
+ this.#pending = undefined
394
+ if (result === undefined) {
395
+ await this.return()
396
+ return DONE
397
+ }
398
+ return { done: false, value: pick<TData>(result) }
399
+ }
400
+
401
+ async return(): Promise<IteratorResult<OperationResult<TData>, undefined>> {
402
+ if (!this.#ended) {
403
+ this.#ended = true
404
+ await this.#source.close()
405
+ }
406
+ return DONE
407
+ }
408
+
409
+ [Symbol.asyncIterator](): OperationStream<TData> {
410
+ return this
411
+ }
412
+
413
+ [Symbol.asyncDispose](): Promise<void> {
414
+ return this.return().then(() => undefined)
415
+ }
416
+ }
417
+
418
+ const TIMEOUT: unique symbol = Symbol('timeout')
419
+
420
+ /** A request error (or a query's result without HTTP transport): one result, then the end. */
421
+ class SingleResult<TData> implements OperationStream<TData> {
422
+ readonly status: number
423
+ readonly headers: Readonly<Record<string, string | readonly string[] | undefined>>
424
+ #result: OperationResult<TData> | undefined
425
+
426
+ constructor(
427
+ status: number,
428
+ headers: Readonly<Record<string, string | readonly string[] | undefined>>,
429
+ result: OperationResult<TData>,
430
+ ) {
431
+ this.status = status
432
+ this.headers = headers
433
+ this.#result = result
434
+ }
435
+
436
+ next(): Promise<IteratorResult<OperationResult<TData>, undefined>> {
437
+ const result = this.#result
438
+ this.#result = undefined
439
+ return Promise.resolve(result === undefined ? DONE : { done: false, value: result })
440
+ }
441
+
442
+ return(): Promise<IteratorResult<OperationResult<TData>, undefined>> {
443
+ this.#result = undefined
444
+ return Promise.resolve(DONE)
445
+ }
446
+
447
+ [Symbol.asyncIterator](): OperationStream<TData> {
448
+ return this
449
+ }
450
+
451
+ [Symbol.asyncDispose](): Promise<void> {
452
+ return Promise.resolve()
453
+ }
454
+ }
package/src/tokens.ts ADDED
@@ -0,0 +1,22 @@
1
+ import type { TypedToken } from '@znight/core'
2
+ import type { GraphQLPlugin } from './plugin.ts'
3
+ import type { PubSub } from './pubsub.ts'
4
+ import type { GraphQLContext } from './types.ts'
5
+
6
+ /**
7
+ * Tokens of the values `GraphQLPlugin` supplies to the container (declared in the package
8
+ * manifest). `GraphQLContext` is a request-scoped external: inject it in request-scoped providers
9
+ * or through `Lazy<GraphQLContext>`.
10
+ */
11
+ export const GraphQLTokens: {
12
+ /** The {@link GraphQLContext} of the current operation (request scope). */
13
+ readonly Context: TypedToken<GraphQLContext>
14
+ /** The application {@link PubSub} (singleton). */
15
+ readonly PubSub: TypedToken<PubSub>
16
+ /** The {@link GraphQLPlugin} instance (singleton). */
17
+ readonly Plugin: TypedToken<GraphQLPlugin>
18
+ } = Object.freeze({
19
+ Context: '@znight/graphql#GraphQLContext',
20
+ PubSub: '@znight/graphql#PubSub',
21
+ Plugin: '@znight/graphql#GraphQLPlugin',
22
+ })
@@ -0,0 +1,80 @@
1
+ /**
2
+ * A deferred abort signal for graphql-js (RFC 0005 §8.3, "Cancellation").
3
+ *
4
+ * `HttpContext.signal` is created on first read, and creating it costs an `AbortController` plus a
5
+ * close listener on the connection; graphql-js then adds and removes an abort listener per
6
+ * execution. A query whose resolvers are synchronous cannot be cancelled anyway, so the transport
7
+ * passes this stand-in instead: it records the listener graphql-js registers and starts following
8
+ * `ctx.signal` only when the execution turned asynchronous ({@link DeferredAbort.follow}).
9
+ *
10
+ * It implements the part of `AbortSignal` graphql-js 17 reads (`aborted`, `reason`,
11
+ * `throwIfAborted()`, `addEventListener('abort')`, `removeEventListener('abort')`); the transport
12
+ * tests check that a client leaving during an asynchronous query aborts it.
13
+ */
14
+
15
+ type Listener = () => void
16
+
17
+ /** The stand-in passed as graphql-js `abortSignal` by the HTTP transport (one per request). */
18
+ export class DeferredAbort {
19
+ aborted = false
20
+ reason: unknown = undefined
21
+ /** graphql-js registers one listener per execution; more are kept in {@link #others}. */
22
+ #listener: Listener | undefined = undefined
23
+ #others: Listener[] | undefined = undefined
24
+
25
+ throwIfAborted(): void {
26
+ if (this.aborted) throw this.reason
27
+ }
28
+
29
+ addEventListener(type: string, listener: Listener): void {
30
+ if (type !== 'abort') return
31
+ if (this.#listener === undefined) {
32
+ this.#listener = listener
33
+ return
34
+ }
35
+ this.#others ??= []
36
+ this.#others.push(listener)
37
+ }
38
+
39
+ removeEventListener(type: string, listener: Listener): void {
40
+ if (type !== 'abort') return
41
+ if (this.#listener === listener) {
42
+ this.#listener = undefined
43
+ return
44
+ }
45
+ const others = this.#others
46
+ const index = others === undefined ? -1 : others.indexOf(listener)
47
+ if (index !== -1) others?.splice(index, 1)
48
+ }
49
+
50
+ /**
51
+ * The execution is asynchronous: from now on, `signal` aborting aborts the execution. Reads the
52
+ * signal only when graphql-js still listens (a pending execution).
53
+ */
54
+ follow(read: () => AbortSignal): void {
55
+ if (this.#listener === undefined && (this.#others === undefined || this.#others.length === 0)) return
56
+ const signal = read()
57
+ if (signal.aborted) {
58
+ this.#abort(signal.reason)
59
+ return
60
+ }
61
+ signal.addEventListener('abort', () => this.#abort(signal.reason), { once: true })
62
+ }
63
+
64
+ /** This stand-in, typed as the `AbortSignal` graphql-js expects. */
65
+ asSignal(): AbortSignal {
66
+ return this as unknown as AbortSignal
67
+ }
68
+
69
+ #abort(reason: unknown): void {
70
+ if (this.aborted) return
71
+ this.aborted = true
72
+ this.reason = reason
73
+ const listener = this.#listener
74
+ const others = this.#others
75
+ this.#listener = undefined
76
+ this.#others = undefined
77
+ listener?.()
78
+ if (others !== undefined) for (const other of others) other()
79
+ }
80
+ }