@znight/graphql 0.0.0-stage → 0.2.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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1062 -2
  3. package/dist/compiler.d.mts +93 -0
  4. package/dist/compiler.mjs +2832 -0
  5. package/dist/compiler.mjs.map +1 -0
  6. package/dist/index.d.mts +542 -0
  7. package/dist/index.mjs +128 -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-BdBxr5NQ.mjs +3222 -0
  12. package/dist/plugin-BdBxr5NQ.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-CgbV9LjD.d.mts +234 -0
  17. package/package.json +95 -4
  18. package/src/compiler/analyze.ts +608 -0
  19. package/src/compiler/builder.ts +1640 -0
  20. package/src/compiler/constants.ts +74 -0
  21. package/src/compiler/data.ts +73 -0
  22. package/src/compiler/defaults.ts +92 -0
  23. package/src/compiler/discriminators.ts +53 -0
  24. package/src/compiler/docs.ts +55 -0
  25. package/src/compiler/generate.ts +405 -0
  26. package/src/compiler/ids.ts +128 -0
  27. package/src/compiler/index.ts +93 -0
  28. package/src/compiler/locate.ts +44 -0
  29. package/src/compiler/model.ts +122 -0
  30. package/src/compiler/options.ts +54 -0
  31. package/src/compiler/scalars.ts +138 -0
  32. package/src/compiler/sdl-view.ts +59 -0
  33. package/src/context.ts +181 -0
  34. package/src/decisions.ts +169 -0
  35. package/src/decorators.ts +196 -0
  36. package/src/errors.ts +89 -0
  37. package/src/execution.ts +1064 -0
  38. package/src/format.ts +285 -0
  39. package/src/index.ts +84 -0
  40. package/src/limits.ts +177 -0
  41. package/src/loaders.ts +149 -0
  42. package/src/memory-pubsub.ts +133 -0
  43. package/src/metadata.ts +195 -0
  44. package/src/names.ts +47 -0
  45. package/src/options.ts +309 -0
  46. package/src/pipeline.ts +277 -0
  47. package/src/plugin.ts +557 -0
  48. package/src/pubsub.ts +36 -0
  49. package/src/scalars.ts +131 -0
  50. package/src/schema-hash.ts +18 -0
  51. package/src/schema.ts +557 -0
  52. package/src/sdl.ts +515 -0
  53. package/src/testing.ts +454 -0
  54. package/src/tokens.ts +22 -0
  55. package/src/transport/abort.ts +80 -0
  56. package/src/transport/accept.ts +155 -0
  57. package/src/transport/graphiql.ts +106 -0
  58. package/src/transport/http.ts +373 -0
  59. package/src/transport/sse.ts +45 -0
  60. package/src/type-ref.ts +89 -0
  61. package/src/types.ts +190 -0
  62. package/src/validate-args.ts +48 -0
