graphql-query-depth-limit-esm 0.1.0 → 2.0.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 CHANGED
@@ -1,496 +1,517 @@
1
1
  # graphql-query-depth-limit-esm
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/graphql-query-depth-limit-esm.svg)](https://www.npmjs.com/package/graphql-query-depth-limit-esm)
4
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
- [![Build Status](https://github.com/lafittemehdy/graphql-query-depth-limit-esm/actions/workflows/test.yml/badge.svg)](https://github.com/lafittemehdy/graphql-query-depth-limit-esm/actions)
3
+ [![CI](https://github.com/lafittemehdy/graphql-query-depth-limit-esm/actions/workflows/ci.yml/badge.svg)](https://github.com/lafittemehdy/graphql-query-depth-limit-esm/actions/workflows/ci.yml)
4
+ [![Publish](https://github.com/lafittemehdy/graphql-query-depth-limit-esm/actions/workflows/publish.yml/badge.svg)](https://github.com/lafittemehdy/graphql-query-depth-limit-esm/actions/workflows/publish.yml)
5
+ [![Pages](https://github.com/lafittemehdy/graphql-query-depth-limit-esm/actions/workflows/pages.yml/badge.svg)](https://github.com/lafittemehdy/graphql-query-depth-limit-esm/actions/workflows/pages.yml)
6
+ [![npm version](https://img.shields.io/npm/v/graphql-query-depth-limit-esm?logo=npm)](https://www.npmjs.com/package/graphql-query-depth-limit-esm)
7
+ [![npm downloads](https://img.shields.io/npm/dm/graphql-query-depth-limit-esm?logo=npm)](https://www.npmjs.com/package/graphql-query-depth-limit-esm)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
9
+ [![Node >=20](https://img.shields.io/badge/node-%3E%3D20-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
6
10
 
7
- Protect your GraphQL API by limiting query depth before execution.
11
+ Production-ready GraphQL query depth limiting as a validation rule. Prevents denial-of-service attacks from deeply nested queries by enforcing a configurable maximum depth.
8
12
 
9
- Prevents deeply nested queries from overloading your server. A lightweight, zero-dependency library that works with any GraphQL server (Apollo, Yoga, etc.) with native ESM and TypeScript support.
13
+ Explore the library's internal function architecture with the **[interactive node-based visualization](https://lafittemehdy.github.io/graphql-query-depth-limit-esm/architecture.html)**.
10
14
 
11
15
  ## Features
12
16
 
13
- - **Native ESM & TypeScript:** Modern module support with full type safety
14
- - **Works Anywhere:** Compatible with any GraphQL-compliant server (Apollo, Yoga, etc.)
15
- - **Fragment Support:** Correctly handles fragment spreads and inline fragments
16
- - **Flexible Ignore Rules:** Skip specific fields using strings, RegExp, or custom functions
17
- - **Directive Support:** Field-specific depth limits using `@depth` directive
18
- - **Zero Dependencies:** Lightweight and focused (small bundle size)
19
- - **Well Tested:** Comprehensive test suite
17
+ - **`@depth` directive** per-field depth overrides via schema directives
18
+ - **Alias-aware paths** violation paths use aliases when present, matching the response shape
19
+ - **Configurable introspection handling** control whether `__schema` and `__type` count toward depth
20
+ - **Callback support** optional callback reports per-operation depth for monitoring
21
+ - **Fragment-safe** handles named fragments, inline fragments, and circular fragment detection
22
+ - **Ignore rules** skip fields by name, pattern, or custom function
23
+ - **Interface directive inheritance** `@depth` directives on interface fields apply to all implementors
24
+ - **Short-circuit traversal** — stops immediately on first violation when no callback is provided
25
+ - **Validation rule** — integrates directly with `graphql`'s `validate()` function
26
+ - **Zero runtime dependencies** — only `graphql ^16` as a peer dependency
20
27
 
21
28
  ## Installation
22
29
 
23
30
  ```bash
24
- npm install graphql-query-depth-limit-esm
31
+ pnpm add graphql-query-depth-limit-esm graphql
32
+ ```
33
+
34
+ ```bash
35
+ npm install graphql-query-depth-limit-esm graphql
36
+ ```
37
+
38
+ ```bash
39
+ yarn add graphql-query-depth-limit-esm graphql
25
40
  ```
26
41
 
27
42
  ## Quick Start
28
43
 
29
- ### Apollo Server Integration
44
+ ```ts
45
+ import { validate } from "graphql";
46
+ import { depthLimit } from "graphql-query-depth-limit-esm";
30
47
 
31
- ```typescript
32
- import { ApolloServer } from '@apollo/server';
33
- import { startStandaloneServer } from '@apollo/server/standalone';
34
- import { depthLimit } from 'graphql-query-depth-limit-esm';
48
+ const errors = validate(schema, document, [depthLimit(7, { useDirective: true })]);
49
+ ```
35
50
 
36
- // Step 1: Define your schema
37
- const typeDefs = `#graphql
38
- directive @depth(max: Int!) on FIELD_DEFINITION
51
+ The recommended way to use `graphql-query-depth-limit-esm` is the `@depth` directive — a global maximum protects your API, while per-field overrides can tighten limits (and can opt into deeper nesting when `directiveMode: "override"` is enabled).
39
52
 
40
- type Query {
41
- # Regular user endpoint with depth limit of 3
42
- user(id: ID!): User @depth(max: 3)
53
+ ### Global Limit with Per-Field Overrides
43
54
 
44
- # Admin endpoint with higher depth limit
45
- adminUser(id: ID!): User @depth(max: 10)
55
+ Include [`depthDirectiveTypeDefs`](#depthdirectivetypedefs) in your schema to declare the `@depth` directive, then annotate individual fields:
46
56
 
47
- # Public endpoint without directive (uses global limit)
48
- users: [User!]!
49
- }
57
+ ```graphql
58
+ # depthDirectiveTypeDefs provides this automatically:
59
+ # directive @depth(max: Int!) on FIELD_DEFINITION
50
60
 
51
- type User {
52
- id: ID!
53
- name: String!
54
- email: String!
55
- friends: [User!]!
56
- posts: [Post!]!
57
- }
61
+ type User {
62
+ name: String!
63
+ profile: Profile!
58
64
 
59
- type Post {
60
- id: ID!
61
- title: String!
62
- author: User!
63
- }
64
- `;
65
-
66
- // Step 2: Define your resolvers
67
- const resolvers = {
68
- Query: {
69
- user: (_: unknown, { id }: { id: string }) => ({
70
- id,
71
- name: "John Doe",
72
- email: "john@example.com",
73
- friends: [],
74
- posts: [],
75
- }),
76
- adminUser: (_: unknown, { id }: { id: string }) => ({
77
- id,
78
- name: "Admin User",
79
- email: "admin@example.com",
80
- friends: [],
81
- posts: [],
82
- }),
83
- users: () => [
84
- {
85
- id: "1",
86
- name: "Alice",
87
- email: "alice@example.com",
88
- friends: [],
89
- posts: [],
90
- },
91
- ],
92
- },
93
- User: {
94
- friends: (parent: { id: string }) => [
95
- {
96
- id: `${parent.id}-friend`,
97
- name: "Friend User",
98
- email: "friend@example.com",
99
- friends: [],
100
- posts: [],
101
- },
102
- ],
103
- posts: () => [],
104
- },
105
- };
65
+ # Self-referential — allow up to 3 levels of nesting
66
+ friends: [User!]! @depth(max: 3)
67
+ }
106
68
 
107
- // Step 3: Create and start the server with depth limiting
108
- const server = new ApolloServer({
109
- typeDefs,
69
+ type Post {
70
+ title: String!
71
+ author: User!
72
+
73
+ # Recursive comments — allow up to 5 levels deep
74
+ comments: [Comment!]! @depth(max: 5)
75
+ }
76
+
77
+ type Comment {
78
+ text: String!
79
+ replies: [Comment!]! @depth(max: 4)
80
+ }
81
+ ```
82
+
83
+ Build the schema with the directive type defs, then enable directive support via [`depthLimit()`](#depthlimitmaxdepth-options-callback):
84
+
85
+ ```ts
86
+ import { makeExecutableSchema } from "@graphql-tools/schema";
87
+ import { depthDirectiveTypeDefs, depthLimit } from "graphql-query-depth-limit-esm";
88
+ import { validate } from "graphql";
89
+
90
+ const schema = makeExecutableSchema({
91
+ typeDefs: [depthDirectiveTypeDefs, yourTypeDefs],
110
92
  resolvers,
111
- validationRules: [
112
- // Global depth limit of 5 with directive support enabled
113
- depthLimit(5, { useDirective: true }),
114
- ],
115
93
  });
116
94
 
117
- const { url } = await startStandaloneServer(server, {
118
- listen: { port: 4000 },
119
- });
95
+ // Global max of 7 @depth directives can tighten but never exceed this limit
96
+ const errors = validate(schema, document, [
97
+ depthLimit(7, { useDirective: true }),
98
+ ]);
99
+ ```
100
+
101
+ ### Nested Directives
102
+
103
+ When multiple `@depth` directives appear along a query path, the effective limit is the **strictest (minimum)** along that path. A child directive can tighten an ancestor's limit but never relax it:
104
+
105
+ ```graphql
106
+ type Post {
107
+ # 3 levels from here (absolute max = currentDepth + 3)
108
+ comments: [Comment] @depth(max: 3)
109
+ }
120
110
 
121
- console.log(`🚀 Server ready at: ${url}`);
111
+ type Comment {
112
+ text: String
113
+ # Wants 5 levels, but capped by the ancestor's limit
114
+ replies: [Comment] @depth(max: 5)
115
+ }
122
116
  ```
123
117
 
124
- ## How It Works
118
+ This ensures a parent directive remains a hard ceiling for its entire subtree, preventing deeply nested child directives from punching through ancestor limits.
119
+
120
+ > **Note:** By default (`directiveMode: "cap"`), directives can only **tighten** the global `maxDepth`, never relax it. If you need directives to override the global limit for specific subtrees, set `directiveMode: "override"`. See [`directiveMode`](#depthlimitoptions) for details.
121
+
122
+ ### Interface Directive Inheritance
123
+
124
+ When a `@depth` directive is placed on an interface field, it applies to all concrete types implementing that interface — even if the concrete type's field definition has no directive:
125
+
126
+ ```graphql
127
+ interface Node {
128
+ children: [Node!]! @depth(max: 3)
129
+ }
130
+
131
+ type TreeNode implements Node {
132
+ children: [Node!]! # Inherits @depth(max: 3) from Node interface
133
+ label: String!
134
+ }
135
+ ```
125
136
 
126
- The library calculates query depth during GraphQL validation (before execution). Deeply nested queries are rejected before hitting your business logic.
137
+ When multiple interfaces define `@depth` on the same field, the **strictest (lowest)** limit is used.
127
138
 
128
- **Process:**
129
- 1. Client sends a query
130
- 2. Server parses and validates the query
131
- 3. **Query depth calculation runs** (this library)
132
- 4. If validation passes, query executes
139
+ ### Why Explicit Opt-In?
133
140
 
134
- The library traverses the query AST to calculate depth. It correctly handles fragments and introspection fields, counting only fields that actually contribute to nesting.
141
+ The [`useDirective`](#depthlimitoptions) option is `false` by default. This is intentional:
135
142
 
136
- ---
143
+ - **Follows GraphQL Convention.** Standard rules like `NoUnusedFragmentsRule` or `KnownDirectivesRule` are configured explicitly, and this library follows that same pattern for consistency.
144
+ - **No hidden side effects.** Users who only need a global depth cap get exactly that, without the engine scanning every field definition for directives they never added.
145
+ - **Predictable behavior.** Enabling directive support is a deliberate choice, making it clear in code review that per-field overrides are in play.
137
146
 
138
- ## Usage Examples
147
+ ## Usage
139
148
 
140
149
  ### Basic Depth Limiting
141
150
 
142
- ```typescript
143
- import { depthLimit } from 'graphql-query-depth-limit-esm';
144
- import { ApolloServer } from '@apollo/server';
151
+ For straightforward global limiting without per-field overrides, call [`depthLimit()`](#depthlimitmaxdepth-options-callback) with only a maximum depth:
152
+
153
+ ```ts
154
+ import { depthLimit } from "graphql-query-depth-limit-esm";
155
+ import { validate } from "graphql";
156
+
157
+ // Reject queries deeper than 10 levels
158
+ const rule = depthLimit(10);
159
+ const errors = validate(schema, document, [rule]);
160
+ ```
161
+
162
+ ### With Apollo Server
163
+
164
+ ```ts
165
+ import { ApolloServer } from "@apollo/server";
166
+ import { depthLimit } from "graphql-query-depth-limit-esm";
145
167
 
146
168
  const server = new ApolloServer({
147
169
  schema,
148
- validationRules: [depthLimit(10)], // Maximum depth of 10
170
+ validationRules: [depthLimit(7, { useDirective: true })],
149
171
  });
150
172
  ```
151
173
 
152
- ### With Ignore Rules
174
+ ### With Yoga
153
175
 
154
- Skip specific fields from depth calculation:
176
+ ```ts
177
+ import { createYoga } from "graphql-yoga";
178
+ import { depthLimit } from "graphql-query-depth-limit-esm";
155
179
 
156
- ```typescript
157
- import { depthLimit } from 'graphql-query-depth-limit-esm';
158
-
159
- const server = new ApolloServer({
180
+ const yoga = createYoga({
160
181
  schema,
161
- validationRules: [
162
- depthLimit(10, {
163
- ignore: [
164
- 'friends', // Exact field name
165
- /^internal/, // RegExp pattern
166
- (fieldName) => fieldName.startsWith('_'), // Custom function
167
- ],
168
- }),
182
+ plugins: [
183
+ {
184
+ onValidate({ addValidationRule }) {
185
+ addValidationRule(depthLimit(7, { useDirective: true }));
186
+ },
187
+ },
169
188
  ],
170
189
  });
171
190
  ```
172
191
 
173
- ### With Callback
192
+ ### Ignore Rules
174
193
 
175
- Monitor query depths:
194
+ Skip specific fields during depth calculation using strings, regular expressions, or custom functions. See [`IgnoreRule`](#ignorerule) for the full type definition.
176
195
 
177
- ```typescript
178
- import { depthLimit } from 'graphql-query-depth-limit-esm';
196
+ ```ts
197
+ const rule = depthLimit(5, {
198
+ ignore: [
199
+ // Exact field name match
200
+ "metadata",
179
201
 
180
- const server = new ApolloServer({
181
- schema,
182
- validationRules: [
183
- depthLimit(10, undefined, (depths) => {
184
- console.log('Query depths:', depths);
185
- // Output: { "GetUser": 5, "GetPosts": 3 }
186
- }),
202
+ // Regular expression pattern
203
+ /.*Connection$/,
204
+
205
+ // Custom function
206
+ (fieldName) => fieldName.startsWith("internal"),
187
207
  ],
188
208
  });
189
209
  ```
190
210
 
191
- ### With @depth Directive
211
+ > **Warning:** When `ignoreMode: "skip"` is set and a field matches an ignore rule, its **entire subtree** is skipped — not just the depth increment for that field. This means all children, grandchildren, etc. are excluded from depth calculation entirely. Use ignore rules carefully on composite fields, as deeply nested subtrees under an ignored field will bypass depth protection.
192
212
 
193
- Apply field-specific depth limits using the `@depth` directive:
213
+ ### Ignore Mode
194
214
 
195
- ```typescript
196
- import { depthLimit } from 'graphql-query-depth-limit-esm';
215
+ By default, ignored fields only skip the depth increment while still traversing children. Use [`ignoreMode`](#depthlimitoptions) to control this behavior:
197
216
 
198
- // First, add the directive to your schema
199
- const typeDefs = `#graphql
200
- directive @depth(max: Int!) on FIELD_DEFINITION
217
+ ```ts
218
+ // Default (secure): skip only the depth increment, still traverse children
219
+ const rule = depthLimit(5, { ignore: ["metadata"], ignoreMode: "exclude" });
201
220
 
202
- type Query {
203
- publicUser(id: ID!): User @depth(max: 2)
204
- adminUser(id: ID!): User @depth(max: 10)
205
- }
221
+ // Optional: skip the field and its entire subtree (use with caution)
222
+ const rule = depthLimit(5, { ignore: ["metadata"], ignoreMode: "skip" });
223
+ ```
206
224
 
207
- type User {
208
- id: ID!
209
- name: String!
210
- friends: [User]
211
- }
212
- `;
225
+ With `"exclude"`, the ignored field does not increment the depth counter, but its children are still traversed and subject to depth limits. This prevents attackers from nesting arbitrarily deep queries under ignored composite fields.
213
226
 
214
- const server = new ApolloServer({
215
- typeDefs,
216
- resolvers,
217
- validationRules: [
218
- depthLimit(5, { useDirective: true }),
219
- ],
227
+ If the ignored field is recursive (for example, `friends -> friends`), depth can remain flat along that edge because the ignored field keeps suppressing increments. To prevent this, enable [`limitIgnoredRecursion`](#depthlimitoptions), use `ignoreMode: "skip"`, or cap the field with `@depth`.
228
+
229
+ ### Recursive Ignore Guard
230
+
231
+ Enable [`limitIgnoredRecursion`](#depthlimitoptions) to prevent unbounded traversal when using `ignoreMode: "exclude"` on recursive fields:
232
+
233
+ ```ts
234
+ const rule = depthLimit(10, {
235
+ ignore: ["friends"],
236
+ ignoreMode: "exclude",
237
+ limitIgnoredRecursion: true,
220
238
  });
221
239
  ```
222
240
 
223
- **How it works:**
224
- - `publicUser` field has `@depth(max: 2)` - queries can only go 2 levels deep
225
- - `adminUser` field has `@depth(max: 10)` - queries can go 10 levels deep
226
- - Fields without `@depth` directive use the global limit (5 in this example)
227
- - The `@depth` directive can be combined with `@complexity` and other directives
228
-
229
- ## How Depth is Calculated
241
+ When enabled, the first ignored field occurrence on a path is still excluded, but repeated occurrences on the same path increment depth normally.
230
242
 
231
- Depth is measured by counting nested field selections:
243
+ ### Short-Circuit Control
232
244
 
233
- ```graphql
234
- query {
235
- user { # depth 1
236
- posts { # depth 2
237
- comments { # depth 3
238
- author { # depth 4
239
- name # depth 4 (scalar fields don't add depth)
240
- }
241
- }
242
- }
243
- }
244
- }
245
- # Total depth: 4
246
- ```
245
+ By default, short-circuit behavior is auto-detected:
247
246
 
248
- ### What Doesn't Add Depth
247
+ - No callback: short-circuits on first violation (`shortCircuit: true`)
248
+ - Callback provided: traverses full tree for exact depth (`shortCircuit: false`)
249
249
 
250
- - **Fragment spreads** (`...FragmentName`) - The fragment's fields are counted, not the spread itself
251
- - **Inline fragments** (`... on Type { }`) - The inline fragment doesn't add depth
252
- - **Introspection fields** (`__typename`, `__schema`, `__type`, etc.)
253
- - **Fields matching ignore rules** - Skipped entirely from calculation
254
- - **Scalar/leaf fields** - Terminal nodes don't increase depth
250
+ You can override this explicitly:
255
251
 
256
- ## Query Depth Examples
252
+ ```ts
253
+ // Force full traversal even without a callback
254
+ const exactRule = depthLimit(5, { shortCircuit: false });
257
255
 
258
- **Schema:**
259
- ```graphql
260
- type Query {
261
- user(id: ID!): User
262
- }
256
+ // Force early bailout even with a callback
257
+ const fastRule = depthLimit(5, { shortCircuit: true }, (depths) => {
258
+ console.log(depths);
259
+ });
260
+ ```
263
261
 
264
- type User {
265
- id: ID!
266
- name: String!
267
- friends: [User]
268
- posts: [Post]
269
- }
262
+ ### Case-Insensitive Matching
270
263
 
271
- type Post {
272
- id: ID!
273
- title: String!
274
- comments: [Comment]
275
- }
264
+ Enable [`caseInsensitiveIgnore`](#depthlimitoptions) to match string ignore rules regardless of casing:
276
265
 
277
- type Comment {
278
- id: ID!
279
- body: String!
280
- author: User
281
- }
266
+ ```ts
267
+ const rule = depthLimit(5, {
268
+ caseInsensitiveIgnore: true,
269
+ ignore: ["metadata"], // Matches "metadata", "Metadata", "METADATA", etc.
270
+ });
282
271
  ```
283
272
 
284
- | Query | Depth | Allowed (max 3)? |
285
- | :--- | :---: | :---: |
286
- | `{ user { id } }` | 1 | ✅ |
287
- | `{ user { friends { name } } }` | 2 | ✅ |
288
- | `{ user { posts { comments { body } } } }` | 3 | ✅ |
289
- | `{ user { posts { comments { author { name } } } } }` | 4 | ❌ |
290
- | `{ user { friends { friends { friends { name } } } } }` | 4 | ❌ |
273
+ ### Introspection Handling
291
274
 
292
- ## Integration Examples
275
+ By default, only `__typename` is ignored during depth calculation. The `__schema` and `__type` fields are **counted** toward depth, protecting against deeply nested introspection queries.
293
276
 
294
- ### Apollo Server
277
+ Note that scalar introspection fields like `__typename` never increment depth on their own (only composite fields with nested selections do). The `ignoreIntrospection` setting controls whether these fields are _recognized as ignored_, which matters for the `ignoreMode` behavior when they appear within composite selections.
295
278
 
296
- ```typescript
297
- import { ApolloServer } from '@apollo/server';
298
- import { depthLimit } from 'graphql-query-depth-limit-esm';
279
+ ```ts
280
+ // Default: only __typename is ignored
281
+ const rule = depthLimit(10);
299
282
 
300
- const server = new ApolloServer({
301
- schema,
302
- validationRules: [depthLimit(10)],
303
- });
283
+ // Ignore all introspection fields and skip their entire subtree
284
+ const rule = depthLimit(10, { ignoreIntrospection: "all" });
285
+
286
+ // Count all fields toward depth, including __typename
287
+ const rule = depthLimit(10, { ignoreIntrospection: "none" });
304
288
  ```
305
289
 
306
- ### Express GraphQL
290
+ > **Note:** When `ignoreIntrospection: "all"` is set, introspection fields and their entire subtree are always skipped — regardless of `ignoreMode`. This is a security hardening mode that completely eliminates introspection from depth calculation.
307
291
 
308
- ```typescript
309
- import { graphqlHTTP } from 'express-graphql';
310
- import { depthLimit } from 'graphql-query-depth-limit-esm';
292
+ ### Depth Callback
311
293
 
312
- app.use(
313
- '/graphql',
314
- graphqlHTTP({
315
- schema,
316
- validationRules: [depthLimit(10)],
317
- }),
318
- );
319
- ```
294
+ Monitor query depths with an optional [`callback`](#depthcallback) that receives per-operation depth results:
320
295
 
321
- ### GraphQL Yoga
296
+ ```ts
297
+ const rule = depthLimit(10, {}, (depths) => {
298
+ // { "GetUser": 3, "ListPosts": 5, "anonymous": 2 }
299
+ for (const [operation, depth] of Object.entries(depths)) {
300
+ console.log(`Operation "${operation}" has depth ${depth}`);
301
+ }
302
+ });
303
+ ```
322
304
 
323
- ```typescript
324
- import { createYoga } from 'graphql-yoga';
325
- import { depthLimit } from 'graphql-query-depth-limit-esm';
305
+ If you do not need options, you can pass the callback as the second argument:
326
306
 
327
- const yoga = createYoga({
328
- schema,
329
- validationRules: [depthLimit(10)],
307
+ ```ts
308
+ const rule = depthLimit(10, (depths) => {
309
+ console.log(depths);
330
310
  });
331
311
  ```
332
312
 
313
+ > **Note:** By default, providing a callback disables short-circuiting so the engine can report accurate maximum depths. If you explicitly set `shortCircuit: true`, traversal still bails early and reported depths are lower bounds (`"at least N"`). Without a callback, the engine short-circuits by default for maximum performance.
314
+
333
315
  ## API Reference
334
316
 
335
317
  ### `depthLimit(maxDepth, options?, callback?)`
336
318
 
337
319
  Creates a GraphQL validation rule that limits query depth.
338
320
 
339
- - `maxDepth` (number, **required**) - Maximum allowed depth for queries
340
- - `options` (object, optional):
341
- - `caseInsensitiveIgnore` (boolean, optional) - Enable case-insensitive matching for string-based ignore rules. Default: `false`
342
- - `ignore` (IgnoreRule[], optional) - Fields to exclude from depth calculation
343
- - String: Exact field name match (case-sensitive by default)
344
- - RegExp: Pattern matching
345
- - Function: `(fieldName: string) => boolean` - Custom logic
346
- - `useDirective` (boolean, optional) - Enable reading depth limits from `@depth` directive on fields. Default: `false`
347
- - `callback` (DepthCallback, optional) - Called after validation with depth information
348
- - Receives object mapping operation names to their depths
321
+ #### Parameters
349
322
 
350
- **Returns:** `ValidationRule` - GraphQL validation rule function
323
+ | Parameter | Type | Required | Description |
324
+ |---|---|---|---|
325
+ | `maxDepth` | `number` | Yes | Maximum allowed depth (non-negative integer) |
326
+ | `options` | [`DepthLimitOptions`](#depthlimitoptions) | No | Configuration options |
327
+ | `callback` | [`DepthCallback`](#depthcallback) | No | Called with per-operation depth results |
351
328
 
352
- ### Ignore Rules
329
+ #### Returns
353
330
 
354
- Control which fields are excluded from depth calculation:
331
+ A GraphQL `ValidationRule` function.
355
332
 
356
- ```typescript
357
- import { depthLimit } from 'graphql-query-depth-limit-esm';
333
+ #### Throws
358
334
 
359
- const server = new ApolloServer({
360
- schema,
361
- validationRules: [
362
- depthLimit(5, {
363
- ignore: [
364
- 'friends', // String: exact match
365
- /^metadata/, // RegExp: pattern match
366
- (fieldName) => fieldName.includes('__'), // Function: custom logic
367
- ],
368
- }),
369
- ],
370
- });
335
+ - `Error` if `maxDepth` is not a non-negative integer
336
+ - `TypeError` if options, callback, or ignore rules are invalid
337
+
338
+ ### `depthDirectiveTypeDefs`
339
+
340
+ GraphQL SDL string defining the `@depth` directive. Include this in your schema type definitions when using `{ useDirective: true }`.
341
+
342
+ ```graphql
343
+ directive @depth(max: Int!) on FIELD_DEFINITION
371
344
  ```
372
345
 
373
- ## Security Considerations
346
+ ### `DepthLimitOptions`
374
347
 
375
- This library is designed to protect against denial-of-service (DoS) attacks via deeply nested queries. Recent updates have strengthened the implementation to handle edge cases correctly:
348
+ | Property | Type | Default | Description |
349
+ |---|---|---|---|
350
+ | `caseInsensitiveIgnore` | `boolean` | `false` | Case-insensitive matching for string ignore rules |
351
+ | `directiveMode` | [`DirectiveMode`](#directivemode) | `"cap"` | Controls how `@depth` directives interact with the global `maxDepth` |
352
+ | `ignore` | [`IgnoreRule \| IgnoreRule[]`](#ignorerule) | `undefined` | Fields to skip during depth calculation |
353
+ | `ignoreIntrospection` | [`IntrospectionMode`](#introspectionmode) | `"typename"` | Controls which introspection fields are ignored |
354
+ | `ignoreMode` | [`IgnoreMode`](#ignoremode) | `"exclude"` | Controls whether ignored fields skip their entire subtree or only the depth increment |
355
+ | `limitIgnoredRecursion` | `boolean` | `false` | In `ignoreMode: "exclude"`, increments depth for repeated ignored fields on the same path to prevent unbounded recursion |
356
+ | `shortCircuit` | `boolean` | `undefined` (auto) | Auto: `true` without callback, `false` with callback. Set explicitly to force early bailout or full traversal |
357
+ | `useDirective` | `boolean` | `false` | Read `@depth(max: Int!)` directives from field definitions. Requires a schema in validation context; without one, directive resolution falls back to the global `maxDepth`. |
376
358
 
377
- ### Correctly Handled Scenarios
359
+ ### `DirectiveMode`
378
360
 
379
- ✅ **Fragments at Different Depths** - Fragments used multiple times at different nesting levels are calculated correctly. Each usage is evaluated independently to prevent depth limit bypass.
361
+ ```ts
362
+ type DirectiveMode = "cap" | "override";
363
+ ```
380
364
 
381
- ```graphql
382
- fragment UserInfo on User {
383
- posts { title } # Adds 2 levels
384
- }
365
+ - `"cap"` — directives can only tighten the limit below the global `maxDepth` (secure default)
366
+ - `"override"` the first directive replaces the global limit for its subtree
385
367
 
386
- query {
387
- user {
388
- ...UserInfo # Depth: 1 + 2 = 3
389
- friends {
390
- ...UserInfo # Depth: 2 + 2 = 4 ✓ (calculated independently)
391
- }
392
- }
393
- }
368
+ ### `IgnoreMode`
369
+
370
+ ```ts
371
+ type IgnoreMode = "exclude" | "skip";
394
372
  ```
395
373
 
396
- ✅ **Circular Fragment Detection** - Circular fragment references are detected per-path to prevent infinite recursion while maintaining accurate depth calculation.
374
+ - `"exclude"` skip the depth increment but still traverse children (secure default)
375
+ - `"skip"` — skip the field and its entire subtree
397
376
 
398
- **Introspection Fields** - All introspection fields (`__typename`, `__schema`, `__type`, etc.) are automatically excluded from depth calculation.
377
+ ### `IntrospectionMode`
399
378
 
400
- ### Known Limitations
379
+ ```ts
380
+ type IntrospectionMode = "all" | "none" | "typename";
381
+ ```
401
382
 
402
- ⚠️ **Variables in @depth Directive** - The `@depth` directive only supports integer literals. Variables are not supported because their values are not available during the validation phase.
383
+ - `"all"` ignore every `__`-prefixed field (`__typename`, `__schema`, `__type`, etc.)
384
+ - `"typename"` — only ignore `__typename` (secure default)
385
+ - `"none"` — count all introspection fields toward depth
403
386
 
404
- ```graphql
405
- # ✅ Works
406
- type Query {
407
- user: User @depth(max: 5)
408
- }
387
+ ### `IgnoreRule`
409
388
 
410
- # ❌ Not supported - will fall back to global depth limit
411
- type Query {
412
- user: User @depth(max: $maxDepth)
413
- }
389
+ ```ts
390
+ type IgnoreRule = string | RegExp | ((fieldName: string) => boolean);
414
391
  ```
415
392
 
416
- ⚠️ **Case-Sensitive Field Names** - By default, field name matching is case-sensitive (as per GraphQL specification). Use the `caseInsensitiveIgnore` option if you need case-insensitive matching for ignore rules.
393
+ ### `DepthCallback`
394
+
395
+ ```ts
396
+ type DepthCallback = (depths: Record<string, number>) => void;
397
+ ```
417
398
 
418
- ```typescript
419
- // Case-sensitive (default)
420
- depthLimit(5, { ignore: ['friends'] }) // Only matches 'friends', not 'Friends'
399
+ ### `ERROR_CODES`
421
400
 
422
- // Case-insensitive
423
- depthLimit(5, {
424
- caseInsensitiveIgnore: true,
425
- ignore: ['friends'] // Matches 'friends', 'Friends', 'FRIENDS', etc.
426
- })
401
+ ```ts
402
+ const ERROR_CODES = {
403
+ IGNORE_RULE_ERROR: "IGNORE_RULE_ERROR",
404
+ QUERY_TOO_DEEP: "QUERY_TOO_DEEP",
405
+ } as const;
427
406
  ```
428
407
 
429
- ### Security Best Practices
408
+ - `QUERY_TOO_DEEP`: query depth exceeded the allowed limit
409
+ - `IGNORE_RULE_ERROR`: a user-provided ignore rule (`RegExp` or function) threw at validation time
430
410
 
431
- 1. **Always use depth limiting in production** - Even if you think your schema is safe
432
- 2. **Combine with complexity limiting** - Use both for comprehensive protection
433
- 3. **Set reasonable limits** - Balance security with legitimate use cases (typically 5-15)
434
- 4. **Monitor query depths** - Use the callback to log and alert on suspicious patterns
435
- 5. **Use field-specific limits** - Apply stricter limits to recursive fields via `@depth` directive
411
+ ## Error Extensions
436
412
 
437
- ## Why Depth Limiting?
413
+ Depth violations include structured extensions for programmatic access:
438
414
 
439
- Deeply nested queries can cause:
415
+ ```json
416
+ {
417
+ "message": "'GetUser' has depth 8 which exceeds maximum allowed depth of 5 (at user.friends.friends)",
418
+ "extensions": {
419
+ "code": "QUERY_TOO_DEEP",
420
+ "depth": 8,
421
+ "maxDepth": 5,
422
+ "path": ["user", "friends", "friends"],
423
+ "shortCircuit": false
424
+ }
425
+ }
426
+ ```
440
427
 
441
- - **Performance issues** - Exponential data fetching (N+1 problem amplified)
442
- - **DoS attacks** - Server resource exhaustion
443
- - **Database overload** - Too many nested joins
428
+ | Field | Type | Description |
429
+ |---|---|---|
430
+ | `code` | `string` | Always `"QUERY_TOO_DEEP"` |
431
+ | `depth` | `number` | The query's calculated depth. If `shortCircuit` is `true`, this is the depth where the violation was detected, which may not be the absolute maximum depth. |
432
+ | `maxDepth` | `number` | The maximum allowed depth that was exceeded |
433
+ | `path` | `string[]` | Field path from the operation root to the violation point (uses aliases when present) |
434
+ | `shortCircuit` | `boolean` | Whether the engine short-circuited. If `true`, `depth` is a lower bound ("at least N"), including when `shortCircuit: true` is explicitly forced with a callback. |
435
+
436
+ If an ignore rule throws, validation reports:
437
+
438
+ ```json
439
+ {
440
+ "message": "Ignore rule function threw for field \"friends\": boom",
441
+ "extensions": {
442
+ "code": "IGNORE_RULE_ERROR"
443
+ }
444
+ }
445
+ ```
446
+
447
+ ## How Depth Is Calculated
444
448
 
445
- ### Example Attack
449
+ - Depth increments for each **composite field** (objects, interfaces, unions)
450
+ - **Scalar and enum fields** do not increment depth
451
+ - **Fragment spreads** contribute the depth of their expanded selections
452
+ - **Inline fragments** contribute the depth of their selections
453
+ - **`__typename`** is ignored by default (configurable via [`ignoreIntrospection`](#introspectionmode))
454
+ - **`__schema` and `__type`** are counted toward depth by default
455
+ - **Circular fragment references** are detected per-path and stop recursion
456
+
457
+ ### Example
446
458
 
447
459
  ```graphql
448
- query Attack {
460
+ # Depth: 0
461
+ query {
462
+ # Depth: 1
449
463
  user {
450
- friends {
451
- friends {
452
- friends {
453
- friends {
454
- # ... 100 levels deep
455
- # This could fetch millions of records!
456
- }
457
- }
464
+ name # Depth: 1 (scalar, no increment)
465
+ # Depth: 2
466
+ posts {
467
+ title # Depth: 2 (scalar, no increment)
468
+ # Depth: 3
469
+ comments {
470
+ text # Depth: 3 (scalar, no increment)
458
471
  }
459
472
  }
460
473
  }
461
474
  }
475
+ # Maximum depth: 3
462
476
  ```
463
477
 
464
- Without depth limiting, this query could:
465
- - Fetch 10^100 user records (if each user has 10 friends)
466
- - Exhaust server memory
467
- - Crash your database
478
+ ## Migrating from v1 to v2
468
479
 
469
- **Depth limiting stops this attack during validation.**
480
+ v2 introduces three **breaking changes** with more secure defaults:
470
481
 
471
- ## Comparison with Complexity Limiting
482
+ ### 1. Introspection fields are no longer fully ignored
472
483
 
473
- | Feature | Depth Limit | Complexity Limit |
474
- | :--- | :--- | :--- |
475
- | **Measures** | Nesting level | Total operation cost |
476
- | **Prevents** | Deep recursion attacks | Wide/expensive queries |
477
- | **Example Attack** | `user { friends { friends { ... } } }` | `users(limit: 9999) { id name email ... }` |
478
- | **Use Case** | Recursive relationships | Pagination/list queries |
484
+ **v1:** All `__`-prefixed fields (`__typename`, `__schema`, `__type`) were ignored during depth calculation.
479
485
 
480
- **Best Practice:** Use **both** depth and complexity limiting for comprehensive protection.
486
+ **v2:** Only `__typename` is ignored by default. `__schema` and `__type` now count toward depth, preventing deeply nested introspection queries from bypassing the depth limit.
481
487
 
482
- ---
488
+ **To restore v1 behavior:**
483
489
 
484
- ## Requirements
490
+ ```ts
491
+ depthLimit(10, { ignoreIntrospection: "all" });
492
+ ```
493
+
494
+ ### 2. `@depth` directives can no longer relax the global limit
495
+
496
+ **v1:** A `@depth(max: 50)` directive could override a global `maxDepth: 10`, allowing that subtree to nest up to 50 levels deep.
485
497
 
486
- - Node.js 18+
487
- - GraphQL 16+
498
+ **v2:** By default (`directiveMode: "cap"`), directives can only **tighten** below the global max. A `@depth(max: 50)` with `maxDepth: 10` caps at 10.
488
499
 
489
- ## Related Packages
500
+ **To restore v1 behavior:**
501
+
502
+ ```ts
503
+ depthLimit(10, { directiveMode: "override", useDirective: true });
504
+ ```
490
505
 
491
- - [graphql-query-complexity-esm](https://github.com/lafittemehdy/graphql-query-complexity-esm) - Query complexity limiting
492
- - [graphql-rate-limit-redis-esm](https://github.com/lafittemehdy/graphql-rate-limit-redis-esm) - Rate limiting with Redis
506
+ ### 3. Short-circuit traversal on violations
507
+
508
+ **v1:** The engine always traversed the full query, even after detecting a violation.
509
+
510
+ **v2:** When no callback is provided, the engine stops traversal immediately on the first violation. This is a performance improvement and DoS protection — a deeply nested query with a small `maxDepth` no longer burns CPU traversing thousands of levels.
511
+
512
+ **Impact:** This is transparent to most users. The only observable difference is that error messages may report the depth at the first violation rather than the deepest violation when multiple branches exceed the limit. If you need the true maximum depth, provide a callback.
493
513
 
494
514
  ## License
495
515
 
496
- MIT License. This code is free to use. It has no opinions. You, I presume, do. Please use them.
516
+ [MIT](LICENSE)
517
+