@leaven-graphql/plugins 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +395 -99
- package/dist/builtin.d.ts +75 -4
- package/dist/builtin.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +488 -218
- package/dist/manager.d.ts +50 -2
- package/dist/manager.d.ts.map +1 -1
- package/dist/types.d.ts +8 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +16 -7
package/LICENSE
CHANGED
|
@@ -175,7 +175,7 @@
|
|
|
175
175
|
|
|
176
176
|
END OF TERMS AND CONDITIONS
|
|
177
177
|
|
|
178
|
-
Copyright 2026
|
|
178
|
+
Copyright 2026 Joseph Quinn
|
|
179
179
|
|
|
180
180
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
181
181
|
you may not use this file except in compliance with the License.
|
package/README.md
CHANGED
|
@@ -10,19 +10,66 @@ bun add @leaven-graphql/plugins
|
|
|
10
10
|
|
|
11
11
|
## Quick Start
|
|
12
12
|
|
|
13
|
+
> **Wiring is manual today.** `ExecutorConfig` has no `plugins` option, and no
|
|
14
|
+
> package in Leaven — core, http, ws, or nestjs — dispatches plugin hooks for
|
|
15
|
+
> you. To run plugins you construct a `PluginManager` yourself and call its
|
|
16
|
+
> hooks around `executor.execute`, as shown below.
|
|
17
|
+
|
|
13
18
|
```typescript
|
|
14
|
-
import {
|
|
15
|
-
import { LeavenExecutor } from '@leaven-graphql/core';
|
|
19
|
+
import { parse } from 'graphql';
|
|
20
|
+
import { LeavenExecutor, type GraphQLRequest } from '@leaven-graphql/core';
|
|
21
|
+
import {
|
|
22
|
+
createPluginManager,
|
|
23
|
+
createLoggingPlugin,
|
|
24
|
+
createDepthLimitPlugin,
|
|
25
|
+
} from '@leaven-graphql/plugins';
|
|
26
|
+
|
|
27
|
+
const executor = new LeavenExecutor({ schema });
|
|
16
28
|
|
|
17
|
-
const
|
|
29
|
+
const plugins = createPluginManager({
|
|
18
30
|
schema,
|
|
19
31
|
plugins: [
|
|
20
|
-
createLoggingPlugin({
|
|
32
|
+
createLoggingPlugin({ level: 'info' }),
|
|
21
33
|
createDepthLimitPlugin({ maxDepth: 10 }),
|
|
22
34
|
],
|
|
23
35
|
});
|
|
36
|
+
|
|
37
|
+
// Optional: run the deferred `onRegister` hooks up front so initialization
|
|
38
|
+
// failures surface here instead of on the first request.
|
|
39
|
+
await plugins.init();
|
|
40
|
+
|
|
41
|
+
async function handleRequest(request: GraphQLRequest, context: unknown) {
|
|
42
|
+
const pluginContext = plugins.createContext(request, context);
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const query = await plugins.beforeParse(request.query, pluginContext);
|
|
46
|
+
const document = await plugins.afterParse(parse(query), pluginContext);
|
|
47
|
+
|
|
48
|
+
// A `beforeExecute` hook may return a response to short-circuit execution
|
|
49
|
+
// (this is how the caching plugin serves a hit).
|
|
50
|
+
const cached = await plugins.beforeExecute(document, pluginContext);
|
|
51
|
+
if (cached) {
|
|
52
|
+
return await plugins.afterExecute(cached, pluginContext);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { response } = await executor.execute({ ...request, query }, context);
|
|
56
|
+
return await plugins.afterExecute(response, pluginContext);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw await plugins.onError(error as Error, pluginContext);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
24
61
|
```
|
|
25
62
|
|
|
63
|
+
Two caveats of this arrangement:
|
|
64
|
+
|
|
65
|
+
- `LeavenExecutor.execute` parses and validates internally, so the document you
|
|
66
|
+
hand to `afterParse` is parsed a second time by the executor. Hooks that
|
|
67
|
+
*rewrite* the document (`afterParse` returning a new `DocumentNode`) cannot
|
|
68
|
+
reach the executor this way; hooks that *inspect and reject* it — depth limit,
|
|
69
|
+
complexity — work exactly as intended.
|
|
70
|
+
- `beforeValidate` / `afterValidate` are only dispatched if you validate the
|
|
71
|
+
document yourself. The pipeline above skips them.
|
|
72
|
+
|
|
26
73
|
## Built-in Plugins
|
|
27
74
|
|
|
28
75
|
### Logging Plugin
|
|
@@ -31,15 +78,41 @@ const executor = new LeavenExecutor({
|
|
|
31
78
|
import { createLoggingPlugin } from '@leaven-graphql/plugins';
|
|
32
79
|
|
|
33
80
|
const loggingPlugin = createLoggingPlugin({
|
|
81
|
+
// Minimum severity to emit (default: 'info')
|
|
82
|
+
level: 'info',
|
|
83
|
+
// Any object with debug/info/warn/error methods (default: console)
|
|
34
84
|
logger: console,
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`level` is the only filter; there are no options for including variables or
|
|
89
|
+
results. Messages are emitted with a structured payload as the second argument:
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
// level: 'debug' — before execution
|
|
93
|
+
logger.debug('Executing GraphQL operation', {
|
|
94
|
+
operationName: 'getUser',
|
|
95
|
+
query: '{ user(id: "1") { name } }',
|
|
38
96
|
});
|
|
39
97
|
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
98
|
+
// level: 'info' or lower — after a successful execution
|
|
99
|
+
logger.info('GraphQL operation completed', {
|
|
100
|
+
operationName: 'getUser',
|
|
101
|
+
duration: '15.23ms',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// after an execution that produced errors
|
|
105
|
+
logger.error('GraphQL operation completed with errors', {
|
|
106
|
+
operationName: 'getUser',
|
|
107
|
+
duration: '15.23ms',
|
|
108
|
+
errors: [/* ... */],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// from the onError hook
|
|
112
|
+
logger.error('GraphQL operation failed', {
|
|
113
|
+
operationName: 'getUser',
|
|
114
|
+
error: 'Something went wrong',
|
|
115
|
+
});
|
|
43
116
|
```
|
|
44
117
|
|
|
45
118
|
### Tracing Plugin
|
|
@@ -47,21 +120,26 @@ const loggingPlugin = createLoggingPlugin({
|
|
|
47
120
|
```typescript
|
|
48
121
|
import { createTracingPlugin } from '@leaven-graphql/plugins';
|
|
49
122
|
|
|
50
|
-
const tracingPlugin = createTracingPlugin(
|
|
51
|
-
|
|
52
|
-
includeValidation: true,
|
|
53
|
-
});
|
|
123
|
+
const tracingPlugin = createTracingPlugin();
|
|
124
|
+
```
|
|
54
125
|
|
|
55
|
-
|
|
126
|
+
Takes no options. It measures each phase from its own hooks, so a phase whose
|
|
127
|
+
hooks were never dispatched is omitted rather than reported as a false zero.
|
|
128
|
+
Durations and offsets are nanoseconds, Apollo Tracing style:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
56
131
|
// {
|
|
57
132
|
// extensions: {
|
|
58
133
|
// tracing: {
|
|
59
134
|
// version: 1,
|
|
60
|
-
// startTime:
|
|
135
|
+
// startTime: '2026-01-01T12:00:00.000Z',
|
|
136
|
+
// endTime: '2026-01-01T12:00:00.015Z',
|
|
61
137
|
// duration: 15000000,
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
138
|
+
// parsing: { startOffset: 1000, duration: 250000 },
|
|
139
|
+
// validation: { startOffset: 260000, duration: 400000 },
|
|
140
|
+
// execution: { startOffset: 680000, duration: 14300000 },
|
|
141
|
+
// },
|
|
142
|
+
// },
|
|
65
143
|
// }
|
|
66
144
|
```
|
|
67
145
|
|
|
@@ -70,175 +148,393 @@ const tracingPlugin = createTracingPlugin({
|
|
|
70
148
|
```typescript
|
|
71
149
|
import { createDepthLimitPlugin } from '@leaven-graphql/plugins';
|
|
72
150
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
ignoreIntrospection: true,
|
|
76
|
-
});
|
|
151
|
+
// Bare number form
|
|
152
|
+
const depthLimitPlugin = createDepthLimitPlugin(10);
|
|
77
153
|
|
|
78
|
-
//
|
|
154
|
+
// Equivalent options-object form
|
|
155
|
+
const alsoDepthLimit = createDepthLimitPlugin({ maxDepth: 10 });
|
|
79
156
|
```
|
|
80
157
|
|
|
158
|
+
`maxDepth` is the only setting — there is no `ignoreIntrospection`. Only field
|
|
159
|
+
selections add a level of depth. Fragment spreads are resolved through their
|
|
160
|
+
definitions and each fragment is measured once per document, so repeated spreads
|
|
161
|
+
cannot fan out exponentially and recursive fragments terminate.
|
|
162
|
+
|
|
163
|
+
Throws `DepthLimitError` (HTTP 400, `extensions.code: 'DEPTH_LIMIT'`) when a
|
|
164
|
+
query is too deep, or when analyzing the document would exceed the 100,000-node
|
|
165
|
+
analysis budget (`extensions.reason: 'ANALYSIS_BUDGET_EXCEEDED'`).
|
|
166
|
+
|
|
81
167
|
### Complexity Plugin
|
|
82
168
|
|
|
83
169
|
```typescript
|
|
84
170
|
import { createComplexityPlugin } from '@leaven-graphql/plugins';
|
|
85
171
|
|
|
86
172
|
const complexityPlugin = createComplexityPlugin({
|
|
173
|
+
// Required: reject operations scoring above this
|
|
87
174
|
maxComplexity: 1000,
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
175
|
+
// Cost of a single field (default: 1)
|
|
176
|
+
defaultComplexity: 1,
|
|
177
|
+
// Page size assumed for a first/last/limit argument whose value cannot be
|
|
178
|
+
// determined — an unsupplied variable that also declares no default.
|
|
179
|
+
// Deliberately pessimistic, since the value is client-controlled (default: 100)
|
|
180
|
+
unknownMultiplier: 100,
|
|
92
181
|
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
By default a field scores `defaultComplexity` plus its children, and the whole
|
|
185
|
+
subtree is multiplied by any `first` / `last` / `limit` page size, resolved from
|
|
186
|
+
a literal, the request variables, or the operation's declared variable default.
|
|
93
187
|
|
|
94
|
-
|
|
188
|
+
Supply a `calculator` to score fields yourself. Its return value is the field's
|
|
189
|
+
**total** complexity, so incorporate `childComplexity` explicitly:
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
const customComplexity = createComplexityPlugin({
|
|
193
|
+
maxComplexity: 1000,
|
|
194
|
+
calculator: (node, childComplexity) => {
|
|
195
|
+
const name = (node as { name?: { value: string } }).name?.value;
|
|
196
|
+
return (name === 'search' ? 10 : 1) + childComplexity;
|
|
197
|
+
},
|
|
198
|
+
});
|
|
95
199
|
```
|
|
96
200
|
|
|
201
|
+
Throws `ComplexityError` (HTTP 400, `extensions.code: 'COMPLEXITY_LIMIT'`) when
|
|
202
|
+
an operation exceeds `maxComplexity`, or when analysis exceeds the node budget.
|
|
203
|
+
|
|
97
204
|
### Caching Plugin
|
|
98
205
|
|
|
99
206
|
```typescript
|
|
100
207
|
import { createCachingPlugin } from '@leaven-graphql/plugins';
|
|
101
208
|
|
|
102
209
|
const cachingPlugin = createCachingPlugin({
|
|
103
|
-
ttl: 60000, // Cache TTL in ms
|
|
104
|
-
maxSize: 100, // Max cached responses
|
|
105
|
-
|
|
106
|
-
|
|
210
|
+
ttl: 60000, // Cache TTL in ms (default: 60000)
|
|
211
|
+
maxSize: 100, // Max cached responses, LRU-evicted (default: 100)
|
|
212
|
+
// Derives the cache key. Incorporate the caller's identity here so
|
|
213
|
+
// responses are never served across users.
|
|
214
|
+
keyFn: (context) => {
|
|
215
|
+
const userId = (context.context as { user?: { id: string } })?.user?.id ?? 'anonymous';
|
|
216
|
+
return `${userId}:${context.request.query}:${JSON.stringify(context.request.variables)}`;
|
|
107
217
|
},
|
|
108
218
|
});
|
|
109
219
|
```
|
|
110
220
|
|
|
221
|
+
`createCachingPlugin` requires either a `keyFn` or an explicit
|
|
222
|
+
`allowSharedCache: true`. Without a `keyFn` the cache key contains no user or
|
|
223
|
+
tenant identity, so entries are shared across every caller — opt in only for
|
|
224
|
+
data that is genuinely public:
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
const publicCache = createCachingPlugin({ ttl: 60000, allowSharedCache: true });
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
A cache hit short-circuits `beforeExecute`, so resolvers — and any field-level
|
|
231
|
+
authorization they perform — do not run.
|
|
232
|
+
|
|
111
233
|
## Creating Custom Plugins
|
|
112
234
|
|
|
235
|
+
`createPlugin` takes **two** arguments: the metadata, then the hooks.
|
|
236
|
+
|
|
113
237
|
```typescript
|
|
114
238
|
import { createPlugin } from '@leaven-graphql/plugins';
|
|
115
239
|
|
|
116
|
-
const metricsPlugin = createPlugin(
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
context.startTime = performance.now();
|
|
122
|
-
return query;
|
|
240
|
+
const metricsPlugin = createPlugin(
|
|
241
|
+
{
|
|
242
|
+
name: 'metrics',
|
|
243
|
+
version: '1.0.0',
|
|
244
|
+
description: 'Records operation timings',
|
|
123
245
|
},
|
|
246
|
+
{
|
|
247
|
+
beforeParse: (query, context) => {
|
|
248
|
+
// Per-request scratch space lives on `context.state`, a Map
|
|
249
|
+
context.state.set('startTime', performance.now());
|
|
250
|
+
return query;
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
afterExecute: (response, context) => {
|
|
254
|
+
const startTime = context.state.get('startTime') as number;
|
|
255
|
+
|
|
256
|
+
metrics.record({
|
|
257
|
+
operation: context.request.operationName,
|
|
258
|
+
duration: performance.now() - startTime,
|
|
259
|
+
errors: response.errors?.length ?? 0,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
return response;
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
onError: (error, context) => {
|
|
266
|
+
metrics.recordError({
|
|
267
|
+
operation: context.request.operationName,
|
|
268
|
+
error: error.message,
|
|
269
|
+
});
|
|
270
|
+
return error;
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
);
|
|
274
|
+
```
|
|
124
275
|
|
|
125
|
-
|
|
126
|
-
const duration = performance.now() - context.startTime;
|
|
276
|
+
A plugin is a plain object, so you can also write one by hand:
|
|
127
277
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
duration,
|
|
131
|
-
errors: result.errors?.length ?? 0,
|
|
132
|
-
});
|
|
278
|
+
```typescript
|
|
279
|
+
import type { Plugin } from '@leaven-graphql/plugins';
|
|
133
280
|
|
|
134
|
-
|
|
281
|
+
const auditPlugin: Plugin = {
|
|
282
|
+
metadata: { name: 'audit', version: '1.0.0' },
|
|
283
|
+
onRegister: async (schema) => {
|
|
284
|
+
await auditLog.open(schema);
|
|
135
285
|
},
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
metrics.recordError({
|
|
139
|
-
operation: context.operationName,
|
|
140
|
-
error: error.message,
|
|
141
|
-
});
|
|
286
|
+
onUnregister: async () => {
|
|
287
|
+
await auditLog.close();
|
|
142
288
|
},
|
|
143
|
-
|
|
289
|
+
afterExecute: (response) => response,
|
|
290
|
+
};
|
|
144
291
|
```
|
|
145
292
|
|
|
146
293
|
## Plugin Manager
|
|
147
294
|
|
|
148
295
|
```typescript
|
|
149
|
-
import {
|
|
296
|
+
import {
|
|
297
|
+
createPluginManager,
|
|
298
|
+
createLoggingPlugin,
|
|
299
|
+
createTracingPlugin,
|
|
300
|
+
createDepthLimitPlugin,
|
|
301
|
+
} from '@leaven-graphql/plugins';
|
|
150
302
|
|
|
151
303
|
const manager = createPluginManager({
|
|
152
304
|
schema,
|
|
153
305
|
plugins: [
|
|
154
|
-
createLoggingPlugin({
|
|
306
|
+
createLoggingPlugin({ level: 'info' }),
|
|
155
307
|
createTracingPlugin(),
|
|
156
|
-
createDepthLimitPlugin(
|
|
308
|
+
createDepthLimitPlugin(10),
|
|
157
309
|
],
|
|
158
310
|
});
|
|
159
311
|
|
|
160
|
-
//
|
|
161
|
-
|
|
312
|
+
// Constructor plugins are inserted synchronously (duplicate names and
|
|
313
|
+
// unsatisfied dependencies throw from the constructor), but their async
|
|
314
|
+
// `onRegister` hooks are deferred. `init()` runs them; the manager also awaits
|
|
315
|
+
// it lazily before the first registration change or hook dispatch.
|
|
316
|
+
await manager.init();
|
|
317
|
+
|
|
318
|
+
// Register additional plugins — async, and rejects if `onRegister` throws
|
|
319
|
+
await manager.register(myCustomPlugin);
|
|
320
|
+
|
|
321
|
+
// Unregister — resolves to false if no such plugin is registered
|
|
322
|
+
const removed = await manager.unregister('logging'); // true
|
|
162
323
|
|
|
163
|
-
//
|
|
164
|
-
manager.
|
|
324
|
+
// Inspect
|
|
325
|
+
manager.has('tracing'); // boolean
|
|
326
|
+
manager.get('tracing'); // Plugin | undefined
|
|
327
|
+
manager.getPluginNames(); // string[], in registration order
|
|
328
|
+
manager.size; // number
|
|
165
329
|
|
|
166
|
-
//
|
|
167
|
-
|
|
330
|
+
// Remove everything, in reverse registration order
|
|
331
|
+
await manager.clear();
|
|
168
332
|
```
|
|
169
333
|
|
|
334
|
+
Registration is transactional: if a plugin's `onRegister` rejects, the plugin is
|
|
335
|
+
removed from the registry and every hook index before the error propagates, so
|
|
336
|
+
its request hooks never run and the registration can be retried.
|
|
337
|
+
|
|
338
|
+
`init()` is likewise all-or-nothing — a failing deferred `onRegister` unwinds
|
|
339
|
+
the hooks that already succeeded, and the failure is not cached, so a later call
|
|
340
|
+
retries the whole run.
|
|
341
|
+
|
|
170
342
|
## Composing Plugins
|
|
171
343
|
|
|
172
|
-
|
|
173
|
-
|
|
344
|
+
`composePlugins` is **name-first and variadic** — the composite needs its own
|
|
345
|
+
name, since it is registered like any other plugin.
|
|
174
346
|
|
|
175
|
-
|
|
176
|
-
|
|
347
|
+
```typescript
|
|
348
|
+
import {
|
|
349
|
+
composePlugins,
|
|
350
|
+
createPluginManager,
|
|
351
|
+
createLoggingPlugin,
|
|
352
|
+
createTracingPlugin,
|
|
353
|
+
createDepthLimitPlugin,
|
|
354
|
+
createComplexityPlugin,
|
|
355
|
+
} from '@leaven-graphql/plugins';
|
|
356
|
+
|
|
357
|
+
const combinedPlugin = composePlugins(
|
|
358
|
+
'guardrails',
|
|
359
|
+
createLoggingPlugin({ level: 'info' }),
|
|
177
360
|
createTracingPlugin(),
|
|
178
|
-
createDepthLimitPlugin(
|
|
179
|
-
createComplexityPlugin({ maxComplexity: 500 })
|
|
180
|
-
|
|
361
|
+
createDepthLimitPlugin(10),
|
|
362
|
+
createComplexityPlugin({ maxComplexity: 500 })
|
|
363
|
+
);
|
|
181
364
|
|
|
182
|
-
const
|
|
183
|
-
schema,
|
|
184
|
-
plugins: [combinedPlugin],
|
|
185
|
-
});
|
|
365
|
+
const manager = createPluginManager({ schema, plugins: [combinedPlugin] });
|
|
186
366
|
```
|
|
187
367
|
|
|
368
|
+
The composite delegates every hook, plus `onRegister` / `onUnregister`, to its
|
|
369
|
+
members in order — except `afterExecute`, which runs in reverse, mirroring
|
|
370
|
+
`PluginManager`. Passing an array instead of spread arguments does **not** work:
|
|
371
|
+
the array becomes the composite's `name` and no plugin is delegated to.
|
|
372
|
+
|
|
188
373
|
## Plugin Hooks
|
|
189
374
|
|
|
190
|
-
| Hook |
|
|
191
|
-
|
|
192
|
-
| `beforeParse` |
|
|
193
|
-
| `afterParse` |
|
|
194
|
-
| `beforeValidate` |
|
|
195
|
-
| `afterValidate` |
|
|
196
|
-
| `beforeExecute` |
|
|
197
|
-
| `afterExecute` |
|
|
198
|
-
| `onError` |
|
|
375
|
+
| Hook | Signature | Return |
|
|
376
|
+
|------|-----------|--------|
|
|
377
|
+
| `beforeParse` | `(query: string, context)` | new query string, or nothing |
|
|
378
|
+
| `afterParse` | `(document: DocumentNode, context)` | new document, or nothing |
|
|
379
|
+
| `beforeValidate` | `(document: DocumentNode, context)` | nothing |
|
|
380
|
+
| `afterValidate` | `(result: { valid, errors }, context)` | nothing |
|
|
381
|
+
| `beforeExecute` | `(document: DocumentNode, context)` | a `GraphQLResponse` to short-circuit execution, or nothing |
|
|
382
|
+
| `afterExecute` | `(response: GraphQLResponse, context)` | replacement response, or nothing |
|
|
383
|
+
| `onError` | `(error: Error, context)` | replacement error, or nothing |
|
|
384
|
+
|
|
385
|
+
Every hook may return a promise. `beforeExecute` receives the **document**, not
|
|
386
|
+
just the context.
|
|
199
387
|
|
|
200
388
|
## API Reference
|
|
201
389
|
|
|
202
|
-
### Plugin
|
|
390
|
+
### Plugin
|
|
203
391
|
|
|
204
392
|
```typescript
|
|
205
|
-
interface
|
|
393
|
+
interface PluginMetadata {
|
|
394
|
+
/** Unique plugin name */
|
|
206
395
|
name: string;
|
|
207
396
|
version?: string;
|
|
397
|
+
description?: string;
|
|
398
|
+
/** Names of plugins that must already be registered */
|
|
399
|
+
dependencies?: string[];
|
|
400
|
+
}
|
|
208
401
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
402
|
+
interface PluginHooks {
|
|
403
|
+
beforeParse?: BeforeParseHook;
|
|
404
|
+
afterParse?: AfterParseHook;
|
|
405
|
+
beforeValidate?: BeforeValidateHook;
|
|
406
|
+
afterValidate?: AfterValidateHook;
|
|
407
|
+
beforeExecute?: BeforeExecuteHook;
|
|
408
|
+
afterExecute?: AfterExecuteHook;
|
|
409
|
+
onError?: OnErrorHook;
|
|
216
410
|
}
|
|
411
|
+
|
|
412
|
+
interface Plugin extends PluginHooks {
|
|
413
|
+
metadata: PluginMetadata;
|
|
414
|
+
onRegister?: (schema: GraphQLSchema) => void | Promise<void>;
|
|
415
|
+
onUnregister?: () => void | Promise<void>;
|
|
416
|
+
}
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
### PluginContext
|
|
420
|
+
|
|
421
|
+
```typescript
|
|
422
|
+
interface PluginContext<TContext = unknown> {
|
|
423
|
+
schema: GraphQLSchema;
|
|
424
|
+
request: GraphQLRequest;
|
|
425
|
+
/** User context */
|
|
426
|
+
context: TContext;
|
|
427
|
+
/** Per-request scratch space shared between hooks */
|
|
428
|
+
state: Map<string, unknown>;
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
### Hook types
|
|
433
|
+
|
|
434
|
+
```typescript
|
|
435
|
+
type BeforeParseHook = (
|
|
436
|
+
query: string,
|
|
437
|
+
context: PluginContext
|
|
438
|
+
) => string | void | Promise<string | void>;
|
|
439
|
+
|
|
440
|
+
type AfterParseHook = (
|
|
441
|
+
document: DocumentNode,
|
|
442
|
+
context: PluginContext
|
|
443
|
+
) => DocumentNode | void | Promise<DocumentNode | void>;
|
|
444
|
+
|
|
445
|
+
type BeforeValidateHook = (
|
|
446
|
+
document: DocumentNode,
|
|
447
|
+
context: PluginContext
|
|
448
|
+
) => void | Promise<void>;
|
|
449
|
+
|
|
450
|
+
type AfterValidateHook = (
|
|
451
|
+
result: { valid: boolean; errors: readonly GraphQLError[] },
|
|
452
|
+
context: PluginContext
|
|
453
|
+
) => void | Promise<void>;
|
|
454
|
+
|
|
455
|
+
type BeforeExecuteHook = (
|
|
456
|
+
document: DocumentNode,
|
|
457
|
+
context: PluginContext
|
|
458
|
+
) => GraphQLResponse | void | Promise<GraphQLResponse | void>;
|
|
459
|
+
|
|
460
|
+
type AfterExecuteHook = (
|
|
461
|
+
response: GraphQLResponse,
|
|
462
|
+
context: PluginContext
|
|
463
|
+
) => GraphQLResponse | void | Promise<GraphQLResponse | void>;
|
|
464
|
+
|
|
465
|
+
type OnErrorHook = (
|
|
466
|
+
error: Error,
|
|
467
|
+
context: PluginContext
|
|
468
|
+
) => Error | void | Promise<Error | void>;
|
|
217
469
|
```
|
|
218
470
|
|
|
219
471
|
### createPlugin
|
|
220
472
|
|
|
221
473
|
```typescript
|
|
222
|
-
function createPlugin(
|
|
474
|
+
function createPlugin(metadata: PluginMetadata, hooks: PluginHooks): Plugin;
|
|
223
475
|
```
|
|
224
476
|
|
|
225
477
|
### composePlugins
|
|
226
478
|
|
|
227
479
|
```typescript
|
|
228
|
-
function composePlugins(plugins: Plugin[]): Plugin;
|
|
480
|
+
function composePlugins(name: string, ...plugins: Plugin[]): Plugin;
|
|
229
481
|
```
|
|
230
482
|
|
|
231
483
|
### PluginManager
|
|
232
484
|
|
|
233
485
|
```typescript
|
|
486
|
+
interface PluginManagerConfig {
|
|
487
|
+
schema: GraphQLSchema;
|
|
488
|
+
plugins?: Plugin[];
|
|
489
|
+
}
|
|
490
|
+
|
|
234
491
|
class PluginManager {
|
|
235
|
-
|
|
236
|
-
|
|
492
|
+
constructor(config: PluginManagerConfig);
|
|
493
|
+
|
|
494
|
+
/** Run deferred constructor `onRegister` hooks. Idempotent; retries on failure. */
|
|
495
|
+
init(): Promise<void>;
|
|
496
|
+
|
|
497
|
+
register(plugin: Plugin): Promise<void>;
|
|
498
|
+
/** Resolves false when no plugin by that name is registered. */
|
|
499
|
+
unregister(name: string): Promise<boolean>;
|
|
500
|
+
|
|
501
|
+
get(name: string): Plugin | undefined;
|
|
502
|
+
has(name: string): boolean;
|
|
237
503
|
getPluginNames(): string[];
|
|
238
|
-
|
|
504
|
+
get size(): number;
|
|
505
|
+
clear(): Promise<void>;
|
|
506
|
+
|
|
507
|
+
createContext<TContext>(
|
|
508
|
+
request: GraphQLRequest,
|
|
509
|
+
userContext: TContext
|
|
510
|
+
): PluginContext<TContext>;
|
|
511
|
+
|
|
512
|
+
// Hook dispatch — call these around your own execution pipeline
|
|
513
|
+
beforeParse(query: string, context: PluginContext): Promise<string>;
|
|
514
|
+
afterParse(document: DocumentNode, context: PluginContext): Promise<DocumentNode>;
|
|
515
|
+
beforeValidate(document: DocumentNode, context: PluginContext): Promise<void>;
|
|
516
|
+
afterValidate(
|
|
517
|
+
result: { valid: boolean; errors: readonly GraphQLError[] },
|
|
518
|
+
context: PluginContext
|
|
519
|
+
): Promise<void>;
|
|
520
|
+
/** Resolves to a response when a hook short-circuits, otherwise undefined. */
|
|
521
|
+
beforeExecute(
|
|
522
|
+
document: DocumentNode,
|
|
523
|
+
context: PluginContext
|
|
524
|
+
): Promise<GraphQLResponse | undefined>;
|
|
525
|
+
afterExecute(
|
|
526
|
+
response: GraphQLResponse,
|
|
527
|
+
context: PluginContext
|
|
528
|
+
): Promise<GraphQLResponse>;
|
|
529
|
+
onError(error: Error, context: PluginContext): Promise<Error>;
|
|
239
530
|
}
|
|
531
|
+
|
|
532
|
+
function createPluginManager(config: PluginManagerConfig): PluginManager;
|
|
240
533
|
```
|
|
241
534
|
|
|
535
|
+
There is no generic `execute(hookName, ...args)` method — each hook has its own
|
|
536
|
+
typed dispatch method above.
|
|
537
|
+
|
|
242
538
|
## License
|
|
243
539
|
|
|
244
|
-
Apache 2.0 -
|
|
540
|
+
Apache 2.0 - Joseph Quinn
|