package/src/plugin.ts ADDED
@@ -0,0 +1,557 @@
1
+ /**
2
+ * `GraphQLPlugin` (RFC 0005 §5.11, §8.2): builds the schema from `metadata.extensions.graphql`,
3
+ * provides `GraphQLContext`, `PubSub` and itself to the container, executes operations
4
+ * (`execute()`, `subscribe()`) and contributes the GraphQL endpoint to `HttpPlugin`.
5
+ */
6
+ import type {
7
+ Container,
8
+ CoreLogger,
9
+ Hooks,
10
+ Plugin,
11
+ PluginContext,
12
+ PluginOptions,
13
+ PluginRootsContext,
14
+ ProviderKey,
15
+ Token,
16
+ ZnightMetadata,
17
+ } from '@znight/core'
18
+ import type { PluginHttpRoutes, PluginHttpRoutesContext, PluginRoute, SseOptions } from '@znight/http'
19
+ import type { FormattedExecutionResult, GraphQLSchema } from 'graphql'
20
+ import type { CheckedField } from './decisions.ts'
21
+ import type { ExecuteOptions, GraphQLRequest, GraphQLResultStream } from './execution.ts'
22
+ import type { Resolvable } from './format.ts'
23
+ import type { GraphQLInputTypeDefinition, GraphQLMetadata, GraphQLResolverBinding } from './metadata.ts'
24
+ import type { GraphQLPluginOptions, ResolvedGraphQLOptions } from './options.ts'
25
+ import type { GraphQLErrorMapper, GraphQLGuard } from './types.ts'
26
+
27
+ import { displayName, ErrorCode, resolveOptions, ZnightError } from '@znight/core'
28
+ import { printSchema } from 'graphql'
29
+
30
+ import { checkFieldDecisions } from './decisions.ts'
31
+ import { GraphQLExecutor, singleResult } from './execution.ts'
32
+ import { ErrorFormatter } from './format.ts'
33
+ import { LoaderFactory } from './loaders.ts'
34
+ import { MemoryPubSub } from './memory-pubsub.ts'
35
+ import { invalidOption, resolveGraphQLOptions } from './options.ts'
36
+ import { globalGuardsApply, pipelineFactory } from './pipeline.ts'
37
+ import { PubSub } from './pubsub.ts'
38
+ import { buildGraphQLSchema, graphqlSection } from './schema.ts'
39
+ import { GraphQLTokens } from './tokens.ts'
40
+ import { checkSseOptions, HttpTransport } from './transport/http.ts'
41
+
42
+ /** Options of {@link GraphQLPlugin.execute} and {@link GraphQLPlugin.subscribe}. */
43
+ export interface DirectExecuteOptions {
44
+ /** Cancels the operation (graphql-js `abortSignal`, `GraphQLContext.signal`). */
45
+ readonly signal?: AbortSignal
46
+ /** `GraphQLContext.requestId`. Default: the request id of the enclosing scope, or a UUID. */
47
+ readonly requestId?: string
48
+ }
49
+
50
+ /** What the plugin built at bootstrap. */
51
+ interface Runtime {
52
+ readonly schema: GraphQLSchema
53
+ readonly section: GraphQLMetadata
54
+ readonly executor: GraphQLExecutor
55
+ }
56
+
57
+ let readExecutor: (plugin: GraphQLPlugin) => GraphQLExecutor
58
+ let readEndpoint: (plugin: GraphQLPlugin) => string | undefined
59
+
60
+ /**
61
+ * The executor of a bootstrapped plugin: the internal API of the transports (statuses, streams).
62
+ * Not exported from the package entry.
63
+ */
64
+ export function executorOf(plugin: GraphQLPlugin): GraphQLExecutor {
65
+ return readExecutor(plugin)
66
+ }
67
+
68
+ /**
69
+ * The absolute path of the GraphQL-over-HTTP endpoint (prefix included), `undefined` without HTTP
70
+ * transport (`http: false`, or before `HttpPlugin` collected the routes). Not exported from the
71
+ * package entry: `@znight/graphql/testing` sends its requests there.
72
+ */
73
+ export function endpointOf(plugin: GraphQLPlugin): string | undefined {
74
+ return readEndpoint(plugin)
75
+ }
76
+
77
+ /** A value of type `T` or a promise of it. */
78
+ export type MaybePromise<T> = T | Promise<T>
79
+
80
+ /**
81
+ * The GraphQL plugin: `GraphQLPlugin.configure(options)` (name `graphql`).
82
+ *
83
+ * ```ts
84
+ * await Application.create({
85
+ * metadata: __ZNIGHT_METADATA__,
86
+ * plugins: [HttpPlugin.configure(), GraphQLPlugin.configure({ guards: [AccessGuard] })],
87
+ * })
88
+ * ```
89
+ */
90
+ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
91
+ readonly name = 'graphql'
92
+ readonly #options: PluginOptions<GraphQLPluginOptions>
93
+ #resolved: ResolvedGraphQLOptions | undefined
94
+ #container: Container | undefined
95
+ #metadata: ZnightMetadata | undefined
96
+ #logger: CoreLogger | undefined
97
+ #hooks: Hooks | undefined
98
+ #pubsub: PubSub | undefined
99
+ #ownPubSub: MemoryPubSub | undefined
100
+ #runtime: Runtime | undefined
101
+ #stop: AbortController | undefined
102
+ #endpoint: string | undefined
103
+ #active = false
104
+
105
+ private constructor(options: PluginOptions<GraphQLPluginOptions>) {
106
+ this.#options = options
107
+ }
108
+
109
+ static {
110
+ readExecutor = plugin => plugin.#ready().executor
111
+ readEndpoint = plugin => plugin.#endpoint
112
+ }
113
+
114
+ /**
115
+ * Creates the plugin. `options` may be a function of the container, evaluated once, when first
116
+ * needed after the container is initialised: `GraphQLPlugin.configure(c => ({ exposeErrors: c.get(Env).dev }))`.
117
+ */
118
+ static configure(options: PluginOptions<GraphQLPluginOptions> = {}): GraphQLPlugin {
119
+ return new GraphQLPlugin(options)
120
+ }
121
+
122
+ /**
123
+ * Providers `TestApp` creates at boot: the classes of the `guards` and `errorMappers` options, and
124
+ * the resolvers, guards and loaders of the metadata.
125
+ */
126
+ initRoots(context?: PluginRootsContext): readonly ProviderKey[] {
127
+ const roots = new Set<ProviderKey>()
128
+ try {
129
+ const options: unknown = this.#options
130
+ if (options !== null && typeof options === 'object') {
131
+ for (const key of ['guards', 'errorMappers'] as const) {
132
+ const refs: unknown = (options as GraphQLPluginOptions)[key]
133
+ if (Array.isArray(refs)) for (const ref of refs) if (typeof ref === 'function') roots.add(ref as ProviderKey)
134
+ }
135
+ }
136
+ const types: unknown = (context?.metadata.extensions?.graphql as { types?: unknown } | undefined)?.types
137
+ if (Array.isArray(types)) {
138
+ for (const type of types as { fields?: unknown }[]) {
139
+ if (!Array.isArray(type?.fields)) continue
140
+ for (const field of type.fields as { resolver?: Partial<GraphQLResolverBinding> }[]) {
141
+ const binding = field?.resolver
142
+ if (binding === undefined || typeof binding.token !== 'string') continue
143
+ roots.add(binding.token)
144
+ for (const token of [...(binding.guards ?? []), ...(binding.loaders ?? [])]) {
145
+ if (typeof token === 'string') roots.add(token)
146
+ }
147
+ }
148
+ }
149
+ }
150
+ } catch {
151
+ // initRoots never throws: register() reports malformed options and metadata
152
+ }
153
+ return [...roots]
154
+ }
155
+
156
+ register(ctx: PluginContext): void {
157
+ if (this.#active) {
158
+ throw new ZnightError(ErrorCode.DuplicateProvider, 'This GraphQLPlugin is already used by another application', {
159
+ hints: ['Call GraphQLPlugin.configure() once per application'],
160
+ })
161
+ }
162
+ this.#resolved = undefined
163
+ this.#runtime = undefined
164
+ this.#ownPubSub = undefined
165
+ this.#endpoint = undefined
166
+ this.#container = ctx.container
167
+ this.#metadata = ctx.metadata
168
+ this.#logger = ctx.logger
169
+ this.#hooks = ctx.hooks
170
+ if (typeof this.#options !== 'function') {
171
+ // static options: validated now, and the HTTP transport needs HttpPlugin
172
+ this.#checkHttp(ctx, this.#resolve())
173
+ }
174
+ this.#active = true
175
+ this.#stop = new AbortController()
176
+ ctx.container.registerExternal({ token: GraphQLTokens.Context, scope: 'request', providedBy: 'GraphQLPlugin' })
177
+ ctx.container.registerValue(
178
+ GraphQLTokens.PubSub,
179
+ typeof this.#options === 'function' ? new DeferredPubSub(() => this.pubsub) : this.pubsub,
180
+ )
181
+ ctx.container.registerValue(GraphQLTokens.Plugin, this)
182
+ }
183
+
184
+ /**
185
+ * The `PluginHttpRoutes` contract of `@znight/http`: the GraphQL endpoint (`GET` and `POST` on
186
+ * `path`). `[]` with `http: false`.
187
+ */
188
+ httpRoutes(context: PluginHttpRoutesContext): readonly PluginRoute[] {
189
+ this.#container ??= context.container
190
+ const options = this.#resolve()
191
+ const http = options.http
192
+ if (http === false) return []
193
+ const endpoint = endpointPath(context.prefix, options.path)
194
+ const transport = new HttpTransport({
195
+ // called on the first request: the routes are collected before or after this plugin's onBootstrap
196
+ executor: () => this.#ready().executor,
197
+ endpoint,
198
+ get: http.get,
199
+ graphiql: options.graphiql,
200
+ sse: sseOptions(options),
201
+ })
202
+ this.#endpoint = endpoint
203
+ const common = {
204
+ name: 'handle',
205
+ path: options.path,
206
+ ...(http.meta.length === 0 ? {} : { meta: http.meta }),
207
+ ...(http.guards.length === 0 ? {} : { guards: http.guards }),
208
+ }
209
+ const routes: PluginRoute[] = [{ ...common, method: 'POST', handler: transport.post, bodyLimit: http.bodyLimit }]
210
+ // GET serves queries (`http.get`) and GraphiQL; without either, the router answers 405
211
+ if (http.get || options.graphiql) routes.unshift({ ...common, method: 'GET', handler: transport.get })
212
+ return routes
213
+ }
214
+
215
+ onBootstrap(ctx: PluginContext): void {
216
+ this.#container = ctx.container
217
+ const options = this.#resolve()
218
+ if (typeof this.#options === 'function') this.#checkHttp(ctx, options)
219
+ this.#checkSse(options)
220
+ this.#runtime = this.#build(ctx, options)
221
+ }
222
+
223
+ onStart(ctx: PluginContext): void {
224
+ const runtime = this.#runtime
225
+ const options = this.#resolved
226
+ if (runtime === undefined || options === undefined) return
227
+ const details = { types: runtime.section.types.length, schemaHash: runtime.section.schemaHash }
228
+ if (options.http === false) ctx.logger.info('GraphQL schema ready', details)
229
+ else ctx.logger.info('GraphQL endpoint ready', { path: options.path, ...details })
230
+ }
231
+
232
+ async onStop(): Promise<void> {
233
+ this.#stop?.abort(new Error('The application is stopping'))
234
+ await this.#runtime?.executor.stopDirect()
235
+ }
236
+
237
+ onDispose(): void {
238
+ this.#active = false
239
+ this.#ownPubSub?.close()
240
+ this.#ownPubSub = undefined
241
+ this.#pubsub = undefined
242
+ this.#runtime?.executor.clear()
243
+ this.#runtime = undefined
244
+ this.#resolved = undefined
245
+ }
246
+
247
+ /** The schema (after bootstrap). */
248
+ get schema(): GraphQLSchema {
249
+ return this.#ready().schema
250
+ }
251
+
252
+ /** The SDL of the schema (graphql-js `printSchema`). */
253
+ printSchema(): string {
254
+ return printSchema(this.#ready().schema)
255
+ }
256
+
257
+ /** The `PubSub` of the application: the `pubsub` option, or the plugin's `MemoryPubSub`. */
258
+ get pubsub(): PubSub {
259
+ let pubsub = this.#pubsub
260
+ if (pubsub === undefined) {
261
+ const options = this.#resolve()
262
+ if (options.pubsub === undefined) {
263
+ this.#ownPubSub = new MemoryPubSub({ bufferSize: options.bufferSize })
264
+ pubsub = this.#ownPubSub
265
+ } else {
266
+ // the option accepts a PubSub of any event map; the container hands it out untyped
267
+ pubsub = options.pubsub as unknown as PubSub
268
+ }
269
+ this.#pubsub = pubsub
270
+ }
271
+ return pubsub
272
+ }
273
+
274
+ /** Active subscriptions (every transport). */
275
+ get activeSubscriptions(): number {
276
+ return this.#runtime?.executor.activeSubscriptions ?? 0
277
+ }
278
+
279
+ /**
280
+ * Executes a query or a mutation outside of HTTP (custom transports, tests, a controller calling
281
+ * GraphQL), in the current request scope or in a scope of its own. Returns the result directly
282
+ * when every resolver is synchronous; `await` it. A subscription is an error result: use
283
+ * {@link subscribe}. Rejects only when `options.signal` aborts the execution.
284
+ */
285
+ execute(request: GraphQLRequest, options?: DirectExecuteOptions): MaybePromise<FormattedExecutionResult> {
286
+ const response = this.#ready().executor.execute(request, direct(options))
287
+ return isPromise(response) ? response.then(settled => settled.result) : response.result
288
+ }
289
+
290
+ /**
291
+ * Subscribes outside of HTTP: the results of the subscription, one per event, until the source
292
+ * ends, `options.signal` aborts, `return()` is called or the application stops. A request error
293
+ * or a query gives a stream of one result.
294
+ */
295
+ async subscribe(request: GraphQLRequest, options?: DirectExecuteOptions): Promise<GraphQLResultStream> {
296
+ const response = await this.#ready().executor.subscribe(request, direct(options))
297
+ return 'status' in response ? singleResult(response.result) : response
298
+ }
299
+
300
+ // ------------------------------------------------------------------------------------------------
301
+
302
+ #ready(): Runtime {
303
+ const runtime = this.#runtime
304
+ if (runtime === undefined) {
305
+ throw new ZnightError(ErrorCode.LifecycleFailed, 'GraphQLPlugin is not bootstrapped yet', {
306
+ hints: ['The schema and execute() are available once Application.create() resolved'],
307
+ })
308
+ }
309
+ return runtime
310
+ }
311
+
312
+ #resolve(): ResolvedGraphQLOptions {
313
+ if (this.#resolved !== undefined) return this.#resolved
314
+ const container = this.#container
315
+ if (container === undefined && typeof this.#options === 'function') {
316
+ throw new ZnightError(ErrorCode.LifecycleFailed, 'GraphQLPlugin: the options are not available before register()')
317
+ }
318
+ this.#resolved = resolveGraphQLOptions(resolveOptions(this.#options, container as Container))
319
+ return this.#resolved
320
+ }
321
+
322
+ /** The SSE options are those of `HttpResponse.sse()` (heartbeat and maxDuration from 1 000 ms): checked at boot. */
323
+ #checkSse(options: ResolvedGraphQLOptions): void {
324
+ const sse = sseOptions(options)
325
+ if (sse === false) return
326
+ const problem = checkSseOptions(sse)
327
+ if (problem !== undefined) throw invalidOption('subscriptions', `valid event stream options (${problem})`, sse)
328
+ }
329
+
330
+ #checkHttp(ctx: PluginContext, options: ResolvedGraphQLOptions): void {
331
+ if (options.http !== false && ctx.getPlugin('http') === undefined) {
332
+ throw new ZnightError(
333
+ ErrorCode.InvalidGraphQLSetup,
334
+ 'GraphQLPlugin serves its endpoint through HttpPlugin, which is not registered',
335
+ {
336
+ hints: [
337
+ 'Add HttpPlugin.configure() to the plugins of the application',
338
+ 'Or set http: false to use the schema and execute() only',
339
+ ],
340
+ },
341
+ )
342
+ }
343
+ }
344
+
345
+ #build(ctx: PluginContext, options: ResolvedGraphQLOptions): Runtime {
346
+ const container = ctx.container
347
+ const metadata = ctx.metadata
348
+ const section = graphqlSection(metadata)
349
+ const providers = new Providers(container, metadata)
350
+ const globalGuards = options.guards.map(ref => providers.ref<GraphQLGuard>(ref, 'guard', 'canResolve'))
351
+ const mappers = options.errorMappers.map(ref => providers.ref<GraphQLErrorMapper>(ref, 'error mapper', 'map'))
352
+ const inputs = new Map<string, GraphQLInputTypeDefinition>()
353
+ const fields: CheckedField[] = []
354
+ const types = Array.isArray(section?.types) ? section.types : []
355
+ for (const type of types) {
356
+ if (type?.kind === 'input') inputs.set(type.name, type)
357
+ if (type?.kind !== 'object' || !Array.isArray(type.fields)) continue
358
+ for (const field of type.fields) {
359
+ const binding = field?.resolver
360
+ if (binding === undefined || binding === null || typeof binding !== 'object') continue
361
+ const root = binding.operation !== 'field'
362
+ const guardRefs: readonly Token[] = Array.isArray(binding.guards) ? binding.guards : []
363
+ fields.push({
364
+ description: `${type.name}.${field.name} (${displayName(String(binding.token))}.${String(binding.method)})`,
365
+ root,
366
+ meta: binding.meta ?? {},
367
+ guards: [
368
+ ...(globalGuardsApply(binding) ? globalGuards.map(guard => ({ cls: guard.cls, name: guard.name })) : []),
369
+ ...guardRefs.map(token => ({ cls: metadata.providers[token]?.class, name: displayName(token) })),
370
+ ],
371
+ })
372
+ }
373
+ }
374
+ const schema = buildGraphQLSchema(
375
+ section,
376
+ pipelineFactory({
377
+ container,
378
+ globalGuards: globalGuards.map(guard => guard.resolvable),
379
+ guard: (token, coordinate) => providers.token<GraphQLGuard>(token, `guard of ${coordinate}`, 'canResolve'),
380
+ resolver: (binding, coordinate) =>
381
+ providers.token<object>(binding.token, `resolver of ${coordinate}`, binding.method),
382
+ inputs,
383
+ }),
384
+ )
385
+ checkFieldDecisions(fields, section.groups, options.enforcement, ctx.logger)
386
+ const formatter = new ErrorFormatter({
387
+ container,
388
+ mappers: mappers.map(mapper => mapper.resolvable),
389
+ exposeErrors: options.exposeErrors,
390
+ logger: ctx.logger,
391
+ schema,
392
+ })
393
+ const executor = new GraphQLExecutor({
394
+ schema,
395
+ container,
396
+ hooks: ctx.hooks,
397
+ logger: ctx.logger,
398
+ formatter,
399
+ runtime: {
400
+ container,
401
+ loaders: new LoaderFactory(container),
402
+ signal: (this.#stop as AbortController).signal,
403
+ },
404
+ limits: options.limits,
405
+ introspection: options.introspection,
406
+ documentCache: options.documentCache,
407
+ maxActiveSubscriptions: options.subscriptions === false ? undefined : options.subscriptions.maxActive,
408
+ })
409
+ ctx.logger.debug('GraphQL schema built', { types: types.length, schemaHash: section.schemaHash })
410
+ return { schema, section, executor }
411
+ }
412
+ }
413
+
414
+ function direct(options: DirectExecuteOptions | undefined): ExecuteOptions {
415
+ if (options === undefined) return DIRECT
416
+ return {
417
+ transport: 'direct',
418
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
419
+ ...(options.requestId === undefined ? {} : { requestId: options.requestId }),
420
+ }
421
+ }
422
+
423
+ const DIRECT: ExecuteOptions = Object.freeze({ transport: 'direct' })
424
+
425
+ /** `prefix` (`''`, `/api`) joined with `path` (`/graphql`), without trailing slash: `/api/graphql`. */
426
+ function endpointPath(prefix: string, path: string): string {
427
+ const joined = `${prefix.replace(/\/+$/, '')}/${path.replace(/^\/+|\/+$/g, '')}`
428
+ return joined.length > 1 && joined.endsWith('/') ? joined.slice(0, -1) : joined
429
+ }
430
+
431
+ /** The `HttpResponse.sse()` options of GraphQL over SSE (`subscriptions`), `false` when disabled. */
432
+ function sseOptions(options: ResolvedGraphQLOptions): SseOptions | false {
433
+ const subscriptions = options.subscriptions
434
+ if (subscriptions === false) return false
435
+ return Object.freeze({
436
+ ...(subscriptions.heartbeat === undefined ? {} : { heartbeat: subscriptions.heartbeat }),
437
+ ...(subscriptions.maxDuration === undefined ? {} : { maxDuration: subscriptions.maxDuration }),
438
+ })
439
+ }
440
+
441
+ function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
442
+ return typeof (value as { then?: unknown } | null)?.then === 'function'
443
+ }
444
+
445
+ /**
446
+ * Resolves providers once at bootstrap: singletons (and registered values) to their instance,
447
+ * request-scoped and transient providers to their token, resolved per use in the current scope.
448
+ */
449
+ class Providers {
450
+ readonly #container: Container
451
+ readonly #metadata: ZnightMetadata
452
+ readonly #resolvables = new Map<Token, Resolvable<unknown>>()
453
+
454
+ constructor(container: Container, metadata: ZnightMetadata) {
455
+ this.#container = container
456
+ this.#metadata = metadata
457
+ }
458
+
459
+ /** A provider token (`@UseGuard`, a resolver), checked to have `method` when its class is known. */
460
+ token<T>(token: Token, what: string, method: string): Resolvable<T> {
461
+ const cls: unknown = Object.hasOwn(this.#metadata.providers, token)
462
+ ? this.#metadata.providers[token]?.class
463
+ : undefined
464
+ if (
465
+ typeof cls === 'function' &&
466
+ typeof (cls.prototype as Record<string, unknown> | undefined)?.[method] !== 'function'
467
+ ) {
468
+ throw new ZnightError(
469
+ ErrorCode.InvalidGraphQLSetup,
470
+ `GraphQLPlugin: the ${what} (${displayName(token)}) has no ${method}() method`,
471
+ {
472
+ hints:
473
+ method === 'canResolve'
474
+ ? ['A GraphQL guard implements GraphQLGuard: canResolve(ctx: ResolverContext)']
475
+ : [],
476
+ },
477
+ )
478
+ }
479
+ return this.#resolvable<T>(token, what)
480
+ }
481
+
482
+ /** A global ref: a provider class or an instance with `method`. */
483
+ ref<T>(
484
+ ref: unknown,
485
+ what: string,
486
+ method: string,
487
+ ): { readonly resolvable: Resolvable<T>; readonly cls: unknown; readonly name: string } {
488
+ if (typeof ref === 'function') {
489
+ const token = this.#container.tokenOf(ref as ProviderKey)
490
+ if (token === undefined) {
491
+ const name = (ref as { name?: string }).name || '<anonymous>'
492
+ throw new ZnightError(
493
+ ErrorCode.MissingProvider,
494
+ `GraphQLPlugin: the global ${what} ${name} is not a provider`,
495
+ {
496
+ hints: ['Add @Injectable() to the class, or pass an instance'],
497
+ },
498
+ )
499
+ }
500
+ const prototype: unknown = (ref as { prototype?: unknown }).prototype
501
+ if (typeof (prototype as Record<string, unknown> | undefined)?.[method] !== 'function') {
502
+ throw invalidOption(`${what}s`, `classes with a ${method}() method`, ref)
503
+ }
504
+ return { resolvable: this.#resolvable<T>(token, `global ${what}`), cls: ref, name: displayName(token) }
505
+ }
506
+ const cls: unknown = (ref as { constructor?: unknown }).constructor
507
+ const name = typeof cls === 'function' && cls !== Object ? (cls as { name?: string }).name : undefined
508
+ return { resolvable: { instance: ref as T }, cls, name: name || `${what} instance` }
509
+ }
510
+
511
+ #resolvable<T>(token: Token, what: string): Resolvable<T> {
512
+ let found = this.#resolvables.get(token)
513
+ if (found === undefined) {
514
+ found = this.#create(token, what)
515
+ this.#resolvables.set(token, found)
516
+ }
517
+ return found as Resolvable<T>
518
+ }
519
+
520
+ #create(token: Token, what: string): Resolvable<unknown> {
521
+ const container = this.#container
522
+ if (!container.has(token)) {
523
+ throw new ZnightError(ErrorCode.MissingProvider, `GraphQLPlugin: the ${what} (${token}) is not a provider`, {
524
+ hints: ['Add @Injectable() to the class (resolvers are providers through @Resolver())'],
525
+ })
526
+ }
527
+ const scope = Object.hasOwn(this.#metadata.providers, token) ? this.#metadata.providers[token]?.scope : undefined
528
+ if (scope === 'request' || scope === 'transient') return { token }
529
+ try {
530
+ return { instance: container.get(token) }
531
+ } catch (error) {
532
+ if ((error as { code?: unknown } | null)?.code === ErrorCode.NoActiveScope) return { token }
533
+ throw error
534
+ }
535
+ }
536
+ }
537
+
538
+ /**
539
+ * The `PubSub` registered when the options are a function of the container (they are only known
540
+ * after `container.init()`): delegates to the plugin's `PubSub`, resolved on first use.
541
+ */
542
+ class DeferredPubSub extends PubSub {
543
+ readonly #target: () => PubSub
544
+
545
+ constructor(target: () => PubSub) {
546
+ super()
547
+ this.#target = target
548
+ }
549
+
550
+ publish(topic: string, payload: unknown): Promise<void> {
551
+ return this.#target().publish(topic, payload)
552
+ }
553
+
554
+ subscribe(topic: string, options?: { readonly signal?: AbortSignal }): AsyncIterableIterator<unknown> {
555
+ return this.#target().subscribe(topic, options)
556
+ }
557
+ }
package/src/pubsub.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The `PubSub` contract (RFC 0005 §5.6, §8.7): topics and payloads of subscriptions. `PubSub` is
3
+ * an injectable external of `GraphQLPlugin` (`@znight/graphql#PubSub`, singleton), in memory by
4
+ * default; multi-instance deployments supply their own implementation through the `pubsub` option.
5
+ */
6
+
7
+ /** Options of {@link PubSub.subscribe}. */
8
+ export interface SubscribeOptions {
9
+ /** Ends the subscription when it aborts (client gone, shutdown, `maxDuration`): pass `ctx.signal`. */
10
+ readonly signal?: AbortSignal
11
+ }
12
+
13
+ /**
14
+ * Publishes events to the subscribers of a topic. `Events` maps each topic to its payload type.
15
+ *
16
+ * ```ts
17
+ * interface CatalogEvents {
18
+ * bookAdded: Book
19
+ * }
20
+ *
21
+ * constructor(private readonly events: PubSub<CatalogEvents>) {}
22
+ *
23
+ * await this.events.publish('bookAdded', book)
24
+ * for await (const book of this.events.subscribe('bookAdded', { signal: ctx.signal })) yield book
25
+ * ```
26
+ */
27
+ export abstract class PubSub<Events extends object = Record<string, unknown>> {
28
+ /** Delivers `payload` to the current subscribers of `topic`. */
29
+ abstract publish<K extends keyof Events & string>(topic: K, payload: Events[K]): Promise<void>
30
+
31
+ /** The events of `topic` published from now on, until `options.signal` aborts or the iterator returns. */
32
+ abstract subscribe<K extends keyof Events & string>(
33
+ topic: K,
34
+ options?: SubscribeOptions,
35
+ ): AsyncIterableIterator<Events[K]>
36
+ }