@znight/graphql 0.2.2 → 0.3.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 +222 -15
- package/dist/{types-DQ713UgI.d.mts → driver-Cnze8Oyr.d.mts} +184 -7
- package/dist/http-BWcayUtH.mjs +2165 -0
- package/dist/http-BWcayUtH.mjs.map +1 -0
- package/dist/index.d.mts +37 -49
- package/dist/index.mjs +2 -1
- package/dist/index.mjs.map +1 -1
- package/dist/{plugin-CR_wVfRU.mjs → plugin-q4oz7d6s.mjs} +214 -1774
- package/dist/plugin-q4oz7d6s.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/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/options.ts +53 -4
- package/src/plugin.ts +170 -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 +12 -2
- 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/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/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,
|
|
@@ -423,13 +520,29 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
|
|
|
423
520
|
introspection: options.introspection,
|
|
424
521
|
documentCache: options.documentCache,
|
|
425
522
|
maxActiveSubscriptions: options.subscriptions === false ? undefined : options.subscriptions.maxActive,
|
|
523
|
+
subscriptions: options.subscriptions !== false,
|
|
426
524
|
})
|
|
525
|
+
const http = ctx.getPlugin<HttpPlugin>('http')
|
|
526
|
+
const engine = new Engine(
|
|
527
|
+
executor,
|
|
528
|
+
{
|
|
529
|
+
path:
|
|
530
|
+
this.#endpoint ?? endpointPath(options.http === false || http === undefined ? '' : http.prefix, options.path),
|
|
531
|
+
introspection: options.introspection,
|
|
532
|
+
ide: options.graphiql,
|
|
533
|
+
exposeErrors: options.exposeErrors,
|
|
534
|
+
maxTokens: options.limits.maxTokens,
|
|
535
|
+
subscriptions: options.subscriptions === false ? false : { maxActive: options.subscriptions.maxActive },
|
|
536
|
+
http: options.http === false ? false : { get: options.http.get, bodyLimit: options.http.bodyLimit },
|
|
537
|
+
},
|
|
538
|
+
options.driver?.name ?? BUILTIN_DRIVER,
|
|
539
|
+
)
|
|
427
540
|
ctx.logger.debug('GraphQL schema built', {
|
|
428
541
|
types: types.length,
|
|
429
542
|
schemaHash: section.schemaHash,
|
|
430
543
|
...(results.fields === 0 ? {} : { resultFields: results.fields }),
|
|
431
544
|
})
|
|
432
|
-
return { schema, section, executor }
|
|
545
|
+
return { schema, section, executor, engine }
|
|
433
546
|
}
|
|
434
547
|
}
|
|
435
548
|
|
|
@@ -444,6 +557,34 @@ function direct(options: DirectExecuteOptions | undefined): ExecuteOptions {
|
|
|
444
557
|
|
|
445
558
|
const DIRECT: ExecuteOptions = Object.freeze({ transport: 'direct' })
|
|
446
559
|
|
|
560
|
+
/** A route of the driver as a plugin route: the `http` option's meta and guards, its body limit unless `GET`. */
|
|
561
|
+
function mount(
|
|
562
|
+
route: GraphQLDriverRoute,
|
|
563
|
+
http: Exclude<ResolvedGraphQLOptions['http'], false>,
|
|
564
|
+
driver: GraphQLDriver,
|
|
565
|
+
): PluginRoute {
|
|
566
|
+
if (route === null || typeof route !== 'object' || typeof route.handler !== 'function') {
|
|
567
|
+
throw invalidOption(`routes() of the ${driver.name} driver`, 'routes { method, path, name, handler }', route)
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
method: route.method,
|
|
571
|
+
path: route.path,
|
|
572
|
+
name: route.name,
|
|
573
|
+
handler: route.handler,
|
|
574
|
+
...(http.meta.length === 0 ? {} : { meta: http.meta }),
|
|
575
|
+
...(http.guards.length === 0 ? {} : { guards: http.guards }),
|
|
576
|
+
...(route.method === 'GET' || route.method === 'HEAD' ? {} : { bodyLimit: http.bodyLimit }),
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** ZNIGHT066 for a driver whose `start()` failed, the original error as `cause`. */
|
|
581
|
+
function driverFailed(driver: GraphQLDriver, error: unknown): ZnightError {
|
|
582
|
+
return new ZnightError(ErrorCode.InvalidGraphQLSetup, `GraphQLPlugin: the ${driver.name} driver failed to start`, {
|
|
583
|
+
cause: error,
|
|
584
|
+
hints: ['See the cause: the driver could not create its server'],
|
|
585
|
+
})
|
|
586
|
+
}
|
|
587
|
+
|
|
447
588
|
/** `prefix` (`''`, `/api`) joined with `path` (`/graphql`), without trailing slash: `/api/graphql`. */
|
|
448
589
|
function endpointPath(prefix: string, path: string): string {
|
|
449
590
|
const joined = `${prefix.replace(/\/+$/, '')}/${path.replace(/^\/+|\/+$/g, '')}`
|
package/src/testing.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* `GraphQLPlugin.execute()`;
|
|
7
7
|
* - `subscribeOperation()` streams an operation over GraphQL over SSE in memory, or through
|
|
8
8
|
* `GraphQLPlugin.subscribe()` without HTTP transport;
|
|
9
|
+
* - `describeDriverConformance()` describes the conformance scenarios of a GraphQL driver (RFC 0009);
|
|
9
10
|
* - `fakeLoader()` builds a `Loader<L>` whose `load` / `loadMany` are spies of the running test
|
|
10
11
|
* framework (`bun:test`, Vitest, Jest, or the built-in spy of `@znight/testing`).
|
|
11
12
|
*
|
|
@@ -23,6 +24,8 @@ import { resolveMockFn } from '@znight/testing'
|
|
|
23
24
|
|
|
24
25
|
import { endpointOf, executorOf, type GraphQLPlugin } from './plugin.ts'
|
|
25
26
|
|
|
27
|
+
export { type ConformanceTestApi, type DriverConformanceOptions, describeDriverConformance } from './conformance.ts'
|
|
28
|
+
|
|
26
29
|
/** An operation for {@link executeOperation} and {@link subscribeOperation}. */
|
|
27
30
|
export interface OperationRequest {
|
|
28
31
|
readonly query: string
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in transport as a driver (RFC 0009 §4.11): GraphQL over HTTP and SSE, the default when
|
|
3
|
+
* the `driver` option is absent. It keeps its privileged access to the internal executor (statuses,
|
|
4
|
+
* formatted results, SSE sessions, the memoised negotiation): its handlers are the ones of
|
|
5
|
+
* `HttpTransport`, unchanged, and it never goes through the public engine.
|
|
6
|
+
*/
|
|
7
|
+
import type { SseOptions } from '@znight/http'
|
|
8
|
+
import type { GraphQLDriver, GraphQLDriverRoute, GraphQLDriverRoutesContext } from '../driver.ts'
|
|
9
|
+
import type { GraphQLExecutor } from '../execution.ts'
|
|
10
|
+
|
|
11
|
+
import { HttpTransport } from './http.ts'
|
|
12
|
+
|
|
13
|
+
/** What the built-in driver is built from (resolved plugin options). */
|
|
14
|
+
export interface BuiltinDriverOptions {
|
|
15
|
+
/** The executor of the plugin, read on the first request (routes may be collected before the bootstrap). */
|
|
16
|
+
readonly executor: () => GraphQLExecutor
|
|
17
|
+
/** Queries over `GET`. */
|
|
18
|
+
readonly get: boolean
|
|
19
|
+
readonly graphiql: boolean
|
|
20
|
+
/** `HttpResponse.sse()` options of GraphQL over SSE; `false`: no event streams. */
|
|
21
|
+
readonly sse: SseOptions | false
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The name of the built-in transport (`GraphQLOperationEvent.driver`), reserved. */
|
|
25
|
+
export const BUILTIN_DRIVER = 'builtin'
|
|
26
|
+
|
|
27
|
+
/** The built-in GraphQL-over-HTTP and SSE endpoint. */
|
|
28
|
+
export class BuiltinDriver implements GraphQLDriver {
|
|
29
|
+
readonly name: string = BUILTIN_DRIVER
|
|
30
|
+
readonly #options: BuiltinDriverOptions
|
|
31
|
+
|
|
32
|
+
constructor(options: BuiltinDriverOptions) {
|
|
33
|
+
this.#options = options
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `POST` on the path, and `GET` when queries over `GET` or GraphiQL are on (the router answers 405 otherwise). */
|
|
37
|
+
routes(context: GraphQLDriverRoutesContext): readonly GraphQLDriverRoute[] {
|
|
38
|
+
const options = this.#options
|
|
39
|
+
const transport = new HttpTransport({
|
|
40
|
+
executor: options.executor,
|
|
41
|
+
endpoint: context.endpoint,
|
|
42
|
+
get: options.get,
|
|
43
|
+
graphiql: options.graphiql,
|
|
44
|
+
sse: options.sse,
|
|
45
|
+
})
|
|
46
|
+
const routes: GraphQLDriverRoute[] = [
|
|
47
|
+
{ method: 'POST', path: context.path, name: 'handle', handler: transport.post },
|
|
48
|
+
]
|
|
49
|
+
if (options.get || options.graphiql) {
|
|
50
|
+
routes.unshift({ method: 'GET', path: context.path, name: 'handle', handler: transport.get })
|
|
51
|
+
}
|
|
52
|
+
return routes
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Nothing to start: the handlers read the executor on the first request. */
|
|
56
|
+
start(): void {}
|
|
57
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Fetch bridge of route-mounted drivers (RFC 0009 §4.4, Q10): the Web `Request` a Fetch server
|
|
3
|
+
* (GraphQL Yoga) handles, built from the `HttpContext` of the route. Internal to `@znight/graphql`
|
|
4
|
+
* (a candidate for `@znight/http` once a second user appears).
|
|
5
|
+
*
|
|
6
|
+
* - On `honoAdapter()` the native request already is a Web `Request`: it is handed over as is when
|
|
7
|
+
* its body was not read and cannot exceed the route's body limit (a `GET`, or a declared
|
|
8
|
+
* `content-length` within the limit), so nothing is copied.
|
|
9
|
+
* - Otherwise (Node-style adapters, chunked bodies) a `Request` is built from the method, the URL,
|
|
10
|
+
* the headers and the body read by `ctx.rawBody()`: the route's body limit then answers with
|
|
11
|
+
* `HttpPlugin`'s 413 on every adapter, before the server sees the request. GraphQL servers buffer
|
|
12
|
+
* the body to parse it anyway, so reading it first costs no extra copy of note.
|
|
13
|
+
*/
|
|
14
|
+
import type { HttpContext } from '@znight/http'
|
|
15
|
+
|
|
16
|
+
/** The Web `Request` of the route's request, read under `limit` bytes. */
|
|
17
|
+
export function fetchRequest(ctx: HttpContext, limit: number): Request | Promise<Request> {
|
|
18
|
+
const native = ctx.request
|
|
19
|
+
const method = ctx.method
|
|
20
|
+
const bodyless = method === 'GET' || method === 'HEAD'
|
|
21
|
+
if (native instanceof Request && !native.bodyUsed) {
|
|
22
|
+
if (bodyless) return native
|
|
23
|
+
const length = ctx.headers['content-length']
|
|
24
|
+
if (length !== undefined && ctx.headers['transfer-encoding'] === undefined) {
|
|
25
|
+
// past the limit, ctx.rawBody() below rejects with HttpPlugin's own 413
|
|
26
|
+
const declared = Number(length)
|
|
27
|
+
if (Number.isSafeInteger(declared) && declared >= 0 && declared <= limit) return native
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const url = requestUrl(ctx)
|
|
31
|
+
const headers = requestHeaders(ctx)
|
|
32
|
+
if (bodyless) return new Request(url, { method, headers, signal: ctx.signal })
|
|
33
|
+
return ctx.rawBody().then(body => new Request(url, { method, headers, body, signal: ctx.signal }))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The absolute URL of the request: the raw target on the request's protocol and host. */
|
|
37
|
+
function requestUrl(ctx: HttpContext): string {
|
|
38
|
+
const host = ctx.headers['host']
|
|
39
|
+
const target = ctx.url
|
|
40
|
+
if (host !== undefined && target.startsWith('/') && !target.startsWith('//')) {
|
|
41
|
+
const url = `${ctx.protocol}://${host}${target}`
|
|
42
|
+
if (URL.canParse(url)) return url
|
|
43
|
+
}
|
|
44
|
+
// no host header, or one a URL cannot hold: the path and query string still route the request
|
|
45
|
+
return new URL(target.startsWith('/') ? target.replace(/^\/+/, '/') : `/${target}`, `${ctx.protocol}://localhost`)
|
|
46
|
+
.href
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function requestHeaders(ctx: HttpContext): Headers {
|
|
50
|
+
const headers = new Headers()
|
|
51
|
+
const source = ctx.headers
|
|
52
|
+
for (const name in source) {
|
|
53
|
+
const value = source[name]
|
|
54
|
+
if (value !== undefined) headers.append(name, value)
|
|
55
|
+
}
|
|
56
|
+
return headers
|
|
57
|
+
}
|
package/src/transport/http.ts
CHANGED
|
@@ -90,8 +90,12 @@ const GET_NOT_ALLOWED = errorReply(
|
|
|
90
90
|
Object.freeze({ allow: 'POST' }),
|
|
91
91
|
)
|
|
92
92
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Whether a `POST` body is JSON (`application/json`, UTF-8): the only body the endpoint takes (and
|
|
95
|
+
* the Yoga driver), so that a cross-site request, which cannot send it without a CORS preflight,
|
|
96
|
+
* never executes an operation. `content-type` values seen most often are compared before parsing.
|
|
97
|
+
*/
|
|
98
|
+
export function isJsonRequest(contentType: string | undefined): boolean {
|
|
95
99
|
if (contentType === 'application/json' || contentType === 'application/json; charset=utf-8') return true
|
|
96
100
|
// parsed once per distinct value (memoised by @znight/http)
|
|
97
101
|
const media = parseMediaType(contentType)
|
|
@@ -99,6 +103,11 @@ function isJsonRequest(contentType: string | undefined): boolean {
|
|
|
99
103
|
return media.charset === undefined || media.charset === 'utf-8' || media.charset === 'utf8'
|
|
100
104
|
}
|
|
101
105
|
|
|
106
|
+
/** The 415 of a `POST` body that is not JSON, in the negotiated JSON type. */
|
|
107
|
+
export function notJson(media: MediaKind): HttpResponse<FormattedExecutionResult> {
|
|
108
|
+
return errorReply(media, 415, errorBody('Send GraphQL requests as application/json (content-type: application/json)'))
|
|
109
|
+
}
|
|
110
|
+
|
|
102
111
|
/** Why the parameters of a request are invalid, `undefined` when they are valid. */
|
|
103
112
|
function invalidParams(params: Readonly<Record<string, unknown>>): string | undefined {
|
|
104
113
|
const query = params['query']
|
|
@@ -211,13 +220,7 @@ export class HttpTransport {
|
|
|
211
220
|
const negotiated = this.#post.choose(headers['accept'])
|
|
212
221
|
const media = (negotiated & MEDIA) as MediaKind
|
|
213
222
|
if (media === Media.None) return NOT_ACCEPTABLE
|
|
214
|
-
if (!isJsonRequest(headers['content-type']))
|
|
215
|
-
return errorReply(
|
|
216
|
-
media,
|
|
217
|
-
415,
|
|
218
|
-
errorBody('Send GraphQL requests as application/json (content-type: application/json)'),
|
|
219
|
-
)
|
|
220
|
-
}
|
|
223
|
+
if (!isJsonRequest(headers['content-type'])) return notJson(media)
|
|
221
224
|
const encoding = headers['content-encoding']
|
|
222
225
|
if (encoding !== undefined && encoding.trim().toLowerCase() !== 'identity' && encoding.trim() !== '') {
|
|
223
226
|
return errorReply(media, 415, errorBody(`Unsupported content encoding "${encoding}"`))
|