@leaven-graphql/nestjs 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 +408 -23
- package/dist/decorators.d.ts +92 -3
- package/dist/decorators.d.ts.map +1 -1
- package/dist/driver.d.ts +107 -8
- package/dist/driver.d.ts.map +1 -1
- package/dist/execution-context.d.ts +46 -2
- package/dist/execution-context.d.ts.map +1 -1
- package/dist/graphql-driver.d.ts +180 -0
- package/dist/graphql-driver.d.ts.map +1 -0
- package/dist/guards.d.ts +57 -11
- package/dist/guards.d.ts.map +1 -1
- package/dist/index.d.ts +10 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1577 -792
- package/dist/interceptors.d.ts +41 -5
- package/dist/interceptors.d.ts.map +1 -1
- package/dist/middleware.d.ts +100 -4
- package/dist/middleware.d.ts.map +1 -1
- package/dist/module.d.ts +77 -12
- package/dist/module.d.ts.map +1 -1
- package/dist/schema-builder.d.ts +39 -10
- package/dist/schema-builder.d.ts.map +1 -1
- package/dist/subscriptions.d.ts +346 -22
- package/dist/subscriptions.d.ts.map +1 -1
- package/dist/types.d.ts +188 -18
- package/dist/types.d.ts.map +1 -1
- package/package.json +19 -12
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
|
@@ -2,14 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
NestJS integration for Leaven GraphQL - a high-performance GraphQL execution engine for Bun.
|
|
4
4
|
|
|
5
|
-
> **Note:** This package assumes you're using
|
|
5
|
+
> **Note:** This package assumes you're using `@lexmata/nestjs-platform-bun` as your NestJS HTTP adapter for Bun runtime support.
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
bun add @leaven-graphql/nestjs @leaven-graphql/core @leaven-graphql/context @leaven-graphql/errors @
|
|
10
|
+
bun add @leaven-graphql/nestjs @leaven-graphql/core @leaven-graphql/context @leaven-graphql/errors @lexmata/nestjs-platform-bun
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
### Required TypeScript configuration
|
|
14
|
+
|
|
15
|
+
NestJS reads its metadata through `reflect-metadata`, and Bun only emits that
|
|
16
|
+
metadata when both decorator flags are on:
|
|
17
|
+
|
|
18
|
+
```jsonc
|
|
19
|
+
// tsconfig.json
|
|
20
|
+
{
|
|
21
|
+
"compilerOptions": {
|
|
22
|
+
"experimentalDecorators": true,
|
|
23
|
+
"emitDecoratorMetadata": true
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Without them Bun fails at *import* time with an error that names neither
|
|
29
|
+
decorators nor NestJS:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
TypeError: undefined is not an object (evaluating 'descriptor.value')
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
If you see that, check these two flags before anything else.
|
|
36
|
+
|
|
13
37
|
## Quick Start
|
|
14
38
|
|
|
15
39
|
### Bootstrap with Bun
|
|
@@ -17,7 +41,7 @@ bun add @leaven-graphql/nestjs @leaven-graphql/core @leaven-graphql/context @lea
|
|
|
17
41
|
```typescript
|
|
18
42
|
// main.ts
|
|
19
43
|
import { NestFactory } from '@nestjs/core';
|
|
20
|
-
import { BunAdapter } from '@
|
|
44
|
+
import { BunAdapter } from '@lexmata/nestjs-platform-bun';
|
|
21
45
|
import { AppModule } from './app.module';
|
|
22
46
|
|
|
23
47
|
async function bootstrap() {
|
|
@@ -89,18 +113,64 @@ export class AppModule {}
|
|
|
89
113
|
|
|
90
114
|
## Features
|
|
91
115
|
|
|
116
|
+
> ### ⚠️ Two paths, and only one of them runs the NestJS pipeline
|
|
117
|
+
>
|
|
118
|
+
> Read this before the sections below. Which path you pick decides whether the
|
|
119
|
+
> guards, interceptors and parameter decorators documented here execute.
|
|
120
|
+
>
|
|
121
|
+
> **`GraphQLModule.forRoot({ driver: LeavenGraphQLDriver })` — the pipeline runs.**
|
|
122
|
+
> `@nestjs/graphql` builds the schema from your `@Resolver()` classes and wraps
|
|
123
|
+
> every field resolver in NestJS's `ExternalContextCreator`, so `@UseGuards`,
|
|
124
|
+
> `@UseInterceptors`, pipes, filters and parameter decorators all fire; Leaven
|
|
125
|
+
> executes the finished schema. See
|
|
126
|
+
> [Integration with @nestjs/graphql](#integration-with-nestjsgraphql).
|
|
127
|
+
>
|
|
128
|
+
> **`LeavenModule.forRoot({ schema })` or `{ typeDefs, resolvers }` — it does not.**
|
|
129
|
+
> In that path **your resolvers are plain functions invoked directly by
|
|
130
|
+
> graphql-js.** They are not NestJS handlers, so nothing wraps them:
|
|
131
|
+
>
|
|
132
|
+
> - `@UseGuards(...)` — `AuthGuard`, `RolesGuard`, `PermissionsGuard`,
|
|
133
|
+
> `ComplexityGuard`, `DepthGuard` — **does not fire.**
|
|
134
|
+
> - `@UseInterceptors(...)` — `LoggingInterceptor`, `MetricsInterceptor`,
|
|
135
|
+
> `ComplexityInterceptor`, `CachingInterceptor`,
|
|
136
|
+
> `ErrorFormattingInterceptor` — **does not fire.**
|
|
137
|
+
> - Parameter decorators — `@Args()`, `@Context()`, `@Info()`, `@Root()`,
|
|
138
|
+
> `@Parent()`, and anything built with `createContextDecorator()` — **are not
|
|
139
|
+
> applied**; your resolver receives the raw graphql-js
|
|
140
|
+
> `(root, args, context, info)` positional arguments instead.
|
|
141
|
+
>
|
|
142
|
+
> Put authorization and argument handling inside your resolver functions or in
|
|
143
|
+
> the `context` factory on that path, or switch to the driver.
|
|
144
|
+
>
|
|
145
|
+
> What works on **both** paths: the schema build, the HTTP endpoint and
|
|
146
|
+
> playground, the `context` factory, `formatError`, the document cache, metrics,
|
|
147
|
+
> and — for `LeavenModule` — `@InjectPubSub` and `SubscriptionManager`'s
|
|
148
|
+
> graphql-ws protocol handling.
|
|
149
|
+
>
|
|
150
|
+
> The guards, interceptors, decorators and `GqlExecutionContext` documented below
|
|
151
|
+
> are also usable directly — they are ordinary NestJS classes and helpers, and
|
|
152
|
+
> they work in any NestJS pipeline you invoke them from, including unit tests.
|
|
153
|
+
|
|
92
154
|
### Decorators
|
|
93
155
|
|
|
94
156
|
#### Parameter Decorators
|
|
95
157
|
|
|
96
158
|
```typescript
|
|
97
159
|
import { Resolver, Query } from '@nestjs/graphql';
|
|
98
|
-
import { Context, Args, Info, Root } from '@leaven-graphql/nestjs';
|
|
160
|
+
import { Context, Args, Info, Root, type GqlContext } from '@leaven-graphql/nestjs';
|
|
161
|
+
|
|
162
|
+
// `GqlContext` is generic over the request type. Parameterise it with whatever
|
|
163
|
+
// your authentication layer attaches to the request so `ctx.req` stays typed.
|
|
164
|
+
interface AuthenticatedRequest extends Request {
|
|
165
|
+
user: User;
|
|
166
|
+
}
|
|
99
167
|
|
|
100
168
|
@Resolver()
|
|
101
169
|
export class UserResolver {
|
|
170
|
+
constructor(private readonly userService: UserService) {}
|
|
171
|
+
|
|
102
172
|
@Query(() => User)
|
|
103
|
-
async me(@Context() ctx: GqlContext) {
|
|
173
|
+
async me(@Context() ctx: GqlContext<AuthenticatedRequest>) {
|
|
104
174
|
return ctx.req.user;
|
|
105
175
|
}
|
|
106
176
|
|
|
@@ -119,6 +189,8 @@ import { Complexity, Description, CacheHint, Deprecated } from '@leaven-graphql/
|
|
|
119
189
|
|
|
120
190
|
@Resolver()
|
|
121
191
|
export class PostResolver {
|
|
192
|
+
constructor(private readonly postService: PostService) {}
|
|
193
|
+
|
|
122
194
|
@Query(() => [Post])
|
|
123
195
|
@Complexity(10)
|
|
124
196
|
@Description('Fetch all posts')
|
|
@@ -135,13 +207,41 @@ export class PostResolver {
|
|
|
135
207
|
}
|
|
136
208
|
```
|
|
137
209
|
|
|
210
|
+
`@Description` and `@Deprecated` are applied to the emitted schema when the
|
|
211
|
+
module builds it from `typeDefs` + `resolvers`: the annotated resolver's field
|
|
212
|
+
gets the description, and a deprecated field is printed with
|
|
213
|
+
`@deprecated(reason: ...)` so client tooling sees it. A pre-built `schema`
|
|
214
|
+
passed via the `schema` option is used exactly as given, so annotate its fields
|
|
215
|
+
there instead.
|
|
216
|
+
|
|
217
|
+
The other two carry caveats:
|
|
218
|
+
|
|
219
|
+
- **`@Complexity(n)` is observational only.** `ComplexityInterceptor` reads it
|
|
220
|
+
and accumulates the total onto the request context's
|
|
221
|
+
`_resolvedFieldComplexity` key. Nothing in this package reads that key back —
|
|
222
|
+
in particular it is deliberately *not* `_queryComplexity`, the key the driver
|
|
223
|
+
computes statically and `ComplexityGuard` enforces. NestJS runs guards before
|
|
224
|
+
interceptors, so a value accumulated during a request could never gate that
|
|
225
|
+
same request anyway. Consume `_resolvedFieldComplexity` yourself from logging
|
|
226
|
+
or metrics after execution. To actually *reject* expensive queries, set
|
|
227
|
+
`maxComplexity` — the executor enforces that one directly, before any resolver
|
|
228
|
+
runs — rather than annotating fields.
|
|
229
|
+
- **`@CacheHint(...)` is a no-op.** `CachingInterceptor` forwards the hint to
|
|
230
|
+
`info.cacheControl.setCacheHint(...)`, but `info.cacheControl` is an Apollo
|
|
231
|
+
Server construct and Leaven never attaches it — so the guarded call is always
|
|
232
|
+
skipped and no cache header or hint is ever produced. Treat it as deprecated:
|
|
233
|
+
it is retained only so existing code keeps compiling. Set cache headers in
|
|
234
|
+
your own middleware, or configure the `cache` option for Leaven's document
|
|
235
|
+
cache (which caches parsed/compiled documents, not responses).
|
|
236
|
+
|
|
138
237
|
### Guards
|
|
139
238
|
|
|
140
239
|
#### Authentication Guard
|
|
141
240
|
|
|
142
241
|
```typescript
|
|
143
242
|
import { UseGuards } from '@nestjs/common';
|
|
144
|
-
import {
|
|
243
|
+
import { Resolver, Query } from '@nestjs/graphql';
|
|
244
|
+
import { AuthGuard, Context, Public, type GqlContext } from '@leaven-graphql/nestjs';
|
|
145
245
|
|
|
146
246
|
@Resolver()
|
|
147
247
|
@UseGuards(AuthGuard)
|
|
@@ -163,11 +263,14 @@ export class UserResolver {
|
|
|
163
263
|
|
|
164
264
|
```typescript
|
|
165
265
|
import { UseGuards } from '@nestjs/common';
|
|
166
|
-
import {
|
|
266
|
+
import { Resolver, Query, Mutation } from '@nestjs/graphql';
|
|
267
|
+
import { AuthGuard, Args, RolesGuard, Roles } from '@leaven-graphql/nestjs';
|
|
167
268
|
|
|
168
269
|
@Resolver()
|
|
169
270
|
@UseGuards(AuthGuard, RolesGuard)
|
|
170
271
|
export class AdminResolver {
|
|
272
|
+
constructor(private readonly userService: UserService) {}
|
|
273
|
+
|
|
171
274
|
@Query(() => [User])
|
|
172
275
|
@Roles('admin')
|
|
173
276
|
async users() {
|
|
@@ -186,11 +289,14 @@ export class AdminResolver {
|
|
|
186
289
|
|
|
187
290
|
```typescript
|
|
188
291
|
import { UseGuards } from '@nestjs/common';
|
|
189
|
-
import {
|
|
292
|
+
import { Resolver, Mutation } from '@nestjs/graphql';
|
|
293
|
+
import { AuthGuard, Args, PermissionsGuard, Permissions } from '@leaven-graphql/nestjs';
|
|
190
294
|
|
|
191
295
|
@Resolver()
|
|
192
296
|
@UseGuards(AuthGuard, PermissionsGuard)
|
|
193
297
|
export class PostResolver {
|
|
298
|
+
constructor(private readonly postService: PostService) {}
|
|
299
|
+
|
|
194
300
|
@Mutation(() => Post)
|
|
195
301
|
@Permissions('posts:write')
|
|
196
302
|
async createPost(@Args('input') input: CreatePostInput) {
|
|
@@ -211,6 +317,7 @@ export class PostResolver {
|
|
|
211
317
|
|
|
212
318
|
```typescript
|
|
213
319
|
import { UseInterceptors } from '@nestjs/common';
|
|
320
|
+
import { Resolver } from '@nestjs/graphql';
|
|
214
321
|
import { LoggingInterceptor } from '@leaven-graphql/nestjs';
|
|
215
322
|
|
|
216
323
|
@Resolver()
|
|
@@ -224,6 +331,7 @@ export class UserResolver {
|
|
|
224
331
|
|
|
225
332
|
```typescript
|
|
226
333
|
import { UseInterceptors } from '@nestjs/common';
|
|
334
|
+
import { Resolver } from '@nestjs/graphql';
|
|
227
335
|
import { MetricsInterceptor } from '@leaven-graphql/nestjs';
|
|
228
336
|
|
|
229
337
|
@Resolver()
|
|
@@ -236,6 +344,7 @@ export class UserResolver {
|
|
|
236
344
|
### Custom Context Decorator
|
|
237
345
|
|
|
238
346
|
```typescript
|
|
347
|
+
import { Resolver, Query } from '@nestjs/graphql';
|
|
239
348
|
import { createContextDecorator } from '@leaven-graphql/nestjs';
|
|
240
349
|
|
|
241
350
|
// Create a custom decorator for accessing the current user
|
|
@@ -244,6 +353,8 @@ export const CurrentUser = createContextDecorator<User>('user');
|
|
|
244
353
|
// Usage
|
|
245
354
|
@Resolver()
|
|
246
355
|
export class ProfileResolver {
|
|
356
|
+
constructor(private readonly profileService: ProfileService) {}
|
|
357
|
+
|
|
247
358
|
@Query(() => Profile)
|
|
248
359
|
async profile(@CurrentUser() user: User) {
|
|
249
360
|
return this.profileService.getByUserId(user.id);
|
|
@@ -282,7 +393,14 @@ export class CustomGuard implements CanActivate {
|
|
|
282
393
|
|
|
283
394
|
### Subscriptions
|
|
284
395
|
|
|
285
|
-
|
|
396
|
+
> **`SubscriptionManager` speaks the graphql-ws protocol; it does not own a
|
|
397
|
+
> server.** Importing `LeavenModule` registers the manager but does **not** open
|
|
398
|
+
> a WebSocket endpoint — nothing here calls `server.upgrade()`, because the HTTP
|
|
399
|
+
> server belongs to your Nest adapter, not to this package. Configuring
|
|
400
|
+
> `subscriptions` alone gets you a configured object and no listening socket.
|
|
401
|
+
> You must wire the transport yourself, as shown below.
|
|
402
|
+
|
|
403
|
+
Configure the protocol behaviour on the module:
|
|
286
404
|
|
|
287
405
|
```typescript
|
|
288
406
|
import { Module } from '@nestjs/common';
|
|
@@ -309,20 +427,133 @@ import { LeavenModule, SubscriptionManager } from '@leaven-graphql/nestjs';
|
|
|
309
427
|
export class AppModule {}
|
|
310
428
|
```
|
|
311
429
|
|
|
312
|
-
|
|
430
|
+
#### Wiring the WebSocket transport
|
|
431
|
+
|
|
432
|
+
Two things are yours to do: **register the upgrade route**, and **hand each
|
|
433
|
+
socket to the manager**. `SubscriptionManager.getPath()` reports the path it was
|
|
434
|
+
configured for (`subscriptions.path`, falling back to `options.path`, then
|
|
435
|
+
`/graphql`), so route against that rather than a duplicated constant.
|
|
436
|
+
|
|
437
|
+
Under Bun, sockets arrive as `ServerWebSocket`, which has no `addEventListener`.
|
|
438
|
+
`getWebSocketConfig()` returns `open`/`message`/`close` handlers already bound to
|
|
439
|
+
the manager, ready to drop into `Bun.serve`:
|
|
440
|
+
|
|
441
|
+
```typescript
|
|
442
|
+
import { NestFactory } from '@nestjs/core';
|
|
443
|
+
import { BunAdapter } from '@lexmata/nestjs-platform-bun';
|
|
444
|
+
import { SubscriptionManager } from '@leaven-graphql/nestjs';
|
|
445
|
+
import { AppModule } from './app.module';
|
|
446
|
+
|
|
447
|
+
async function bootstrap() {
|
|
448
|
+
const app = await NestFactory.create(AppModule, new BunAdapter());
|
|
449
|
+
await app.init();
|
|
450
|
+
|
|
451
|
+
const subscriptions = app.get(SubscriptionManager);
|
|
452
|
+
|
|
453
|
+
Bun.serve({
|
|
454
|
+
port: 3000,
|
|
455
|
+
// The upgrade route. The manager never calls server.upgrade() itself —
|
|
456
|
+
// it does not have the Server instance.
|
|
457
|
+
fetch(request, server) {
|
|
458
|
+
const { pathname } = new URL(request.url);
|
|
459
|
+
if (
|
|
460
|
+
pathname === subscriptions.getPath() &&
|
|
461
|
+
request.headers.get('upgrade')?.toLowerCase() === 'websocket'
|
|
462
|
+
) {
|
|
463
|
+
// Bun requires the `data` option whenever the socket's data type is
|
|
464
|
+
// not `undefined`; nothing is stashed here.
|
|
465
|
+
return server.upgrade(request, { data: undefined })
|
|
466
|
+
? undefined
|
|
467
|
+
: new Response('Upgrade failed', { status: 400 });
|
|
468
|
+
}
|
|
469
|
+
// Everything else stays on the Nest HTTP pipeline, including the
|
|
470
|
+
// GraphQL POST endpoint served by GraphQLMiddleware.
|
|
471
|
+
return app.getHttpAdapter().getInstance().fetch(request);
|
|
472
|
+
},
|
|
473
|
+
websocket: subscriptions.getWebSocketConfig(),
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
bootstrap();
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
`subscriptions.path` defaults to `options.path`, so the GraphQL POST endpoint and
|
|
481
|
+
the WebSocket endpoint share `/graphql` by default — they are told apart by the
|
|
482
|
+
`Upgrade` header, as above. Set `subscriptions.path` to something else to
|
|
483
|
+
separate them.
|
|
484
|
+
|
|
485
|
+
**The upgrade request is not threaded through `getWebSocketConfig()`**, so
|
|
486
|
+
`ctx.request` is `undefined` inside `onConnect` / `context` hooks when you use it.
|
|
487
|
+
If a hook needs the original request (to read a cookie or the `Sec-WebSocket-Protocol`
|
|
488
|
+
header, say), skip the packaged config and call `handleOpen` yourself with the
|
|
489
|
+
request in hand:
|
|
490
|
+
|
|
491
|
+
<!-- doc-check: skip - object-literal excerpt, not a standalone module -->
|
|
492
|
+
```typescript
|
|
493
|
+
websocket: {
|
|
494
|
+
open: (socket) => subscriptions.handleOpen(socket, socket.data.request),
|
|
495
|
+
message: (socket, message) => void subscriptions.handleMessage(socket, message),
|
|
496
|
+
close: (socket) => void subscriptions.handleClose(socket),
|
|
497
|
+
},
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
stashing it at upgrade time with `server.upgrade(request, { data: { request } })`.
|
|
501
|
+
Authenticating from `connectionParams` in `onConnect` avoids the problem entirely
|
|
502
|
+
and is the more common graphql-ws pattern.
|
|
503
|
+
|
|
504
|
+
If your sockets are DOM-shaped `WebSocket` objects instead (a `ws`-style server,
|
|
505
|
+
or a test harness), hand each one to `handleConnection` and the manager attaches
|
|
506
|
+
its own `message`/`close`/`error` listeners:
|
|
507
|
+
|
|
508
|
+
```typescript
|
|
509
|
+
await subscriptions.handleConnection(socket, request);
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
Inject the module's shared `PubSub` with `@InjectPubSub()` and use the
|
|
513
|
+
`@Subscription` decorator in resolvers:
|
|
313
514
|
|
|
314
515
|
```typescript
|
|
315
516
|
import { Resolver } from '@nestjs/graphql';
|
|
316
|
-
import { Subscription } from '@leaven-graphql/nestjs';
|
|
517
|
+
import { InjectPubSub, Subscription } from '@leaven-graphql/nestjs';
|
|
518
|
+
import type { PubSub } from '@leaven-graphql/ws';
|
|
317
519
|
|
|
318
520
|
@Resolver()
|
|
319
521
|
export class MessageResolver {
|
|
522
|
+
constructor(@InjectPubSub() private readonly pubSub: PubSub) {}
|
|
523
|
+
|
|
320
524
|
@Subscription(() => Message, {
|
|
321
|
-
filter: (payload, variables) =>
|
|
525
|
+
filter: (payload, variables) =>
|
|
526
|
+
(payload as { roomId: string }).roomId === (variables as { roomId: string }).roomId,
|
|
322
527
|
})
|
|
323
528
|
messageAdded() {
|
|
324
529
|
return this.pubSub.asyncIterator('MESSAGE_ADDED');
|
|
325
530
|
}
|
|
531
|
+
|
|
532
|
+
publish(roomId: string, body: string) {
|
|
533
|
+
this.pubSub.publish('MESSAGE_ADDED', { messageAdded: { roomId, body } });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
`@InjectPubSub()` resolves the module's own in-memory instance by default, so a
|
|
539
|
+
publisher and a subscriber in different providers share the same topics. Pass a
|
|
540
|
+
`pubSub` option to `forRoot()`/`forRootAsync()` to supply your own — for example
|
|
541
|
+
a distributed engine, so events published on one server instance reach
|
|
542
|
+
subscribers on another.
|
|
543
|
+
|
|
544
|
+
`@SubscriptionFilter` is the standalone equivalent of the `filter` option, and
|
|
545
|
+
preserves the decorated method's return kind: a method returning an async
|
|
546
|
+
iterable still returns one, so `for await (const event of resolver.messageAdded())`
|
|
547
|
+
works in tests and direct callers.
|
|
548
|
+
|
|
549
|
+
<!-- doc-check: skip - class-member excerpt, not a standalone module -->
|
|
550
|
+
```typescript
|
|
551
|
+
import { SubscriptionFilter } from '@leaven-graphql/nestjs';
|
|
552
|
+
|
|
553
|
+
@Subscription(() => Comment)
|
|
554
|
+
@SubscriptionFilter((payload, variables) => payload.postId === variables.postId)
|
|
555
|
+
commentAdded() {
|
|
556
|
+
return this.pubSub.asyncIterator('COMMENT_ADDED');
|
|
326
557
|
}
|
|
327
558
|
```
|
|
328
559
|
|
|
@@ -356,41 +587,195 @@ export class SchemaService {
|
|
|
356
587
|
|--------|------|---------|-------------|
|
|
357
588
|
| `path` | `string` | `/graphql` | GraphQL endpoint path |
|
|
358
589
|
| `schema` | `GraphQLSchema` | - | Pre-built GraphQL schema |
|
|
590
|
+
| `typeDefs` | `string \| DocumentNode \| Array<string \| DocumentNode>` | - | SDL type definitions |
|
|
591
|
+
| `resolvers` | `Record<string, unknown> \| Array<Record<string, unknown>>` | - | Resolver map merged into `typeDefs` |
|
|
592
|
+
| `subscriptions` | `SubscriptionConfig` | - | GraphQL-over-WebSocket configuration: lifecycle hooks (`onConnect`, `onDisconnect`, `onSubscribe`, `onOperation`, `onComplete`), per-connection context factory, keep-alive, connection-init timeout, and max subscriptions per connection |
|
|
593
|
+
| `pubSub` | `PubSub` | per-module in-memory instance | Instance resolved by `@InjectPubSub()`; supply your own to share it across modules or to use a distributed engine |
|
|
359
594
|
| `playground` | `boolean` | `true` (dev) | Enable GraphQL Playground |
|
|
360
595
|
| `introspection` | `boolean` | `true` (dev) | Enable introspection |
|
|
361
596
|
| `cache` | `DocumentCacheConfig \| boolean` | - | Document cache configuration |
|
|
362
|
-
| `maxComplexity` | `number` | - | Maximum query complexity |
|
|
363
|
-
| `maxDepth` | `number` | - | Maximum query depth |
|
|
597
|
+
| `maxComplexity` | `number` | - | Maximum query complexity. Enforced by the executor before any resolver runs — an over-budget document is rejected with a `ComplexityError` |
|
|
598
|
+
| `maxDepth` | `number` | - | Maximum query depth. **Only recorded on the context** (as `_queryDepth`) for `DepthGuard`, so it is not enforced on `LeavenModule`'s HTTP path — see the caveat under [Features](#features). The `LeavenGraphQLDriver` path enforces it in the executor |
|
|
364
599
|
| `metrics` | `boolean` | `false` | Enable execution metrics |
|
|
365
|
-
| `debug` | `boolean` | `false` | Enable debug mode |
|
|
366
600
|
| `context` | `ContextFactory` | - | Custom context factory |
|
|
367
601
|
| `formatError` | `FormatErrorFn` | - | Error formatting function |
|
|
368
|
-
| `
|
|
602
|
+
| `publicUrl` | `string` | `http://localhost` | Canonical origin (scheme + authority) used to build `request.url` for the context factory. The `Host` header is client-controlled, so it is not trusted: set this when a factory resolves tenancy or issues redirects from `request.url` |
|
|
603
|
+
| `cors` | `boolean \| CorsOptions` | *unset — no CORS headers* | CORS handling. **Opt-in:** while unset the endpoint emits no `Access-Control-*` headers and delegates `OPTIONS` preflights to the next handler. `true` (or an options object without `origin`) enables `Access-Control-Allow-Origin: *`; set `origin` to narrow it. `false` is equivalent to unset |
|
|
604
|
+
| `includeStacktraceInErrorResponses` | `boolean` | `false` | Include stack traces in errors (also suppresses production masking — honored in every environment, not just development) |
|
|
605
|
+
|
|
606
|
+
### Deprecated options
|
|
607
|
+
|
|
608
|
+
These are accepted so existing configurations keep compiling, but nothing reads
|
|
609
|
+
them. Removal target: 0.3.0.
|
|
610
|
+
|
|
611
|
+
| Option | Type | Status |
|
|
612
|
+
|--------|------|--------|
|
|
613
|
+
| `debug` | `boolean` | Not implemented; setting it has no effect |
|
|
614
|
+
| `sortSchema` | `boolean` | No effect — pass `sortSchema` to `generateSchemaFile` instead |
|
|
615
|
+
| `buildSchemaOptions` | `BuildSchemaOptions` | No effect — `LeavenModule` has no code-first pipeline. For code-first, use `LeavenGraphQLDriver` with `GraphQLModule.forRoot()`, which honors `@nestjs/graphql`'s own `buildSchemaOptions` |
|
|
616
|
+
| `plugins` | `LeavenPlugin[]` | No effect — use NestJS interceptors and guards |
|
|
617
|
+
| `autoSchemaFile` | `boolean \| string` | Not implemented by `LeavenModule`; configuring it without `schema` or `typeDefs` throws at bootstrap. `GraphQLModule.forRoot({ driver: LeavenGraphQLDriver, autoSchemaFile: true })` supports it fully |
|
|
369
618
|
|
|
370
619
|
## Integration with @nestjs/graphql
|
|
371
620
|
|
|
372
|
-
|
|
621
|
+
`LeavenGraphQLDriver` is a real `@nestjs/graphql` driver: it extends
|
|
622
|
+
`AbstractGraphQLDriver` and implements `start()` and `stop()`. Pass it to
|
|
623
|
+
`GraphQLModule.forRoot()` and your `@Resolver()` classes become NestJS
|
|
624
|
+
handlers, executed by Leaven.
|
|
625
|
+
|
|
626
|
+
**On this path guards, interceptors, pipes, filters and parameter decorators
|
|
627
|
+
all execute.**
|
|
373
628
|
|
|
374
629
|
```typescript
|
|
375
630
|
import { Module } from '@nestjs/common';
|
|
376
631
|
import { GraphQLModule } from '@nestjs/graphql';
|
|
377
|
-
import {
|
|
632
|
+
import { LeavenGraphQLDriver, type LeavenDriverConfig } from '@leaven-graphql/nestjs';
|
|
633
|
+
import { UserResolver } from './user.resolver';
|
|
378
634
|
|
|
379
635
|
@Module({
|
|
380
636
|
imports: [
|
|
381
|
-
|
|
637
|
+
GraphQLModule.forRoot<LeavenDriverConfig>({
|
|
638
|
+
driver: LeavenGraphQLDriver,
|
|
639
|
+
// Code-first. `true` keeps the generated schema in memory; pass a path
|
|
640
|
+
// to also write it to disk.
|
|
641
|
+
autoSchemaFile: true,
|
|
382
642
|
playground: true,
|
|
643
|
+
path: '/graphql',
|
|
644
|
+
// Leaven executor options
|
|
383
645
|
cache: { maxSize: 1000 },
|
|
646
|
+
metrics: true,
|
|
647
|
+
maxComplexity: 1000,
|
|
648
|
+
maxDepth: 10,
|
|
649
|
+
// Runs once per request; merged over the built-in `{ req, res }`
|
|
650
|
+
context: ({ req }) => ({ req, user: req.user ?? null }),
|
|
384
651
|
}),
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
652
|
+
],
|
|
653
|
+
providers: [UserResolver],
|
|
654
|
+
})
|
|
655
|
+
export class AppModule {}
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
```typescript
|
|
659
|
+
import { Resolver, Query, Args } from '@nestjs/graphql';
|
|
660
|
+
import { UseGuards, UseInterceptors } from '@nestjs/common';
|
|
661
|
+
import {
|
|
662
|
+
AuthGuard,
|
|
663
|
+
RolesGuard,
|
|
664
|
+
Roles,
|
|
665
|
+
LoggingInterceptor,
|
|
666
|
+
Context,
|
|
667
|
+
} from '@leaven-graphql/nestjs';
|
|
668
|
+
|
|
669
|
+
@Resolver()
|
|
670
|
+
@UseGuards(AuthGuard, RolesGuard)
|
|
671
|
+
@UseInterceptors(LoggingInterceptor)
|
|
672
|
+
export class UserResolver {
|
|
673
|
+
@Query(() => String)
|
|
674
|
+
@Roles('admin')
|
|
675
|
+
public adminReport(
|
|
676
|
+
@Args('id', { type: () => String }) id: string,
|
|
677
|
+
@Context() ctx: { user: { id: string } },
|
|
678
|
+
): string {
|
|
679
|
+
// The guards ran before this body. `id` and `ctx` were injected by the
|
|
680
|
+
// parameter decorators.
|
|
681
|
+
return `${id} for ${ctx.user.id}`;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
### Why it works
|
|
687
|
+
|
|
688
|
+
`GraphQLModule.onModuleInit` calls `generateSchema()` before `start()`.
|
|
689
|
+
`generateSchema()` is inherited from `AbstractGraphQLDriver` and delegates to
|
|
690
|
+
`GraphQLFactory`, which builds the schema from your `@Resolver()` classes and
|
|
691
|
+
wraps every field resolver in NestJS's `ExternalContextCreator` — the wrapper
|
|
692
|
+
that runs the execution pipeline. By the time `start()` receives
|
|
693
|
+
`options.schema`, that pipeline is already part of the schema; the driver
|
|
694
|
+
constructs a `LeavenExecutor` over it and serves `POST <path>` (plus
|
|
695
|
+
`GET <path>` for GraphiQL when `playground` is enabled).
|
|
696
|
+
|
|
697
|
+
### Driver options
|
|
698
|
+
|
|
699
|
+
`LeavenDriverConfig` accepts everything `GqlModuleOptions` does, plus:
|
|
700
|
+
|
|
701
|
+
| Option | Type | Description |
|
|
702
|
+
|--------|------|-------------|
|
|
703
|
+
| `cache` | `DocumentCacheConfig \| boolean \| IDocumentCache` | Document/validation cache passed to the executor |
|
|
704
|
+
| `metrics` | `boolean` | Collect per-operation metrics and expose them under `extensions.metrics` |
|
|
705
|
+
| `maxComplexity` | `number` | Rejected by the executor before any resolver runs |
|
|
706
|
+
| `maxDepth` | `number` | Rejected by the executor at parse time |
|
|
707
|
+
| `playground` | `boolean` | Serve GraphiQL on `GET <path>` |
|
|
708
|
+
| `subscriptionEndpoint` | `string` | WebSocket endpoint advertised to GraphiQL |
|
|
709
|
+
| `formatError` | `(error) => error` | Final transformation applied to every response error |
|
|
710
|
+
| `includeStacktraceInErrorResponses` | `boolean` | Include stack traces; also disables production masking |
|
|
711
|
+
|
|
712
|
+
### Errors and HTTP status
|
|
713
|
+
|
|
714
|
+
Every error leaves the driver carrying an `ErrorCode`, and the status is
|
|
715
|
+
derived from it (`400` validation/complexity/depth/input, `401`
|
|
716
|
+
authentication, `403` authorization, `404` not found, `429` rate limit, `500`
|
|
717
|
+
otherwise) — but **only for a total failure**. A response carrying `data`
|
|
718
|
+
alongside `errors` is a spec-conformant partial success and stays `200`.
|
|
719
|
+
|
|
720
|
+
NestJS `HttpException`s — what guards, pipes and filters throw — are
|
|
721
|
+
translated to the equivalent Leaven error, so `UnauthorizedException` reaches
|
|
722
|
+
the client as `401 UNAUTHENTICATED` rather than as a masked `500`.
|
|
723
|
+
|
|
724
|
+
### The `LeavenModule` alternative
|
|
725
|
+
|
|
726
|
+
`LeavenModule.forRoot({ typeDefs, resolvers })` remains supported and is the
|
|
727
|
+
lighter option when you do not want the NestJS pipeline: it serves a schema
|
|
728
|
+
built from plain resolver functions. On that path guards, interceptors and
|
|
729
|
+
parameter decorators **do not run** (see the callout under
|
|
730
|
+
[Features](#features)); put authorization in the resolver or in the `context`
|
|
731
|
+
factory:
|
|
732
|
+
|
|
733
|
+
```typescript
|
|
734
|
+
import { Module } from '@nestjs/common';
|
|
735
|
+
import { LeavenModule } from '@leaven-graphql/nestjs';
|
|
736
|
+
import { AuthorizationError } from '@leaven-graphql/errors';
|
|
737
|
+
|
|
738
|
+
@Module({
|
|
739
|
+
imports: [
|
|
740
|
+
LeavenModule.forRoot({
|
|
741
|
+
typeDefs,
|
|
742
|
+
resolvers,
|
|
743
|
+
cache: { maxSize: 1000 },
|
|
744
|
+
playground: true,
|
|
745
|
+
// Runs before execution. Throwing a Leaven error here reaches the client
|
|
746
|
+
// with its own status (401 for AuthenticationError), so this is the
|
|
747
|
+
// practical stand-in for an AuthGuard on this path.
|
|
748
|
+
context: async (request) => {
|
|
749
|
+
const token = request.headers.get('authorization');
|
|
750
|
+
return { user: token ? await verify(token) : null };
|
|
751
|
+
},
|
|
388
752
|
}),
|
|
389
753
|
],
|
|
390
754
|
})
|
|
391
755
|
export class AppModule {}
|
|
392
756
|
```
|
|
393
757
|
|
|
758
|
+
<!-- doc-check: skip - defines `resolvers` for the module block above it, so checking it as a continuation would be a forward reference -->
|
|
759
|
+
|
|
760
|
+
```typescript
|
|
761
|
+
const resolvers = {
|
|
762
|
+
Query: {
|
|
763
|
+
adminReport: (_root, _args, context) => {
|
|
764
|
+
if (!context.user?.roles.includes('admin')) {
|
|
765
|
+
throw new AuthorizationError('admin role required');
|
|
766
|
+
}
|
|
767
|
+
return reportService.build();
|
|
768
|
+
},
|
|
769
|
+
},
|
|
770
|
+
};
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
One cost limit does not depend on which path you choose: **`maxComplexity` is
|
|
774
|
+
enforced by the executor itself**, which rejects an over-budget document with a
|
|
775
|
+
`ComplexityError` before any resolver runs. `maxDepth` under `LeavenModule` is
|
|
776
|
+
only recorded on the context for `DepthGuard`, so there it needs the driver
|
|
777
|
+
like every other guard.
|
|
778
|
+
|
|
394
779
|
## License
|
|
395
780
|
|
|
396
|
-
Apache 2.0 -
|
|
781
|
+
Apache 2.0 - Joseph Quinn
|