@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/types.ts
CHANGED
|
@@ -17,8 +17,11 @@ export type GraphQLArgs = Readonly<Record<string, unknown>>
|
|
|
17
17
|
/** Kind of a GraphQL operation. */
|
|
18
18
|
export type GraphQLOperationKind = 'query' | 'mutation' | 'subscription'
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* How an operation reached the server: GraphQL over HTTP, GraphQL over SSE, GraphQL over WebSocket
|
|
22
|
+
* (`ws`, reserved for `graphql-ws`), or `plugin.execute()` / `plugin.subscribe()` (`direct`).
|
|
23
|
+
*/
|
|
24
|
+
export type GraphQLTransport = 'http' | 'sse' | 'ws' | 'direct'
|
|
22
25
|
|
|
23
26
|
/**
|
|
24
27
|
* Options of a loader (§5.5), read once per loader class: the `options` property of a
|
|
@@ -112,6 +115,13 @@ export interface GraphQLContext {
|
|
|
112
115
|
readonly signal: AbortSignal
|
|
113
116
|
/** Per-operation storage. */
|
|
114
117
|
readonly state: Map<string | symbol, unknown>
|
|
118
|
+
/**
|
|
119
|
+
* The context of the GraphQL server that received the operation through a driver (RFC 0009): the
|
|
120
|
+
* `contextValue` it passed to `GraphQLEngine.execute()` (Yoga's initial context, Apollo's context
|
|
121
|
+
* value). `undefined` for the built-in transport and direct calls. Resolvers do not need it: the
|
|
122
|
+
* request is `http`, the user a request-scoped provider.
|
|
123
|
+
*/
|
|
124
|
+
readonly serverContext: unknown
|
|
115
125
|
/** The DataLoader of the current execution for a loader class (`ctx.loader(AuthorsById)`). */
|
|
116
126
|
loader<L extends BatchLoad<unknown, unknown>>(loader: BatchLoadClass<L>): BatchLoader<LoaderKey<L>, LoaderValue<L>>
|
|
117
127
|
/**
|
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
|
+
}
|