@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/src/yoga.ts ADDED
@@ -0,0 +1,471 @@
1
+ /**
2
+ * `@znight/graphql/yoga`: the GraphQL Yoga driver (RFC 0009 §5.1). GraphQL Yoga 6 serves the schema
3
+ * of `GraphQLPlugin` (its protocol, Envelop plugins, GraphiQL, SSE subscriptions) and hands every
4
+ * document to Znight's engine, which parses it through its document cache, validates it with its
5
+ * limits, and executes it with the application's guards, loaders, request scope, error masking and
6
+ * hook. Mounted as plugin routes of `HttpPlugin`: the HTTP pipeline (request scope, hooks, guards,
7
+ * CORS, security headers, drain) wraps every request, on every adapter, on Node.js and Bun.
8
+ *
9
+ * Needs `graphql-yoga` (an optional peer dependency of `@znight/graphql`), only loaded by this entry.
10
+ */
11
+ import type { CoreLogger } from '@znight/core'
12
+ import type { HttpContext } from '@znight/http'
13
+ import type { DocumentNode, ExecutionArgs, ExecutionResult, GraphQLSchema, ValidationRule } from 'graphql'
14
+ import type {
15
+ GraphiQLOptions,
16
+ YogaInitialContext,
17
+ Plugin as YogaPlugin,
18
+ YogaServerInstance,
19
+ YogaServerOptions,
20
+ } from 'graphql-yoga'
21
+ import type { GraphQLDriver, GraphQLDriverContext, GraphQLDriverRoute, GraphQLDriverRoutesContext } from './driver.ts'
22
+ import type { EngineExecuteOptions, GraphQLEngine } from './engine.ts'
23
+ import type { GraphQLContext } from './types.ts'
24
+
25
+ import { ErrorCode, ZnightError } from '@znight/core'
26
+ import { parseByteSize } from '@znight/http'
27
+ import { GraphQLError } from 'graphql'
28
+ import { createYoga } from 'graphql-yoga'
29
+
30
+ import { GraphQLErrorCode } from './errors.ts'
31
+ import { MEDIA, Media, type MediaKind, negotiate } from './transport/accept.ts'
32
+ import { fetchRequest } from './transport/fetch.ts'
33
+ import { isJsonRequest, notJson } from './transport/http.ts'
34
+
35
+ /** The server context the driver gives Yoga: the `HttpContext` of the route, as `znight`. */
36
+ export interface YogaServerContext {
37
+ readonly znight: HttpContext
38
+ }
39
+
40
+ /**
41
+ * Yoga's context of an operation: its initial context (`request`, `params`, `log`) and the server
42
+ * context of the driver. What {@link yogaContext} returns.
43
+ */
44
+ export type YogaContext = YogaInitialContext & YogaServerContext
45
+
46
+ /** Options of Yoga the engine or `GraphQLPlugin` own (RFC 0009 §4.6): refused by {@link yogaDriver}. */
47
+ const OWNED = [
48
+ 'schema',
49
+ 'context',
50
+ 'graphqlEndpoint',
51
+ 'maskedErrors',
52
+ 'cors',
53
+ 'fetchAPI',
54
+ 'maxRequestBodySize',
55
+ 'parserAndValidationCache',
56
+ 'requestId',
57
+ 'healthCheckEndpoint',
58
+ 'landingPage',
59
+ 'disposeOnProcessTerminate',
60
+ 'batching',
61
+ 'multipart',
62
+ ] as const
63
+
64
+ /** Where each owned option comes from, for the ZNIGHT066 hint. */
65
+ const OWNER: Readonly<Record<(typeof OWNED)[number], string>> = {
66
+ schema: 'the engine serves the schema GraphQLPlugin built',
67
+ context: "resolvers get GraphQLContext; read Yoga's context with yogaContext(ctx)",
68
+ graphqlEndpoint: 'set the path option of GraphQLPlugin',
69
+ maskedErrors: 'the engine masks errors once (exposeErrors, errorMappers)',
70
+ cors: 'set the cors option of HttpPlugin',
71
+ fetchAPI: "the driver gives Yoga the runtime's own Fetch API",
72
+ maxRequestBodySize: 'set http.bodyLimit of GraphQLPlugin',
73
+ parserAndValidationCache: 'the engine caches documents (the documentCache option of GraphQLPlugin)',
74
+ requestId: 'Yoga uses the request id of HttpPlugin',
75
+ healthCheckEndpoint: 'the driver only serves the GraphQL endpoint',
76
+ landingPage: 'the driver only serves the GraphQL endpoint',
77
+ disposeOnProcessTerminate: 'the application stops Yoga once HttpPlugin drained its requests',
78
+ batching:
79
+ 'Send one operation per request: the operations of a batch would share the request scope and its GraphQLContext',
80
+ multipart:
81
+ 'POST bodies are application/json only: form and multipart bodies skip the CORS preflight (CSRF); upload files through an HTTP controller',
82
+ }
83
+
84
+ /**
85
+ * Options of {@link yogaDriver}: Yoga's own (`plugins`, `logging`, `allowedHeaders`...), except
86
+ * those the engine, `GraphQLPlugin` or `HttpPlugin` own: the schema, the context, the endpoint
87
+ * (`path`), masking (`exposeErrors`), CORS, the body limit (`http.bodyLimit`), the document cache,
88
+ * the request id and the IDE switch (`graphiql`); and those the driver refuses: `batching` (one
89
+ * operation per request scope) and `multipart` (JSON bodies only, see {@link yogaDriver}). Yoga's
90
+ * `logging` is off unless set: requests and operations are logged by Znight.
91
+ */
92
+ export interface YogaDriverOptions
93
+ extends Omit<YogaServerOptions<YogaServerContext, Record<string, unknown>>, (typeof OWNED)[number] | 'graphiql'> {
94
+ /**
95
+ * GraphiQL's options (title, default query...), when the `graphiql` option of `GraphQLPlugin` is
96
+ * on (it decides whether the IDE is served).
97
+ */
98
+ readonly graphiql?: GraphiQLOptions
99
+ }
100
+
101
+ /** A GraphQL Yoga driver: `GraphQLPlugin.configure({ driver: yogaDriver() })`. */
102
+ export interface YogaDriver extends GraphQLDriver {
103
+ readonly name: 'yoga'
104
+ /** The Yoga server, once started (`undefined` before `start()` and after `stop()`). */
105
+ readonly yoga: YogaServerInstance<YogaServerContext, Record<string, unknown>> | undefined
106
+ }
107
+
108
+ /**
109
+ * The GraphQL Yoga driver (RFC 0009 §5.1): `GraphQLPlugin.configure({ driver: yogaDriver({ plugins }) })`.
110
+ * Yoga handles the protocol and its Envelop plugins; the engine parses, validates and executes
111
+ * (guards, loaders, request scope, masking, the `graphql:operation` hook). Plugins that replace
112
+ * the engine's execution (`useGraphQlJit`) are refused: the operation fails with ZNIGHT066.
113
+ *
114
+ * Like the built-in transport, a `POST` body is `application/json` only (415 otherwise): form,
115
+ * multipart and text bodies, which a cross-site page sends without a CORS preflight, never run
116
+ * an operation (CSRF). Errors thrown outside the engine (by Yoga or an Envelop plugin) are masked
117
+ * like the engine's (`Unexpected error.` unless `exposeErrors`) and logged.
118
+ *
119
+ * ```ts
120
+ * GraphQLPlugin.configure({
121
+ * guards: [AccessGuard],
122
+ * driver: yogaDriver({ plugins: [useResponseCache({ session: () => null })] }),
123
+ * })
124
+ * ```
125
+ */
126
+ export function yogaDriver(options: YogaDriverOptions = {}): YogaDriver {
127
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) {
128
+ throw invalid('yogaDriver(): expected an options object', ['yogaDriver({ plugins: [...] })'])
129
+ }
130
+ for (const key of OWNED) {
131
+ if ((options as Record<string, unknown>)[key] !== undefined) {
132
+ throw invalid(`yogaDriver(): the ${key} option of Yoga is not available with Znight`, [OWNER[key]])
133
+ }
134
+ }
135
+ const graphiql: unknown = options.graphiql
136
+ if (graphiql !== undefined && (graphiql === null || typeof graphiql !== 'object')) {
137
+ throw invalid("yogaDriver(): graphiql takes GraphiQL's options", [
138
+ 'Turn the IDE on or off with the graphiql option of GraphQLPlugin',
139
+ ])
140
+ }
141
+ if (options.plugins !== undefined && !Array.isArray(options.plugins)) {
142
+ throw invalid('yogaDriver(): plugins must be an array of Envelop plugins', [])
143
+ }
144
+ return new Driver(options)
145
+ }
146
+
147
+ /**
148
+ * Yoga's context of an operation received by the Yoga driver (RFC 0009 Q2): `GraphQLContext.serverContext`,
149
+ * typed. For the rare resolver that needs Yoga's `request`, `params` or the context an Envelop
150
+ * plugin extended; the HTTP request is `ctx.http`, the user a request-scoped provider.
151
+ *
152
+ * @throws ZnightError (ZNIGHT066) when the operation did not come through the Yoga driver.
153
+ */
154
+ export function yogaContext<TContext extends Record<string, unknown> = Record<string, unknown>>(
155
+ ctx: GraphQLContext,
156
+ ): YogaContext & TContext {
157
+ const server = ctx?.serverContext as Partial<YogaContext> | undefined
158
+ if (server === null || typeof server !== 'object' || server.znight === undefined || server.params === undefined) {
159
+ throw invalid('yogaContext(): the operation was not received by the Yoga driver', [
160
+ 'yogaContext() reads GraphQLContext.serverContext of operations served by yogaDriver()',
161
+ ])
162
+ }
163
+ return server as YogaContext & TContext
164
+ }
165
+
166
+ // ------------------------------------------------------------------------------------------------
167
+
168
+ type Yoga = YogaServerInstance<YogaServerContext, Record<string, unknown>>
169
+
170
+ const SSE: EngineExecuteOptions = Object.freeze({ transport: 'sse' })
171
+ const DEFAULT_BODY_LIMIT = 1024 * 1024
172
+
173
+ class Driver implements YogaDriver {
174
+ readonly name = 'yoga' as const
175
+ readonly #options: YogaDriverOptions
176
+ #yoga: Yoga | undefined
177
+ /** Stopped: the application stops, requests that still reach the driver get a 503. */
178
+ #stopped = false
179
+ #get = true
180
+ #ide = false
181
+ #limit = DEFAULT_BODY_LIMIT
182
+
183
+ constructor(options: YogaDriverOptions) {
184
+ this.#options = options
185
+ }
186
+
187
+ get yoga(): Yoga | undefined {
188
+ return this.#yoga
189
+ }
190
+
191
+ /** `GET` and `POST` on the path (`GET` answers 405 when neither queries over `GET` nor GraphiQL apply). */
192
+ routes(context: GraphQLDriverRoutesContext): readonly GraphQLDriverRoute[] {
193
+ return [
194
+ { method: 'GET', path: context.path, name: 'yoga', handler: this.#getHandler },
195
+ { method: 'POST', path: context.path, name: 'yoga', handler: this.#postHandler },
196
+ ]
197
+ }
198
+
199
+ start(engine: GraphQLEngine, context: GraphQLDriverContext): void {
200
+ if (this.#yoga !== undefined) {
201
+ throw invalid('yogaDriver(): this driver already serves an application', [
202
+ 'Create one driver per application: GraphQLPlugin.configure({ driver: yogaDriver() })',
203
+ ])
204
+ }
205
+ const settings = engine.settings
206
+ const http = settings.http
207
+ this.#get = http !== false && http.get
208
+ this.#ide = settings.ide
209
+ this.#limit = http === false ? DEFAULT_BODY_LIMIT : (parseByteSize(http.bodyLimit) ?? DEFAULT_BODY_LIMIT)
210
+ const { graphiql, plugins = [], logging = false, ...rest } = this.#options
211
+ const engineFns = engineFunctions(engine, context.logger)
212
+ this.#yoga = createYoga<YogaServerContext, Record<string, unknown>>({
213
+ ...rest,
214
+ logging,
215
+ schema: engine.schema,
216
+ graphqlEndpoint: settings.path,
217
+ graphiql: settings.ide ? (graphiql ?? true) : false,
218
+ landingPage: false,
219
+ healthCheckEndpoint: UNREACHABLE,
220
+ // the engine masked its errors (kept as they are); those thrown outside of it are masked here
221
+ maskedErrors: { maskError: outsideErrors(settings.exposeErrors, context.logger) },
222
+ batching: false,
223
+ multipart: false,
224
+ cors: false, // HttpPlugin's CORS
225
+ maxRequestBodySize: this.#limit,
226
+ parserAndValidationCache: false, // the engine's document cache
227
+ disposeOnProcessTerminate: false,
228
+ requestId: { generateRequestId: requestIdOf, trustHeader: false },
229
+ fetchAPI: fetchApi(),
230
+ plugins: [engineFns.plugin, ...plugins, engineFns.guard],
231
+ })
232
+ this.#stopped = false
233
+ }
234
+
235
+ /** Called once `HttpPlugin` drained the requests (`GraphQLPlugin.onDispose`). */
236
+ async stop(): Promise<void> {
237
+ const yoga = this.#yoga
238
+ this.#yoga = undefined
239
+ this.#stopped = true
240
+ await yoga?.dispose()
241
+ }
242
+
243
+ readonly #handle = (ctx: HttpContext): Response | Promise<Response> => {
244
+ const yoga = this.#yoga
245
+ if (yoga === undefined) {
246
+ if (this.#stopped) return shuttingDown()
247
+ throw notStarted()
248
+ }
249
+ const request = fetchRequest(ctx, this.#limit)
250
+ const server: YogaServerContext = { znight: ctx }
251
+ const response =
252
+ request instanceof Request ? yoga.fetch(request, server) : request.then(built => yoga.fetch(built, server))
253
+ return response instanceof Response ? response : Promise.resolve(response).then(sendable)
254
+ }
255
+
256
+ /** `POST`: JSON bodies only (the 415 of the built-in transport otherwise: no CSRF). */
257
+ readonly #postHandler = (ctx: HttpContext): unknown => {
258
+ const headers = ctx.headers
259
+ if (!isJsonRequest(headers['content-type'])) return notJson(jsonMedia(headers['accept']))
260
+ return this.#handle(ctx)
261
+ }
262
+
263
+ /** `GET`: Yoga's, or 405 when queries over `GET` are off (GraphiQL pages still served). */
264
+ readonly #getHandler = (ctx: HttpContext): Response | Promise<Response> => {
265
+ if (this.#get || (this.#ide && acceptsHtml(ctx))) return this.#handle(ctx)
266
+ return getNotAllowed()
267
+ }
268
+ }
269
+
270
+ /** A path no request reaches: the driver's routes only serve the GraphQL endpoint. */
271
+ const UNREACHABLE = '/.znight/unreachable'
272
+
273
+ /**
274
+ * The Fetch API of the runtime: on Node.js, Yoga would otherwise use the ponyfills of
275
+ * `@whatwg-node/fetch`, whose `Response` objects `HttpPlugin` cannot send.
276
+ */
277
+ function fetchApi(): Record<string, unknown> {
278
+ return {
279
+ fetch: globalThis.fetch,
280
+ Request: globalThis.Request,
281
+ Response: globalThis.Response,
282
+ Headers: globalThis.Headers,
283
+ FormData: globalThis.FormData,
284
+ ReadableStream: globalThis.ReadableStream,
285
+ WritableStream: globalThis.WritableStream,
286
+ TransformStream: globalThis.TransformStream,
287
+ CompressionStream: globalThis.CompressionStream,
288
+ DecompressionStream: globalThis.DecompressionStream,
289
+ TextDecoderStream: globalThis.TextDecoderStream,
290
+ TextEncoderStream: globalThis.TextEncoderStream,
291
+ Blob: globalThis.Blob,
292
+ File: globalThis.File,
293
+ TextEncoder: globalThis.TextEncoder,
294
+ TextDecoder: globalThis.TextDecoder,
295
+ URL: globalThis.URL,
296
+ URLSearchParams: globalThis.URLSearchParams,
297
+ btoa: globalThis.btoa,
298
+ }
299
+ }
300
+
301
+ /**
302
+ * A `Response` `HttpPlugin` recognises: `@hono/node-server` replaces the global `Response` when it
303
+ * starts listening, after Yoga captured the runtime's own; such a response is rebuilt on the
304
+ * global class (same status, headers and body stream).
305
+ */
306
+ function sendable(response: Response): Response {
307
+ if (response instanceof Response) return response
308
+ const foreign: { readonly body: ReadableStream<Uint8Array> | null } & ResponseInit = response
309
+ return new Response(foreign.body, foreign)
310
+ }
311
+
312
+ /** The request id of `HttpPlugin` (a UUID for a request handed to `yoga.fetch()` directly). */
313
+ function requestIdOf(payload: { readonly context: Partial<YogaServerContext> }): string {
314
+ return payload.context.znight?.requestId ?? crypto.randomUUID()
315
+ }
316
+
317
+ function acceptsHtml(ctx: HttpContext): boolean {
318
+ return ctx.headers['accept']?.includes('text/html') === true
319
+ }
320
+
321
+ /** The JSON type a refusal is written in, like the built-in transport: `application/json` unless the client prefers `application/graphql-response+json`. */
322
+ function jsonMedia(accept: string | undefined): MediaKind {
323
+ const media = (negotiate(accept, (1 << Media.GraphQLJson) | (1 << Media.Json)) & MEDIA) as MediaKind
324
+ return media === Media.None ? Media.Json : media
325
+ }
326
+
327
+ /** A request reaching the driver once it stopped (after `HttpPlugin` drained): 503. */
328
+ function shuttingDown(): Response {
329
+ return Response.json(
330
+ {
331
+ errors: [
332
+ {
333
+ message: 'The server is shutting down, retry later',
334
+ extensions: { code: GraphQLErrorCode.ServiceUnavailable },
335
+ },
336
+ ],
337
+ },
338
+ { status: 503 },
339
+ )
340
+ }
341
+
342
+ /**
343
+ * Yoga's `maskError`: the errors of the engine (masked already) and the `GraphQLError`s thrown on
344
+ * purpose (by Yoga or a plugin, with a code) are kept; any other error, thrown outside the engine
345
+ * (an Envelop plugin's hook, Yoga itself), is logged and masked like the engine masks it
346
+ * (`Unexpected error.`, its message with `exposeErrors`). Yoga then answers 500 for a request error.
347
+ */
348
+ function outsideErrors(expose: boolean, logger: CoreLogger): (error: unknown, message: string) => Error {
349
+ return (error, message) => {
350
+ if (isOriginalGraphQLError(error)) return error
351
+ logger.error('GraphQL: an error was thrown outside the engine (by Yoga or an Envelop plugin)', { err: error })
352
+ const original = error instanceof Error ? error : new Error(String(error))
353
+ return new GraphQLError(expose ? original.message : message, {
354
+ originalError: original,
355
+ extensions: { code: GraphQLErrorCode.InternalServerError },
356
+ })
357
+ }
358
+ }
359
+
360
+ /** A `GraphQLError` that wraps no other error: the engine's, or thrown on purpose. */
361
+ function isOriginalGraphQLError(error: unknown): error is GraphQLError {
362
+ if (!(error instanceof GraphQLError)) return false
363
+ const original = error.originalError
364
+ return original === undefined || original === null || isOriginalGraphQLError(original)
365
+ }
366
+
367
+ function getNotAllowed(): Response {
368
+ return Response.json(
369
+ {
370
+ errors: [
371
+ { message: 'GraphQL operations are sent with POST on this endpoint', extensions: { code: 'BAD_REQUEST' } },
372
+ ],
373
+ },
374
+ { status: 405, headers: { allow: 'POST' } },
375
+ )
376
+ }
377
+
378
+ /** Envelop's onExecute payload (the part the driver reads). */
379
+ interface ExecutePayload {
380
+ readonly executeFn: unknown
381
+ readonly context: unknown
382
+ setExecuteFn(fn: (args: ExecutionArgs) => unknown): void
383
+ }
384
+
385
+ interface SubscribePayload {
386
+ readonly subscribeFn: unknown
387
+ readonly context: unknown
388
+ setSubscribeFn(fn: (args: ExecutionArgs) => unknown): void
389
+ }
390
+
391
+ interface ResultPayload {
392
+ readonly result: unknown
393
+ setResult(result: ExecutionResult): void
394
+ }
395
+
396
+ /**
397
+ * The Envelop plugins of the engine: one installed before the application's plugins, which makes
398
+ * the engine Yoga's `parse`, `validate`, `execute` and `subscribe` (Envelop's `useEngine`), and a
399
+ * guard installed after them, which refuses an operation that a plugin executed elsewhere
400
+ * (`useGraphQlJit`): its resolvers would not get the `GraphQLContext`. Plugins wrapping the
401
+ * execution (`useResponseCache`) keep working: they call the engine.
402
+ */
403
+ function engineFunctions(engine: GraphQLEngine, logger: CoreLogger): { plugin: YogaPlugin; guard: YogaPlugin } {
404
+ const parse = engine.parse
405
+ const validate = (schema: GraphQLSchema, document: DocumentNode, rules?: readonly ValidationRule[]) =>
406
+ engine.validate(schema, document, rules)
407
+ // marks the contexts the engine ran, once a plugin wrapped or replaced the execution
408
+ let watching = false
409
+ const ran = new WeakSet<object>()
410
+ const execute = (args: ExecutionArgs): unknown => {
411
+ if (watching) mark(ran, args.contextValue)
412
+ return engine.execute(args)
413
+ }
414
+ const subscribe = (args: ExecutionArgs): unknown => {
415
+ if (watching) mark(ran, args.contextValue)
416
+ return engine.subscribe(args, SSE)
417
+ }
418
+ let reported = false
419
+ const bypassed = (): ExecutionResult => {
420
+ const error = invalid(
421
+ 'GraphQLPlugin: a Yoga plugin executed the operation instead of the Znight engine (useGraphQlJit?)',
422
+ ['Remove the plugin: resolvers, guards and loaders only run on the engine'],
423
+ )
424
+ if (!reported) {
425
+ reported = true
426
+ logger.error(error.message, { error })
427
+ }
428
+ return { errors: [new GraphQLError(error.message, { extensions: { code: 'INTERNAL_SERVER_ERROR' } })] }
429
+ }
430
+ const plugin = {
431
+ onParse: ({ setParseFn }: { setParseFn(fn: unknown): void }) => setParseFn(parse),
432
+ onValidate: ({ setValidationFn }: { setValidationFn(fn: unknown): void }) => setValidationFn(validate),
433
+ onExecute: ({ setExecuteFn }: ExecutePayload) => setExecuteFn(execute),
434
+ onSubscribe: ({ setSubscribeFn }: SubscribePayload) => setSubscribeFn(subscribe),
435
+ }
436
+ const guard = {
437
+ onExecute: ({ executeFn, context }: ExecutePayload) => {
438
+ if (executeFn === execute) return undefined
439
+ watching = true
440
+ return {
441
+ onExecuteDone: ({ setResult }: ResultPayload) => {
442
+ if (!ran.has(context as object)) setResult(bypassed())
443
+ },
444
+ }
445
+ },
446
+ onSubscribe: ({ subscribeFn, context }: SubscribePayload) => {
447
+ if (subscribeFn === subscribe) return undefined
448
+ watching = true
449
+ return {
450
+ onSubscribeResult: ({ setResult }: ResultPayload) => {
451
+ if (!ran.has(context as object)) setResult(bypassed())
452
+ },
453
+ }
454
+ },
455
+ }
456
+ return { plugin: plugin as YogaPlugin, guard: guard as YogaPlugin }
457
+ }
458
+
459
+ function mark(ran: WeakSet<object>, context: unknown): void {
460
+ if (context !== null && typeof context === 'object') ran.add(context)
461
+ }
462
+
463
+ function notStarted(): ZnightError {
464
+ return new ZnightError(ErrorCode.LifecycleFailed, 'The Yoga driver is not started yet', {
465
+ hints: ['GraphQLPlugin starts its driver in onBootstrap'],
466
+ })
467
+ }
468
+
469
+ function invalid(message: string, hints: readonly string[]): ZnightError {
470
+ return new ZnightError(ErrorCode.InvalidGraphQLSetup, message, { hints: [...hints] })
471
+ }