@znight/graphql 0.2.2 → 0.4.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/README.md +262 -17
- package/dist/compiler.d.mts +21 -1
- package/dist/compiler.mjs +116 -15
- package/dist/compiler.mjs.map +1 -1
- package/dist/{types-DQ713UgI.d.mts → driver-DgbpFj4-.d.mts} +193 -9
- package/dist/http-BWcayUtH.mjs +2165 -0
- package/dist/http-BWcayUtH.mjs.map +1 -0
- package/dist/index.d.mts +52 -51
- package/dist/index.mjs +2 -1
- package/dist/index.mjs.map +1 -1
- package/dist/names-BlzDM5cF.mjs.map +1 -1
- package/dist/{plugin-CR_wVfRU.mjs → plugin-BssB7wtn.mjs} +242 -1778
- package/dist/plugin-BssB7wtn.mjs.map +1 -0
- package/dist/testing.d.mts +40 -1
- package/dist/testing.mjs +649 -4
- package/dist/testing.mjs.map +1 -1
- package/dist/yoga.d.mts +64 -0
- package/dist/yoga.mjs +379 -0
- package/dist/yoga.mjs.map +1 -0
- package/package.json +19 -10
- package/src/compiler/analyze.ts +27 -0
- package/src/compiler/data.ts +20 -1
- package/src/compiler/generate.ts +137 -19
- package/src/conformance.ts +700 -0
- package/src/context.ts +13 -0
- package/src/driver.ts +63 -0
- package/src/engine.ts +146 -0
- package/src/execution.ts +602 -67
- package/src/index.ts +7 -0
- package/src/limits.ts +22 -1
- package/src/metadata.ts +20 -4
- package/src/options.ts +53 -4
- package/src/pipeline.ts +72 -9
- package/src/plugin.ts +172 -29
- package/src/testing.ts +3 -0
- package/src/transport/builtin.ts +57 -0
- package/src/transport/fetch.ts +57 -0
- package/src/transport/http.ts +12 -9
- package/src/types.ts +22 -4
- package/src/yoga.ts +471 -0
- package/dist/plugin-CR_wVfRU.mjs.map +0 -1
package/src/index.ts
CHANGED
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
export type { StandardSchemaV1 } from '@znight/http'
|
|
9
9
|
export type { Float, ID, Int, JSONValue, ResolverOptions } from './decorators.ts'
|
|
10
|
+
export type {
|
|
11
|
+
GraphQLDriver,
|
|
12
|
+
GraphQLDriverContext,
|
|
13
|
+
GraphQLDriverRoute,
|
|
14
|
+
GraphQLDriverRoutesContext,
|
|
15
|
+
} from './driver.ts'
|
|
16
|
+
export type { EngineExecuteOptions, GraphQLEngine, GraphQLEngineSettings } from './engine.ts'
|
|
10
17
|
export type { GraphQLRequest, GraphQLResultStream } from './execution.ts'
|
|
11
18
|
export type {
|
|
12
19
|
AnyPubSub,
|
package/src/limits.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* written, and, when the document spreads fragments, as each operation executes them (a fragment
|
|
6
6
|
* spread several times counts each time). `maxTokens` is enforced by `parse()` itself.
|
|
7
7
|
*/
|
|
8
|
-
import type { DirectiveNode, DocumentNode, FragmentDefinitionNode, SelectionSetNode } from 'graphql'
|
|
8
|
+
import type { DirectiveNode, DocumentNode, FragmentDefinitionNode, SelectionSetNode, ValidationRule } from 'graphql'
|
|
9
9
|
|
|
10
10
|
import { GraphQLError, Kind } from 'graphql'
|
|
11
11
|
|
|
@@ -76,6 +76,27 @@ export function checkLimits(document: DocumentNode, limits: DocumentLimits): Gra
|
|
|
76
76
|
return errors
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The limits as a graphql-js validation rule (`GraphQLEngine.rules`, RFC 0009), for GraphQL servers
|
|
81
|
+
* that parse and validate documents themselves: the same check as {@link checkLimits}, run once on
|
|
82
|
+
* the document node. `undefined` when every limit is disabled.
|
|
83
|
+
*/
|
|
84
|
+
export function limitsRule(limits: DocumentLimits): ValidationRule | undefined {
|
|
85
|
+
if (limits.maxDepth === undefined && limits.maxAliases === undefined && limits.maxDirectives === undefined) {
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
const rule: ValidationRule = context => ({
|
|
89
|
+
Document(node) {
|
|
90
|
+
const errors = checkLimits(node, limits)
|
|
91
|
+
if (errors !== undefined) for (const error of errors) context.reportError(error)
|
|
92
|
+
// the whole document is checked at once: nothing to visit below it
|
|
93
|
+
return false
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
Object.defineProperty(rule, 'name', { value: 'ZnightDocumentLimitsRule' })
|
|
97
|
+
return rule
|
|
98
|
+
}
|
|
99
|
+
|
|
79
100
|
interface Counter {
|
|
80
101
|
aliases: number
|
|
81
102
|
directives: number
|
package/src/metadata.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import type { Token } from '@znight/core'
|
|
13
13
|
import type { HttpRouteGroupDefinition } from '@znight/http'
|
|
14
14
|
import type { GraphQLResolveInfo, GraphQLScalarType } from 'graphql'
|
|
15
|
-
import type { GraphQLArgs, GraphQLContext, ScalarDefinition } from './types.ts'
|
|
15
|
+
import type { GraphQLArgs, GraphQLContext, ResolverContext, ScalarDefinition } from './types.ts'
|
|
16
16
|
|
|
17
17
|
/** Format version of `metadata.extensions.graphql` (another version is ZNIGHT066 at boot). */
|
|
18
18
|
export const GRAPHQL_METADATA_VERSION = 1
|
|
@@ -201,18 +201,34 @@ export interface GraphQLResolverBinding {
|
|
|
201
201
|
readonly meta: Readonly<Record<string, unknown>>
|
|
202
202
|
/** Informational: loader provider tokens the method injects. */
|
|
203
203
|
readonly loaders?: readonly Token[]
|
|
204
|
+
/**
|
|
205
|
+
* The method has custom parameters (`createParamDecorator`, RFC 0010): the pipeline passes the
|
|
206
|
+
* field's `ResolverContext` to `invoke` (`f`). Absent otherwise.
|
|
207
|
+
*/
|
|
208
|
+
readonly context?: true
|
|
209
|
+
/**
|
|
210
|
+
* Provider classes resolving custom parameters (`ParamResolver`), in order of first use: the
|
|
211
|
+
* pipeline resolves them in the operation scope and passes the instances to `invoke` (`x`).
|
|
212
|
+
* Absent when there is none.
|
|
213
|
+
*/
|
|
214
|
+
readonly paramResolvers?: readonly Token[]
|
|
204
215
|
/**
|
|
205
216
|
* Calls the method: maps the graphql-js arguments to the method parameters (§7.3). Synchronous
|
|
206
|
-
* unless an argument is validated with a Standard Schema
|
|
217
|
+
* unless an argument is validated with a Standard Schema or a custom parameter resolver returns a
|
|
218
|
+
* promise. `field` and `resolvers` are passed only to methods with custom parameters (declared as
|
|
219
|
+
* a method so that a generated invoke requiring them is accepted).
|
|
207
220
|
*/
|
|
208
|
-
|
|
221
|
+
invoke(
|
|
209
222
|
// biome-ignore lint/suspicious/noExplicitAny: generated invokers receive the concrete resolver class
|
|
210
223
|
resolver: any,
|
|
211
224
|
parent: unknown,
|
|
212
225
|
args: GraphQLArgs,
|
|
213
226
|
context: GraphQLContext,
|
|
214
227
|
info: GraphQLResolveInfo,
|
|
215
|
-
|
|
228
|
+
field?: ResolverContext,
|
|
229
|
+
// biome-ignore lint/suspicious/noExplicitAny: generated invokers receive the tuple of resolver classes
|
|
230
|
+
resolvers?: any,
|
|
231
|
+
): unknown
|
|
216
232
|
}
|
|
217
233
|
|
|
218
234
|
declare module '@znight/core' {
|
package/src/options.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Options of `GraphQLPlugin` (RFC 0005 §5.11): their types, defaults and validation (ZNIGHT066).
|
|
3
3
|
*/
|
|
4
4
|
import type { ByteSize, GuardRef, RouteMetaEntry } from '@znight/http'
|
|
5
|
+
import type { GraphQLDriver } from './driver.ts'
|
|
5
6
|
import type { SubscribeOptions } from './pubsub.ts'
|
|
6
7
|
import type {
|
|
7
8
|
GraphQLErrorMapper,
|
|
@@ -70,9 +71,12 @@ export interface GraphQLSubscriptionOptions {
|
|
|
70
71
|
readonly maxActive?: number
|
|
71
72
|
/** Events buffered per subscriber by the default `MemoryPubSub`. Default 100. */
|
|
72
73
|
readonly bufferSize?: number
|
|
73
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* SSE heartbeat (ms), `false` for none. Default: the `HttpPlugin` `sse` option. Built-in transport
|
|
76
|
+
* only: with a `driver`, its own keep-alive options apply (ZNIGHT066).
|
|
77
|
+
*/
|
|
74
78
|
readonly heartbeat?: number | false
|
|
75
|
-
/** SSE maximum duration (ms). Default: none. */
|
|
79
|
+
/** SSE maximum duration (ms). Default: none. Built-in transport only (ZNIGHT066 with a `driver`). */
|
|
76
80
|
readonly maxDuration?: number
|
|
77
81
|
}
|
|
78
82
|
|
|
@@ -95,12 +99,15 @@ export interface GraphQLPluginOptions {
|
|
|
95
99
|
readonly exposeErrors?: boolean
|
|
96
100
|
/** Introspection; off, suggestions are hidden too. Default `NODE_ENV !== 'production'`. */
|
|
97
101
|
readonly introspection?: boolean
|
|
98
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* GraphiQL on `GET path` with `accept: text/html`; with a `driver`, the driver's IDE. Default
|
|
104
|
+
* `NODE_ENV === 'development'`.
|
|
105
|
+
*/
|
|
99
106
|
readonly graphiql?: boolean
|
|
100
107
|
readonly limits?: GraphQLLimits
|
|
101
108
|
/** Distinct documents kept parsed and validated (LRU); 0 disables the cache. Default 1 000. */
|
|
102
109
|
readonly documentCache?: number
|
|
103
|
-
/** `false` disables subscriptions over SSE. */
|
|
110
|
+
/** `false` disables subscriptions over SSE (with a `driver`, subscriptions altogether). */
|
|
104
111
|
readonly subscriptions?: GraphQLSubscriptionOptions | false
|
|
105
112
|
/** The `PubSub` provided to the container. Default: a `MemoryPubSub` (closed with the application). */
|
|
106
113
|
readonly pubsub?: AnyPubSub
|
|
@@ -109,6 +116,11 @@ export interface GraphQLPluginOptions {
|
|
|
109
116
|
* `'error'` (default) fails the boot, `'warn'` logs.
|
|
110
117
|
*/
|
|
111
118
|
readonly enforcement?: 'error' | 'warn'
|
|
119
|
+
/**
|
|
120
|
+
* The GraphQL server serving the schema (RFC 0009): a driver of a subpath (`yogaDriver()`) or a
|
|
121
|
+
* third party's. Default: the built-in GraphQL-over-HTTP and SSE transport, the fastest.
|
|
122
|
+
*/
|
|
123
|
+
readonly driver?: GraphQLDriver
|
|
112
124
|
}
|
|
113
125
|
|
|
114
126
|
/** Options with their defaults, validated. */
|
|
@@ -139,6 +151,8 @@ export interface ResolvedGraphQLOptions {
|
|
|
139
151
|
readonly bufferSize: number
|
|
140
152
|
readonly pubsub: AnyPubSub | undefined
|
|
141
153
|
readonly enforcement: 'error' | 'warn'
|
|
154
|
+
/** `undefined`: the built-in transport. */
|
|
155
|
+
readonly driver: GraphQLDriver | undefined
|
|
142
156
|
}
|
|
143
157
|
|
|
144
158
|
const KEYS: ReadonlySet<string> = new Set([
|
|
@@ -155,6 +169,7 @@ const KEYS: ReadonlySet<string> = new Set([
|
|
|
155
169
|
'subscriptions',
|
|
156
170
|
'pubsub',
|
|
157
171
|
'enforcement',
|
|
172
|
+
'driver',
|
|
158
173
|
])
|
|
159
174
|
const PATH = /^\/[A-Za-z0-9\-._~!$&'()*+,;=:@/]*$/
|
|
160
175
|
const LIMIT_DEFAULTS = { maxTokens: 2000, maxDepth: 12, maxAliases: 30, maxDirectives: 50 } as const
|
|
@@ -307,6 +322,19 @@ export function resolveGraphQLOptions(
|
|
|
307
322
|
if (enforcement !== 'error' && enforcement !== 'warn')
|
|
308
323
|
throw invalidOption('enforcement', "'error' or 'warn'", enforcement)
|
|
309
324
|
|
|
325
|
+
const driver = resolveDriver(options['driver'])
|
|
326
|
+
if (driver !== undefined && subscriptions !== false) {
|
|
327
|
+
for (const key of ['heartbeat', 'maxDuration'] as const) {
|
|
328
|
+
if (subscriptions[key] !== undefined) {
|
|
329
|
+
throw new ZnightError(
|
|
330
|
+
ErrorCode.InvalidGraphQLSetup,
|
|
331
|
+
`GraphQLPlugin: subscriptions.${key} configures the built-in SSE transport, not the ${driver.name} driver`,
|
|
332
|
+
{ hints: [`Remove subscriptions.${key}: the ${driver.name} driver's own keep-alive options apply`] },
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
310
338
|
return {
|
|
311
339
|
path,
|
|
312
340
|
http,
|
|
@@ -327,5 +355,26 @@ export function resolveGraphQLOptions(
|
|
|
327
355
|
bufferSize,
|
|
328
356
|
pubsub: pubsub as AnyPubSub | undefined,
|
|
329
357
|
enforcement,
|
|
358
|
+
driver,
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** The `driver` option: an object with a `name` (not `builtin`) and `start()`, `routes()` and `stop()` functions when present. */
|
|
363
|
+
function resolveDriver(value: unknown): GraphQLDriver | undefined {
|
|
364
|
+
if (value === undefined) return undefined
|
|
365
|
+
const expected = 'a GraphQL driver ({ name, start(), routes?(), stop?() }, from yogaDriver() for instance)'
|
|
366
|
+
if (value === null || typeof value !== 'object') throw invalidOption('driver', expected, value)
|
|
367
|
+
const driver = value as Record<string, unknown>
|
|
368
|
+
const name = driver['name']
|
|
369
|
+
if (typeof name !== 'string' || name === '') throw invalidOption('driver', `${expected} with a name`, value)
|
|
370
|
+
if (name === 'builtin') {
|
|
371
|
+
throw invalidOption('driver.name', "a name other than 'builtin' (reserved for the built-in transport)", name)
|
|
372
|
+
}
|
|
373
|
+
if (typeof driver['start'] !== 'function') throw invalidOption('driver', `${expected} with a start() method`, value)
|
|
374
|
+
for (const method of ['routes', 'stop']) {
|
|
375
|
+
if (driver[method] !== undefined && typeof driver[method] !== 'function') {
|
|
376
|
+
throw invalidOption(`driver.${method}`, 'a function', driver[method])
|
|
377
|
+
}
|
|
330
378
|
}
|
|
379
|
+
return value as GraphQLDriver
|
|
331
380
|
}
|
package/src/pipeline.ts
CHANGED
|
@@ -45,6 +45,8 @@ export interface PipelineOptions {
|
|
|
45
45
|
readonly guard: (token: Token, coordinate: string) => Resolvable<GraphQLGuard>
|
|
46
46
|
/** The resolver provider of a binding (memoised by the caller). */
|
|
47
47
|
readonly resolver: (binding: GraphQLResolverBinding, coordinate: string) => Resolvable<object>
|
|
48
|
+
/** The provider of a custom parameter resolver class (RFC 0010; memoised by the caller). */
|
|
49
|
+
readonly paramResolver?: (token: Token, coordinate: string) => Resolvable<object>
|
|
48
50
|
/** Input object types by name (null handling of nested input values). */
|
|
49
51
|
readonly inputs: ReadonlyMap<string, GraphQLInputTypeDefinition>
|
|
50
52
|
/** The {@link Recover} function of a field that carries results (RFC 0008), `undefined` for the others. */
|
|
@@ -52,7 +54,15 @@ export interface PipelineOptions {
|
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
/** The generated invoker of a binding. */
|
|
55
|
-
type Invoke =
|
|
57
|
+
type Invoke = (
|
|
58
|
+
resolver: object,
|
|
59
|
+
parent: unknown,
|
|
60
|
+
args: GraphQLArgs,
|
|
61
|
+
context: GraphQLContext,
|
|
62
|
+
info: GraphQLResolveInfo,
|
|
63
|
+
field?: ResolverContext,
|
|
64
|
+
resolvers?: readonly object[],
|
|
65
|
+
) => unknown
|
|
56
66
|
|
|
57
67
|
/** Mutates a coerced argument or input value in place: `null` → `undefined` where flagged. */
|
|
58
68
|
type Normalize = (value: unknown) => void
|
|
@@ -78,7 +88,6 @@ function fieldFunctions(
|
|
|
78
88
|
parent: GraphQLObjectTypeDefinition,
|
|
79
89
|
): FieldFunctions {
|
|
80
90
|
const container = options.container
|
|
81
|
-
const invoke: Invoke = binding.invoke
|
|
82
91
|
const target = options.resolver(binding, coordinate)
|
|
83
92
|
const guards: Resolvable<GraphQLGuard>[] = [
|
|
84
93
|
...(globalGuardsApply(binding) ? options.globalGuards : []),
|
|
@@ -86,10 +95,16 @@ function fieldFunctions(
|
|
|
86
95
|
]
|
|
87
96
|
const normalize = field.args === undefined ? undefined : normalizers.args(field.args)
|
|
88
97
|
const recover = options.results?.(field, coordinate)
|
|
98
|
+
const custom = binding.context === true
|
|
89
99
|
const route =
|
|
90
|
-
guards.length === 0 && recover === undefined
|
|
100
|
+
guards.length === 0 && recover === undefined && !custom
|
|
101
|
+
? undefined
|
|
102
|
+
: Object.freeze({ meta: binding.meta, coordinate })
|
|
91
103
|
const parentType = parent.name
|
|
92
104
|
const fieldName = field.name
|
|
105
|
+
const invoke: Invoke = custom
|
|
106
|
+
? customInvoke(options, binding, coordinate, route as ResolverContext['route'], parentType, fieldName)
|
|
107
|
+
: (binding.invoke as Invoke)
|
|
93
108
|
|
|
94
109
|
let resolve: GraphQLFieldResolver<unknown, unknown>
|
|
95
110
|
if (guards.length === 0) {
|
|
@@ -120,15 +135,21 @@ function fieldFunctions(
|
|
|
120
135
|
resolve = (p, a, c, i) => {
|
|
121
136
|
const operation = c as OperationContext
|
|
122
137
|
const args = a as GraphQLArgs
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
138
|
+
const fieldContext = new FieldContext(
|
|
139
|
+
route as ResolverContext['route'],
|
|
140
|
+
operation,
|
|
141
|
+
parentType,
|
|
142
|
+
fieldName,
|
|
143
|
+
args,
|
|
144
|
+
p,
|
|
145
|
+
i,
|
|
127
146
|
)
|
|
128
|
-
|
|
147
|
+
const allowed = runGuards(container, guards, fieldContext)
|
|
148
|
+
// §8.5: guards (they see the coerced arguments), then null handling, then invoke (custom
|
|
149
|
+
// parameters share the guards' ResolverContext)
|
|
129
150
|
const call = (): unknown => {
|
|
130
151
|
if (normalize !== undefined) normalize(args)
|
|
131
|
-
return invoke(resolveIn(container, target), p, args, operation, i)
|
|
152
|
+
return invoke(resolveIn(container, target), p, args, operation, i, fieldContext)
|
|
132
153
|
}
|
|
133
154
|
if (allowed === true) return call()
|
|
134
155
|
if (allowed === false) return forbidden()
|
|
@@ -150,6 +171,48 @@ function identity(payload: unknown): unknown {
|
|
|
150
171
|
return payload
|
|
151
172
|
}
|
|
152
173
|
|
|
174
|
+
/**
|
|
175
|
+
* The invoke of a method with custom parameters (RFC 0010): the generated `invoke` receives the
|
|
176
|
+
* field's `ResolverContext` (the guards' one when the field has guards, else a new one) and the
|
|
177
|
+
* instances of its parameter resolvers: one frozen array resolved at boot when they are all
|
|
178
|
+
* singletons, otherwise resolved in the operation scope on every call.
|
|
179
|
+
*/
|
|
180
|
+
function customInvoke(
|
|
181
|
+
options: PipelineOptions,
|
|
182
|
+
binding: GraphQLResolverBinding,
|
|
183
|
+
coordinate: string,
|
|
184
|
+
route: ResolverContext['route'],
|
|
185
|
+
parentType: string,
|
|
186
|
+
fieldName: string,
|
|
187
|
+
): Invoke {
|
|
188
|
+
const generated = binding.invoke as Invoke
|
|
189
|
+
const container = options.container
|
|
190
|
+
const tokens = binding.paramResolvers ?? []
|
|
191
|
+
const resolvers = tokens.map(token => {
|
|
192
|
+
if (options.paramResolver === undefined) throw new TypeError(`No parameter resolver for ${coordinate}`)
|
|
193
|
+
return options.paramResolver(token, coordinate)
|
|
194
|
+
})
|
|
195
|
+
const context = (p: unknown, a: GraphQLArgs, c: GraphQLContext, i: GraphQLResolveInfo, f?: ResolverContext) =>
|
|
196
|
+
f ?? new FieldContext(route, c as OperationContext, parentType, fieldName, a, p, i)
|
|
197
|
+
if (resolvers.length === 0) {
|
|
198
|
+
return (r, p, a, c, i, f) => generated(r, p, a, c, i, context(p, a, c, i, f))
|
|
199
|
+
}
|
|
200
|
+
if (resolvers.every(resolver => resolver.token === undefined)) {
|
|
201
|
+
const instances = Object.freeze(resolvers.map(resolver => resolveIn(container, resolver)))
|
|
202
|
+
return (r, p, a, c, i, f) => generated(r, p, a, c, i, context(p, a, c, i, f), instances)
|
|
203
|
+
}
|
|
204
|
+
return (r, p, a, c, i, f) =>
|
|
205
|
+
generated(
|
|
206
|
+
r,
|
|
207
|
+
p,
|
|
208
|
+
a,
|
|
209
|
+
c,
|
|
210
|
+
i,
|
|
211
|
+
context(p, a, c, i, f),
|
|
212
|
+
resolvers.map(resolver => resolveIn(container, resolver)),
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
153
216
|
/**
|
|
154
217
|
* The resolve function of a field that carries results (RFC 0008): errors of the whole pipeline
|
|
155
218
|
* (guards, null handling, `invoke`), thrown or rejected, go through `recover`. Synchronous results
|
package/src/plugin.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `GraphQLPlugin` (RFC 0005 §5.11, §8.2): builds the schema from `metadata.extensions.graphql`,
|
|
3
3
|
* provides `GraphQLContext`, `PubSub` and itself to the container, executes operations
|
|
4
|
-
* (`execute()`, `subscribe()`) and contributes the
|
|
4
|
+
* (`execute()`, `subscribe()`, the `engine` of drivers, RFC 0009) and contributes the routes of its
|
|
5
|
+
* driver (the built-in GraphQL-over-HTTP endpoint by default) to `HttpPlugin`.
|
|
5
6
|
*/
|
|
6
7
|
import type {
|
|
7
8
|
Container,
|
|
@@ -15,9 +16,11 @@ import type {
|
|
|
15
16
|
Token,
|
|
16
17
|
ZnightMetadata,
|
|
17
18
|
} from '@znight/core'
|
|
18
|
-
import type { PluginHttpRoutes, PluginHttpRoutesContext, PluginRoute, SseOptions } from '@znight/http'
|
|
19
|
+
import type { HttpPlugin, PluginHttpRoutes, PluginHttpRoutesContext, PluginRoute, SseOptions } from '@znight/http'
|
|
19
20
|
import type { FormattedExecutionResult, GraphQLSchema } from 'graphql'
|
|
20
21
|
import type { CheckedField } from './decisions.ts'
|
|
22
|
+
import type { GraphQLDriver, GraphQLDriverRoute } from './driver.ts'
|
|
23
|
+
import type { GraphQLEngine } from './engine.ts'
|
|
21
24
|
import type { ExecuteOptions, GraphQLRequest, GraphQLResultStream } from './execution.ts'
|
|
22
25
|
import type { Resolvable } from './format.ts'
|
|
23
26
|
import type { GraphQLInputTypeDefinition, GraphQLMetadata, GraphQLResolverBinding } from './metadata.ts'
|
|
@@ -28,6 +31,7 @@ import { displayName, ErrorCode, resolveOptions, ZnightError } from '@znight/cor
|
|
|
28
31
|
import { printSchema } from 'graphql'
|
|
29
32
|
|
|
30
33
|
import { checkFieldDecisions } from './decisions.ts'
|
|
34
|
+
import { Engine } from './engine.ts'
|
|
31
35
|
import { GraphQLExecutor, singleResult } from './execution.ts'
|
|
32
36
|
import { ErrorFormatter } from './format.ts'
|
|
33
37
|
import { LoaderFactory } from './loaders.ts'
|
|
@@ -38,7 +42,8 @@ import { PubSub } from './pubsub.ts'
|
|
|
38
42
|
import { checkResultTypes, declaredTypes, ResultMappers } from './results.ts'
|
|
39
43
|
import { buildGraphQLSchema, checkGraphQLSection, graphqlSection } from './schema.ts'
|
|
40
44
|
import { GraphQLTokens } from './tokens.ts'
|
|
41
|
-
import {
|
|
45
|
+
import { BUILTIN_DRIVER, BuiltinDriver } from './transport/builtin.ts'
|
|
46
|
+
import { checkSseOptions } from './transport/http.ts'
|
|
42
47
|
|
|
43
48
|
/** Options of {@link GraphQLPlugin.execute} and {@link GraphQLPlugin.subscribe}. */
|
|
44
49
|
export interface DirectExecuteOptions {
|
|
@@ -53,6 +58,7 @@ interface Runtime {
|
|
|
53
58
|
readonly schema: GraphQLSchema
|
|
54
59
|
readonly section: GraphQLMetadata
|
|
55
60
|
readonly executor: GraphQLExecutor
|
|
61
|
+
readonly engine: GraphQLEngine
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
let readExecutor: (plugin: GraphQLPlugin) => GraphQLExecutor
|
|
@@ -101,6 +107,10 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
|
|
|
101
107
|
#runtime: Runtime | undefined
|
|
102
108
|
#stop: AbortController | undefined
|
|
103
109
|
#endpoint: string | undefined
|
|
110
|
+
/** The driver serving the schema: the `driver` option, or the built-in transport. */
|
|
111
|
+
#driver: GraphQLDriver | undefined
|
|
112
|
+
/** The driver was started (`onBootstrap`): `onStop` stops it. */
|
|
113
|
+
#started = false
|
|
104
114
|
#active = false
|
|
105
115
|
|
|
106
116
|
private constructor(options: PluginOptions<GraphQLPluginOptions>) {
|
|
@@ -164,6 +174,8 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
|
|
|
164
174
|
this.#runtime = undefined
|
|
165
175
|
this.#ownPubSub = undefined
|
|
166
176
|
this.#endpoint = undefined
|
|
177
|
+
this.#driver = undefined
|
|
178
|
+
this.#started = false
|
|
167
179
|
this.#container = ctx.container
|
|
168
180
|
this.#metadata = ctx.metadata
|
|
169
181
|
this.#logger = ctx.logger
|
|
@@ -183,42 +195,58 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
|
|
|
183
195
|
}
|
|
184
196
|
|
|
185
197
|
/**
|
|
186
|
-
* The `PluginHttpRoutes` contract of `@znight/http`: the
|
|
187
|
-
*
|
|
198
|
+
* The `PluginHttpRoutes` contract of `@znight/http`: the routes of the driver, with the `http`
|
|
199
|
+
* option's meta and guards, and its body limit on the routes that are not `GET`. For the built-in
|
|
200
|
+
* transport, the GraphQL endpoint (`GET` and `POST` on `path`). `[]` with `http: false`.
|
|
188
201
|
*/
|
|
189
202
|
httpRoutes(context: PluginHttpRoutesContext): readonly PluginRoute[] {
|
|
190
203
|
this.#container ??= context.container
|
|
191
204
|
const options = this.#resolve()
|
|
192
205
|
const http = options.http
|
|
193
206
|
if (http === false) return []
|
|
207
|
+
const driver = this.#driverOf(options)
|
|
208
|
+
// a server-mounted driver attaches itself to the adapter in start()
|
|
209
|
+
if (driver.routes === undefined) return []
|
|
194
210
|
const endpoint = endpointPath(context.prefix, options.path)
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
endpoint,
|
|
199
|
-
get: http.get,
|
|
200
|
-
graphiql: options.graphiql,
|
|
201
|
-
sse: sseOptions(options),
|
|
202
|
-
})
|
|
211
|
+
const routes = driver.routes({ path: options.path, endpoint })
|
|
212
|
+
if (!Array.isArray(routes))
|
|
213
|
+
throw invalidOption(`routes() of the ${driver.name} driver`, 'an array of routes', routes)
|
|
203
214
|
this.#endpoint = endpoint
|
|
204
|
-
|
|
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
|
|
215
|
+
return routes.map(route => mount(route, http, driver))
|
|
214
216
|
}
|
|
215
217
|
|
|
216
|
-
onBootstrap(ctx: PluginContext): void {
|
|
218
|
+
onBootstrap(ctx: PluginContext): void | Promise<void> {
|
|
217
219
|
this.#container = ctx.container
|
|
218
220
|
const options = this.#resolve()
|
|
219
221
|
if (typeof this.#options === 'function') this.#checkHttp(ctx, options)
|
|
220
|
-
this.#checkSse(options)
|
|
221
|
-
|
|
222
|
+
if (options.driver === undefined) this.#checkSse(options)
|
|
223
|
+
const runtime = this.#build(ctx, options)
|
|
224
|
+
this.#runtime = runtime
|
|
225
|
+
const driver = this.#driverOf(options)
|
|
226
|
+
// the built-in transport reads the executor on its first request: nothing to start
|
|
227
|
+
if (options.driver === undefined) return
|
|
228
|
+
let started: void | Promise<void>
|
|
229
|
+
try {
|
|
230
|
+
started = driver.start(runtime.engine, {
|
|
231
|
+
logger: ctx.logger,
|
|
232
|
+
container: ctx.container,
|
|
233
|
+
http: ctx.getPlugin<HttpPlugin>('http'),
|
|
234
|
+
})
|
|
235
|
+
} catch (error) {
|
|
236
|
+
throw driverFailed(driver, error)
|
|
237
|
+
}
|
|
238
|
+
if (!isPromise(started)) {
|
|
239
|
+
this.#started = true
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
return started.then(
|
|
243
|
+
() => {
|
|
244
|
+
this.#started = true
|
|
245
|
+
},
|
|
246
|
+
(error: unknown) => {
|
|
247
|
+
throw driverFailed(driver, error)
|
|
248
|
+
},
|
|
249
|
+
)
|
|
222
250
|
}
|
|
223
251
|
|
|
224
252
|
onStart(ctx: PluginContext): void {
|
|
@@ -230,12 +258,45 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
|
|
|
230
258
|
else ctx.logger.info('GraphQL endpoint ready', { path: options.path, ...details })
|
|
231
259
|
}
|
|
232
260
|
|
|
261
|
+
/**
|
|
262
|
+
* The `PluginHttpRoutes` contract: `HttpPlugin` starts draining, whatever the plugin order. The
|
|
263
|
+
* subscriptions of the engine end (a route-mounted driver serves them as streamed responses
|
|
264
|
+
* `HttpPlugin` does not close), and new ones are refused (503); queries and mutations in flight
|
|
265
|
+
* are served. Built-in SSE streams are closed by `HttpPlugin` itself.
|
|
266
|
+
*/
|
|
267
|
+
onHttpDrain(): Promise<void> | undefined {
|
|
268
|
+
return this.#runtime?.executor.stopEngineStreams()
|
|
269
|
+
}
|
|
270
|
+
|
|
233
271
|
async onStop(): Promise<void> {
|
|
234
272
|
this.#stop?.abort(new Error('The application is stopping'))
|
|
235
|
-
|
|
273
|
+
// direct subscriptions and the driver's (engine) ones; built-in SSE streams end with HttpPlugin
|
|
274
|
+
await this.#runtime?.executor.stopStreams()
|
|
275
|
+
// a route-mounted driver serves requests until HttpPlugin drained them: it stops in onDispose
|
|
276
|
+
if (this.#driver?.routes === undefined) await this.#stopDriver()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
onDispose(): void | Promise<void> {
|
|
280
|
+
if (!this.#started) {
|
|
281
|
+
this.#release()
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
// after HttpPlugin drained (every onStop ran), or disposed without having started (onStop did not run)
|
|
285
|
+
this.#stop?.abort(new Error('The application is stopping'))
|
|
286
|
+
return Promise.resolve(this.#runtime?.executor.stopStreams())
|
|
287
|
+
.then(() => this.#stopDriver())
|
|
288
|
+
.finally(() => this.#release())
|
|
236
289
|
}
|
|
237
290
|
|
|
238
|
-
|
|
291
|
+
/** Stops the driver started in onBootstrap (once). */
|
|
292
|
+
#stopDriver(): void | Promise<void> {
|
|
293
|
+
const driver = this.#driver
|
|
294
|
+
if (!this.#started) return
|
|
295
|
+
this.#started = false
|
|
296
|
+
return driver?.stop?.()
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
#release(): void {
|
|
239
300
|
this.#active = false
|
|
240
301
|
this.#ownPubSub?.close()
|
|
241
302
|
this.#ownPubSub = undefined
|
|
@@ -250,6 +311,15 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
|
|
|
250
311
|
return this.#ready().schema
|
|
251
312
|
}
|
|
252
313
|
|
|
314
|
+
/**
|
|
315
|
+
* The GraphQL engine (after bootstrap, RFC 0009): graphql-js-shaped `parse`, `validate`, `execute`
|
|
316
|
+
* and `subscribe` running this plugin's schema with its guards, loaders, request scope, error
|
|
317
|
+
* formatting and hook. What drivers receive in `start()`; any GraphQL server can call it.
|
|
318
|
+
*/
|
|
319
|
+
get engine(): GraphQLEngine {
|
|
320
|
+
return this.#ready().engine
|
|
321
|
+
}
|
|
322
|
+
|
|
253
323
|
/** The SDL of the schema (graphql-js `printSchema`). */
|
|
254
324
|
printSchema(): string {
|
|
255
325
|
return printSchema(this.#ready().schema)
|
|
@@ -320,6 +390,25 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
|
|
|
320
390
|
return this.#resolved
|
|
321
391
|
}
|
|
322
392
|
|
|
393
|
+
/** The driver of the application: the `driver` option, or the built-in transport. */
|
|
394
|
+
#driverOf(options: ResolvedGraphQLOptions): GraphQLDriver {
|
|
395
|
+
let driver = this.#driver
|
|
396
|
+
if (driver === undefined) {
|
|
397
|
+
const http = options.http
|
|
398
|
+
driver =
|
|
399
|
+
options.driver ??
|
|
400
|
+
new BuiltinDriver({
|
|
401
|
+
// called on the first request: the routes are collected before or after this plugin's onBootstrap
|
|
402
|
+
executor: () => this.#ready().executor,
|
|
403
|
+
get: http !== false && http.get,
|
|
404
|
+
graphiql: options.graphiql,
|
|
405
|
+
sse: sseOptions(options),
|
|
406
|
+
})
|
|
407
|
+
this.#driver = driver
|
|
408
|
+
}
|
|
409
|
+
return driver
|
|
410
|
+
}
|
|
411
|
+
|
|
323
412
|
/** The SSE options are those of `HttpResponse.sse()` (heartbeat and maxDuration from 1 000 ms): checked at boot. */
|
|
324
413
|
#checkSse(options: ResolvedGraphQLOptions): void {
|
|
325
414
|
const sse = sseOptions(options)
|
|
@@ -329,6 +418,14 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
|
|
|
329
418
|
}
|
|
330
419
|
|
|
331
420
|
#checkHttp(ctx: PluginContext, options: ResolvedGraphQLOptions): void {
|
|
421
|
+
const driver = options.driver
|
|
422
|
+
if (options.http === false && driver?.routes !== undefined) {
|
|
423
|
+
throw new ZnightError(
|
|
424
|
+
ErrorCode.InvalidGraphQLSetup,
|
|
425
|
+
`GraphQLPlugin: the ${driver.name} driver serves routes, which http: false turns off`,
|
|
426
|
+
{ hints: ['Remove http: false: the driver serves the endpoint through HttpPlugin'] },
|
|
427
|
+
)
|
|
428
|
+
}
|
|
332
429
|
if (options.http !== false && ctx.getPlugin('http') === undefined) {
|
|
333
430
|
throw new ZnightError(
|
|
334
431
|
ErrorCode.InvalidGraphQLSetup,
|
|
@@ -396,6 +493,8 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
|
|
|
396
493
|
guard: (token, coordinate) => providers.token<GraphQLGuard>(token, `guard of ${coordinate}`, 'canResolve'),
|
|
397
494
|
resolver: (binding, coordinate) =>
|
|
398
495
|
providers.token<object>(binding.token, `resolver of ${coordinate}`, binding.method),
|
|
496
|
+
paramResolver: (token, coordinate) =>
|
|
497
|
+
providers.token<object>(token, `parameter resolver of ${coordinate}`, 'resolve'),
|
|
399
498
|
inputs,
|
|
400
499
|
results: (field, coordinate) => results.recover(field, coordinate),
|
|
401
500
|
}),
|
|
@@ -423,13 +522,29 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
|
|
|
423
522
|
introspection: options.introspection,
|
|
424
523
|
documentCache: options.documentCache,
|
|
425
524
|
maxActiveSubscriptions: options.subscriptions === false ? undefined : options.subscriptions.maxActive,
|
|
525
|
+
subscriptions: options.subscriptions !== false,
|
|
426
526
|
})
|
|
527
|
+
const http = ctx.getPlugin<HttpPlugin>('http')
|
|
528
|
+
const engine = new Engine(
|
|
529
|
+
executor,
|
|
530
|
+
{
|
|
531
|
+
path:
|
|
532
|
+
this.#endpoint ?? endpointPath(options.http === false || http === undefined ? '' : http.prefix, options.path),
|
|
533
|
+
introspection: options.introspection,
|
|
534
|
+
ide: options.graphiql,
|
|
535
|
+
exposeErrors: options.exposeErrors,
|
|
536
|
+
maxTokens: options.limits.maxTokens,
|
|
537
|
+
subscriptions: options.subscriptions === false ? false : { maxActive: options.subscriptions.maxActive },
|
|
538
|
+
http: options.http === false ? false : { get: options.http.get, bodyLimit: options.http.bodyLimit },
|
|
539
|
+
},
|
|
540
|
+
options.driver?.name ?? BUILTIN_DRIVER,
|
|
541
|
+
)
|
|
427
542
|
ctx.logger.debug('GraphQL schema built', {
|
|
428
543
|
types: types.length,
|
|
429
544
|
schemaHash: section.schemaHash,
|
|
430
545
|
...(results.fields === 0 ? {} : { resultFields: results.fields }),
|
|
431
546
|
})
|
|
432
|
-
return { schema, section, executor }
|
|
547
|
+
return { schema, section, executor, engine }
|
|
433
548
|
}
|
|
434
549
|
}
|
|
435
550
|
|
|
@@ -444,6 +559,34 @@ function direct(options: DirectExecuteOptions | undefined): ExecuteOptions {
|
|
|
444
559
|
|
|
445
560
|
const DIRECT: ExecuteOptions = Object.freeze({ transport: 'direct' })
|
|
446
561
|
|
|
562
|
+
/** A route of the driver as a plugin route: the `http` option's meta and guards, its body limit unless `GET`. */
|
|
563
|
+
function mount(
|
|
564
|
+
route: GraphQLDriverRoute,
|
|
565
|
+
http: Exclude<ResolvedGraphQLOptions['http'], false>,
|
|
566
|
+
driver: GraphQLDriver,
|
|
567
|
+
): PluginRoute {
|
|
568
|
+
if (route === null || typeof route !== 'object' || typeof route.handler !== 'function') {
|
|
569
|
+
throw invalidOption(`routes() of the ${driver.name} driver`, 'routes { method, path, name, handler }', route)
|
|
570
|
+
}
|
|
571
|
+
return {
|
|
572
|
+
method: route.method,
|
|
573
|
+
path: route.path,
|
|
574
|
+
name: route.name,
|
|
575
|
+
handler: route.handler,
|
|
576
|
+
...(http.meta.length === 0 ? {} : { meta: http.meta }),
|
|
577
|
+
...(http.guards.length === 0 ? {} : { guards: http.guards }),
|
|
578
|
+
...(route.method === 'GET' || route.method === 'HEAD' ? {} : { bodyLimit: http.bodyLimit }),
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** ZNIGHT066 for a driver whose `start()` failed, the original error as `cause`. */
|
|
583
|
+
function driverFailed(driver: GraphQLDriver, error: unknown): ZnightError {
|
|
584
|
+
return new ZnightError(ErrorCode.InvalidGraphQLSetup, `GraphQLPlugin: the ${driver.name} driver failed to start`, {
|
|
585
|
+
cause: error,
|
|
586
|
+
hints: ['See the cause: the driver could not create its server'],
|
|
587
|
+
})
|
|
588
|
+
}
|
|
589
|
+
|
|
447
590
|
/** `prefix` (`''`, `/api`) joined with `path` (`/graphql`), without trailing slash: `/api/graphql`. */
|
|
448
591
|
function endpointPath(prefix: string, path: string): string {
|
|
449
592
|
const joined = `${prefix.replace(/\/+$/, '')}/${path.replace(/^\/+|\/+$/g, '')}`
|