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