graphql-query-depth-limit-esm 0.1.0 → 2.0.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/README.md CHANGED
@@ -1,496 +1,524 @@
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/)
10
+ [![Provenance](https://img.shields.io/badge/provenance-verified-brightgreen?logo=npm)](https://www.npmjs.com/package/graphql-query-depth-limit-esm)
6
11
 
7
- Protect your GraphQL API by limiting query depth before execution.
12
+ 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
13
 
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.
14
+ 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
15
 
11
16
  ## Features
12
17
 
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
18
+ - **`@depth` directive** per-field depth overrides via schema directives
19
+ - **Alias-aware paths** violation paths use aliases when present, matching the response shape
20
+ - **Configurable introspection handling** control whether `__schema` and `__type` count toward depth
21
+ - **Callback support** optional callback reports per-operation depth for monitoring
22
+ - **Fragment-safe** handles named fragments, inline fragments, and circular fragment detection
23
+ - **Ignore rules** skip fields by name, pattern, or custom function
24
+ - **Interface directive inheritance** `@depth` directives on interface fields apply to all implementors
25
+ - **Short-circuit traversal** — stops immediately on first violation when no callback is provided
26
+ - **Validation rule** — integrates directly with `graphql`'s `validate()` function
27
+ - **Zero runtime dependencies** — only `graphql ^16` as a peer dependency
20
28
 
21
29
  ## Installation
22
30
 
23
31
  ```bash
24
- npm install graphql-query-depth-limit-esm
32
+ pnpm add graphql-query-depth-limit-esm graphql
33
+ ```
34
+
35
+ ```bash
36
+ npm install graphql-query-depth-limit-esm graphql
37
+ ```
38
+
39
+ ```bash
40
+ yarn add graphql-query-depth-limit-esm graphql
25
41
  ```
26
42
 
27
43
  ## Quick Start
28
44
 
29
- ### Apollo Server Integration
45
+ ```ts
46
+ import { specifiedRules, validate } from "graphql";
47
+ import { depthLimit } from "graphql-query-depth-limit-esm";
30
48
 
31
- ```typescript
32
- import { ApolloServer } from '@apollo/server';
33
- import { startStandaloneServer } from '@apollo/server/standalone';
34
- import { depthLimit } from 'graphql-query-depth-limit-esm';
49
+ const errors = validate(schema, document, [
50
+ ...specifiedRules,
51
+ depthLimit(7, { useDirective: true }),
52
+ ]);
53
+ ```
35
54
 
36
- // Step 1: Define your schema
37
- const typeDefs = `#graphql
38
- directive @depth(max: Int!) on FIELD_DEFINITION
55
+ When calling `validate()` directly, include `specifiedRules` unless you intentionally want to replace GraphQL's default validation rules.
39
56
 
40
- type Query {
41
- # Regular user endpoint with depth limit of 3
42
- user(id: ID!): User @depth(max: 3)
57
+ 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).
43
58
 
44
- # Admin endpoint with higher depth limit
45
- adminUser(id: ID!): User @depth(max: 10)
59
+ ### Global Limit with Per-Field Overrides
46
60
 
47
- # Public endpoint without directive (uses global limit)
48
- users: [User!]!
49
- }
61
+ Include [`depthDirectiveTypeDefs`](#depthdirectivetypedefs) in your schema to declare the `@depth` directive, then annotate individual fields:
50
62
 
51
- type User {
52
- id: ID!
53
- name: String!
54
- email: String!
55
- friends: [User!]!
56
- posts: [Post!]!
57
- }
63
+ ```graphql
64
+ # depthDirectiveTypeDefs provides this automatically:
65
+ # directive @depth(max: Int!) on FIELD_DEFINITION
58
66
 
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
- };
67
+ type User {
68
+ name: String!
69
+ profile: Profile!
106
70
 
107
- // Step 3: Create and start the server with depth limiting
108
- const server = new ApolloServer({
109
- typeDefs,
71
+ # Self-referential allow up to 3 levels of nesting
72
+ friends: [User!]! @depth(max: 3)
73
+ }
74
+
75
+ type Post {
76
+ title: String!
77
+ author: User!
78
+
79
+ # Recursive comments — allow up to 5 levels deep
80
+ comments: [Comment!]! @depth(max: 5)
81
+ }
82
+
83
+ type Comment {
84
+ text: String!
85
+ replies: [Comment!]! @depth(max: 4)
86
+ }
87
+ ```
88
+
89
+ Build the schema with the directive type defs, then enable directive support via [`depthLimit()`](#depthlimitmaxdepth-options-callback):
90
+
91
+ ```ts
92
+ import { makeExecutableSchema } from "@graphql-tools/schema";
93
+ import { depthDirectiveTypeDefs, depthLimit } from "graphql-query-depth-limit-esm";
94
+ import { specifiedRules, validate } from "graphql";
95
+
96
+ const schema = makeExecutableSchema({
97
+ typeDefs: [depthDirectiveTypeDefs, yourTypeDefs],
110
98
  resolvers,
111
- validationRules: [
112
- // Global depth limit of 5 with directive support enabled
113
- depthLimit(5, { useDirective: true }),
114
- ],
115
99
  });
116
100
 
117
- const { url } = await startStandaloneServer(server, {
118
- listen: { port: 4000 },
119
- });
101
+ // Global max of 7 @depth directives can tighten but never exceed this limit
102
+ const errors = validate(schema, document, [
103
+ ...specifiedRules,
104
+ depthLimit(7, { useDirective: true }),
105
+ ]);
106
+ ```
107
+
108
+ ### Nested Directives
109
+
110
+ 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:
111
+
112
+ ```graphql
113
+ type Post {
114
+ # 3 levels from here (absolute max = currentDepth + 3)
115
+ comments: [Comment] @depth(max: 3)
116
+ }
120
117
 
121
- console.log(`🚀 Server ready at: ${url}`);
118
+ type Comment {
119
+ text: String
120
+ # Wants 5 levels, but capped by the ancestor's limit
121
+ replies: [Comment] @depth(max: 5)
122
+ }
122
123
  ```
123
124
 
124
- ## How It Works
125
+ This ensures a parent directive remains a hard ceiling for its entire subtree, preventing deeply nested child directives from punching through ancestor limits.
126
+
127
+ > **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.
128
+
129
+ ### Interface Directive Inheritance
125
130
 
126
- The library calculates query depth during GraphQL validation (before execution). Deeply nested queries are rejected before hitting your business logic.
131
+ 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:
127
132
 
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
133
+ ```graphql
134
+ interface Node {
135
+ children: [Node!]! @depth(max: 3)
136
+ }
137
+
138
+ type TreeNode implements Node {
139
+ children: [Node!]! # Inherits @depth(max: 3) from Node interface
140
+ label: String!
141
+ }
142
+ ```
133
143
 
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.
144
+ When multiple interfaces define `@depth` on the same field, the **strictest (lowest)** limit is used.
135
145
 
136
- ---
146
+ ### Why Explicit Opt-In?
137
147
 
138
- ## Usage Examples
148
+ The [`useDirective`](#depthlimitoptions) option is `false` by default. This is intentional:
149
+
150
+ - **Follows GraphQL Convention.** Standard rules like `NoUnusedFragmentsRule` or `KnownDirectivesRule` are configured explicitly, and this library follows that same pattern for consistency.
151
+ - **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.
152
+ - **Predictable behavior.** Enabling directive support is a deliberate choice, making it clear in code review that per-field overrides are in play.
153
+
154
+ ## Usage
139
155
 
140
156
  ### Basic Depth Limiting
141
157
 
142
- ```typescript
143
- import { depthLimit } from 'graphql-query-depth-limit-esm';
144
- import { ApolloServer } from '@apollo/server';
158
+ For straightforward global limiting without per-field overrides, call [`depthLimit()`](#depthlimitmaxdepth-options-callback) with only a maximum depth:
159
+
160
+ ```ts
161
+ import { depthLimit } from "graphql-query-depth-limit-esm";
162
+ import { specifiedRules, validate } from "graphql";
163
+
164
+ // Reject queries deeper than 10 levels
165
+ const rule = depthLimit(10);
166
+ const errors = validate(schema, document, [...specifiedRules, rule]);
167
+ ```
168
+
169
+ ### With Apollo Server
170
+
171
+ ```ts
172
+ import { ApolloServer } from "@apollo/server";
173
+ import { depthLimit } from "graphql-query-depth-limit-esm";
145
174
 
146
175
  const server = new ApolloServer({
147
176
  schema,
148
- validationRules: [depthLimit(10)], // Maximum depth of 10
177
+ validationRules: [depthLimit(7, { useDirective: true })],
149
178
  });
150
179
  ```
151
180
 
152
- ### With Ignore Rules
181
+ ### With Yoga
153
182
 
154
- Skip specific fields from depth calculation:
183
+ ```ts
184
+ import { createYoga } from "graphql-yoga";
185
+ import { depthLimit } from "graphql-query-depth-limit-esm";
155
186
 
156
- ```typescript
157
- import { depthLimit } from 'graphql-query-depth-limit-esm';
158
-
159
- const server = new ApolloServer({
187
+ const yoga = createYoga({
160
188
  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
- }),
189
+ plugins: [
190
+ {
191
+ onValidate({ addValidationRule }) {
192
+ addValidationRule(depthLimit(7, { useDirective: true }));
193
+ },
194
+ },
169
195
  ],
170
196
  });
171
197
  ```
172
198
 
173
- ### With Callback
199
+ ### Ignore Rules
174
200
 
175
- Monitor query depths:
201
+ Skip specific fields during depth calculation using strings, regular expressions, or custom functions. See [`IgnoreRule`](#ignorerule) for the full type definition.
176
202
 
177
- ```typescript
178
- import { depthLimit } from 'graphql-query-depth-limit-esm';
203
+ ```ts
204
+ const rule = depthLimit(5, {
205
+ ignore: [
206
+ // Exact field name match
207
+ "metadata",
179
208
 
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
- }),
209
+ // Regular expression pattern
210
+ /.*Connection$/,
211
+
212
+ // Custom function
213
+ (fieldName) => fieldName.startsWith("internal"),
187
214
  ],
188
215
  });
189
216
  ```
190
217
 
191
- ### With @depth Directive
218
+ > **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
219
 
193
- Apply field-specific depth limits using the `@depth` directive:
220
+ ### Ignore Mode
194
221
 
195
- ```typescript
196
- import { depthLimit } from 'graphql-query-depth-limit-esm';
222
+ By default, ignored fields only skip the depth increment while still traversing children. Use [`ignoreMode`](#depthlimitoptions) to control this behavior:
197
223
 
198
- // First, add the directive to your schema
199
- const typeDefs = `#graphql
200
- directive @depth(max: Int!) on FIELD_DEFINITION
224
+ ```ts
225
+ // Default (secure): skip only the depth increment, still traverse children
226
+ const rule = depthLimit(5, { ignore: ["metadata"], ignoreMode: "exclude" });
201
227
 
202
- type Query {
203
- publicUser(id: ID!): User @depth(max: 2)
204
- adminUser(id: ID!): User @depth(max: 10)
205
- }
228
+ // Optional: skip the field and its entire subtree (use with caution)
229
+ const rule = depthLimit(5, { ignore: ["metadata"], ignoreMode: "skip" });
230
+ ```
206
231
 
207
- type User {
208
- id: ID!
209
- name: String!
210
- friends: [User]
211
- }
212
- `;
232
+ 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
233
 
214
- const server = new ApolloServer({
215
- typeDefs,
216
- resolvers,
217
- validationRules: [
218
- depthLimit(5, { useDirective: true }),
219
- ],
234
+ 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`.
235
+
236
+ ### Recursive Ignore Guard
237
+
238
+ Enable [`limitIgnoredRecursion`](#depthlimitoptions) to prevent unbounded traversal when using `ignoreMode: "exclude"` on recursive fields:
239
+
240
+ ```ts
241
+ const rule = depthLimit(10, {
242
+ ignore: ["friends"],
243
+ ignoreMode: "exclude",
244
+ limitIgnoredRecursion: true,
220
245
  });
221
246
  ```
222
247
 
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
248
+ When enabled, the first ignored field occurrence on a path is still excluded, but repeated occurrences on the same path increment depth normally.
228
249
 
229
- ## How Depth is Calculated
250
+ ### Short-Circuit Control
230
251
 
231
- Depth is measured by counting nested field selections:
252
+ By default, short-circuit behavior is auto-detected:
232
253
 
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
- ```
254
+ - No callback: short-circuits on first violation (`shortCircuit: true`)
255
+ - Callback provided: traverses full tree for exact depth (`shortCircuit: false`)
247
256
 
248
- ### What Doesn't Add Depth
257
+ You can override this explicitly:
249
258
 
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
259
+ ```ts
260
+ // Force full traversal even without a callback
261
+ const exactRule = depthLimit(5, { shortCircuit: false });
255
262
 
256
- ## Query Depth Examples
263
+ // Force early bailout even with a callback
264
+ const fastRule = depthLimit(5, { shortCircuit: true }, (depths) => {
265
+ console.log(depths);
266
+ });
267
+ ```
257
268
 
258
- **Schema:**
259
- ```graphql
260
- type Query {
261
- user(id: ID!): User
262
- }
269
+ ### Case-Insensitive Matching
263
270
 
264
- type User {
265
- id: ID!
266
- name: String!
267
- friends: [User]
268
- posts: [Post]
269
- }
270
-
271
- type Post {
272
- id: ID!
273
- title: String!
274
- comments: [Comment]
275
- }
271
+ Enable [`caseInsensitiveIgnore`](#depthlimitoptions) to match string ignore rules regardless of casing:
276
272
 
277
- type Comment {
278
- id: ID!
279
- body: String!
280
- author: User
281
- }
273
+ ```ts
274
+ const rule = depthLimit(5, {
275
+ caseInsensitiveIgnore: true,
276
+ ignore: ["metadata"], // Matches "metadata", "Metadata", "METADATA", etc.
277
+ });
282
278
  ```
283
279
 
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 | ❌ |
280
+ ### Introspection Handling
291
281
 
292
- ## Integration Examples
282
+ 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
283
 
294
- ### Apollo Server
284
+ 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
285
 
296
- ```typescript
297
- import { ApolloServer } from '@apollo/server';
298
- import { depthLimit } from 'graphql-query-depth-limit-esm';
286
+ ```ts
287
+ // Default: only __typename is ignored
288
+ const rule = depthLimit(10);
299
289
 
300
- const server = new ApolloServer({
301
- schema,
302
- validationRules: [depthLimit(10)],
303
- });
290
+ // Ignore all introspection fields and skip their entire subtree
291
+ const rule = depthLimit(10, { ignoreIntrospection: "all" });
292
+
293
+ // Count all fields toward depth, including __typename
294
+ const rule = depthLimit(10, { ignoreIntrospection: "none" });
304
295
  ```
305
296
 
306
- ### Express GraphQL
297
+ > **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
298
 
308
- ```typescript
309
- import { graphqlHTTP } from 'express-graphql';
310
- import { depthLimit } from 'graphql-query-depth-limit-esm';
299
+ ### Depth Callback
311
300
 
312
- app.use(
313
- '/graphql',
314
- graphqlHTTP({
315
- schema,
316
- validationRules: [depthLimit(10)],
317
- }),
318
- );
319
- ```
301
+ Monitor query depths with an optional [`callback`](#depthcallback) that receives per-operation depth results:
320
302
 
321
- ### GraphQL Yoga
303
+ ```ts
304
+ const rule = depthLimit(10, {}, (depths) => {
305
+ // { "GetUser": 3, "ListPosts": 5, "anonymous": 2 }
306
+ for (const [operation, depth] of Object.entries(depths)) {
307
+ console.log(`Operation "${operation}" has depth ${depth}`);
308
+ }
309
+ });
310
+ ```
322
311
 
323
- ```typescript
324
- import { createYoga } from 'graphql-yoga';
325
- import { depthLimit } from 'graphql-query-depth-limit-esm';
312
+ If you do not need options, you can pass the callback as the second argument:
326
313
 
327
- const yoga = createYoga({
328
- schema,
329
- validationRules: [depthLimit(10)],
314
+ ```ts
315
+ const rule = depthLimit(10, (depths) => {
316
+ console.log(depths);
330
317
  });
331
318
  ```
332
319
 
320
+ > **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.
321
+
333
322
  ## API Reference
334
323
 
335
324
  ### `depthLimit(maxDepth, options?, callback?)`
336
325
 
337
326
  Creates a GraphQL validation rule that limits query depth.
338
327
 
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
328
+ #### Parameters
349
329
 
350
- **Returns:** `ValidationRule` - GraphQL validation rule function
330
+ | Parameter | Type | Required | Description |
331
+ |---|---|---|---|
332
+ | `maxDepth` | `number` | Yes | Maximum allowed depth (non-negative integer) |
333
+ | `options` | [`DepthLimitOptions`](#depthlimitoptions) | No | Configuration options |
334
+ | `callback` | [`DepthCallback`](#depthcallback) | No | Called with per-operation depth results |
351
335
 
352
- ### Ignore Rules
336
+ #### Returns
353
337
 
354
- Control which fields are excluded from depth calculation:
338
+ A GraphQL `ValidationRule` function.
355
339
 
356
- ```typescript
357
- import { depthLimit } from 'graphql-query-depth-limit-esm';
340
+ #### Throws
358
341
 
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
- });
342
+ - `Error` if `maxDepth` is not a non-negative integer
343
+ - `TypeError` if options, callback, or ignore rules are invalid
344
+
345
+ ### `depthDirectiveTypeDefs`
346
+
347
+ GraphQL SDL string defining the `@depth` directive. Include this in your schema type definitions when using `{ useDirective: true }`.
348
+
349
+ ```graphql
350
+ directive @depth(max: Int!) on FIELD_DEFINITION
371
351
  ```
372
352
 
373
- ## Security Considerations
353
+ ### `DepthLimitOptions`
374
354
 
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:
355
+ | Property | Type | Default | Description |
356
+ |---|---|---|---|
357
+ | `caseInsensitiveIgnore` | `boolean` | `false` | Case-insensitive matching for string ignore rules |
358
+ | `directiveMode` | [`DirectiveMode`](#directivemode) | `"cap"` | Controls how `@depth` directives interact with the global `maxDepth` |
359
+ | `ignore` | [`IgnoreRule \| IgnoreRule[]`](#ignorerule) | `undefined` | Fields to skip during depth calculation |
360
+ | `ignoreIntrospection` | [`IntrospectionMode`](#introspectionmode) | `"typename"` | Controls which introspection fields are ignored |
361
+ | `ignoreMode` | [`IgnoreMode`](#ignoremode) | `"exclude"` | Controls whether ignored fields skip their entire subtree or only the depth increment |
362
+ | `limitIgnoredRecursion` | `boolean` | `false` | In `ignoreMode: "exclude"`, increments depth for repeated ignored fields on the same path to prevent unbounded recursion |
363
+ | `shortCircuit` | `boolean` | `undefined` (auto) | Auto: `true` without callback, `false` with callback. Set explicitly to force early bailout or full traversal |
364
+ | `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
365
 
377
- ### Correctly Handled Scenarios
366
+ ### `DirectiveMode`
378
367
 
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.
368
+ ```ts
369
+ type DirectiveMode = "cap" | "override";
370
+ ```
380
371
 
381
- ```graphql
382
- fragment UserInfo on User {
383
- posts { title } # Adds 2 levels
384
- }
372
+ - `"cap"` — directives can only tighten the limit below the global `maxDepth` (secure default)
373
+ - `"override"` the first directive replaces the global limit for its subtree
385
374
 
386
- query {
387
- user {
388
- ...UserInfo # Depth: 1 + 2 = 3
389
- friends {
390
- ...UserInfo # Depth: 2 + 2 = 4 ✓ (calculated independently)
391
- }
392
- }
393
- }
375
+ ### `IgnoreMode`
376
+
377
+ ```ts
378
+ type IgnoreMode = "exclude" | "skip";
394
379
  ```
395
380
 
396
- ✅ **Circular Fragment Detection** - Circular fragment references are detected per-path to prevent infinite recursion while maintaining accurate depth calculation.
381
+ - `"exclude"` skip the depth increment but still traverse children (secure default)
382
+ - `"skip"` — skip the field and its entire subtree
397
383
 
398
- **Introspection Fields** - All introspection fields (`__typename`, `__schema`, `__type`, etc.) are automatically excluded from depth calculation.
384
+ ### `IntrospectionMode`
399
385
 
400
- ### Known Limitations
386
+ ```ts
387
+ type IntrospectionMode = "all" | "none" | "typename";
388
+ ```
401
389
 
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.
390
+ - `"all"` ignore every `__`-prefixed field (`__typename`, `__schema`, `__type`, etc.)
391
+ - `"typename"` — only ignore `__typename` (secure default)
392
+ - `"none"` — count all introspection fields toward depth
403
393
 
404
- ```graphql
405
- # ✅ Works
406
- type Query {
407
- user: User @depth(max: 5)
408
- }
394
+ ### `IgnoreRule`
409
395
 
410
- # ❌ Not supported - will fall back to global depth limit
411
- type Query {
412
- user: User @depth(max: $maxDepth)
413
- }
396
+ ```ts
397
+ type IgnoreRule = string | RegExp | ((fieldName: string) => boolean);
414
398
  ```
415
399
 
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.
400
+ ### `DepthCallback`
401
+
402
+ ```ts
403
+ type DepthCallback = (depths: Record<string, number>) => void;
404
+ ```
417
405
 
418
- ```typescript
419
- // Case-sensitive (default)
420
- depthLimit(5, { ignore: ['friends'] }) // Only matches 'friends', not 'Friends'
406
+ ### `ERROR_CODES`
421
407
 
422
- // Case-insensitive
423
- depthLimit(5, {
424
- caseInsensitiveIgnore: true,
425
- ignore: ['friends'] // Matches 'friends', 'Friends', 'FRIENDS', etc.
426
- })
408
+ ```ts
409
+ const ERROR_CODES = {
410
+ IGNORE_RULE_ERROR: "IGNORE_RULE_ERROR",
411
+ QUERY_TOO_DEEP: "QUERY_TOO_DEEP",
412
+ } as const;
427
413
  ```
428
414
 
429
- ### Security Best Practices
415
+ - `QUERY_TOO_DEEP`: query depth exceeded the allowed limit
416
+ - `IGNORE_RULE_ERROR`: a user-provided ignore rule (`RegExp` or function) threw at validation time
430
417
 
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
418
+ ## Error Extensions
436
419
 
437
- ## Why Depth Limiting?
420
+ Depth violations include structured extensions for programmatic access:
438
421
 
439
- Deeply nested queries can cause:
422
+ ```json
423
+ {
424
+ "message": "'GetUser' has depth 8 which exceeds maximum allowed depth of 5 (at user.friends.friends)",
425
+ "extensions": {
426
+ "code": "QUERY_TOO_DEEP",
427
+ "depth": 8,
428
+ "maxDepth": 5,
429
+ "path": ["user", "friends", "friends"],
430
+ "shortCircuit": false
431
+ }
432
+ }
433
+ ```
440
434
 
441
- - **Performance issues** - Exponential data fetching (N+1 problem amplified)
442
- - **DoS attacks** - Server resource exhaustion
443
- - **Database overload** - Too many nested joins
435
+ | Field | Type | Description |
436
+ |---|---|---|
437
+ | `code` | `string` | Always `"QUERY_TOO_DEEP"` |
438
+ | `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. |
439
+ | `maxDepth` | `number` | The maximum allowed depth that was exceeded |
440
+ | `path` | `string[]` | Field path from the operation root to the violation point (uses aliases when present) |
441
+ | `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. |
442
+
443
+ If an ignore rule throws, validation reports:
444
+
445
+ ```json
446
+ {
447
+ "message": "Ignore rule function threw for field \"friends\": boom",
448
+ "extensions": {
449
+ "code": "IGNORE_RULE_ERROR"
450
+ }
451
+ }
452
+ ```
453
+
454
+ ## How Depth Is Calculated
444
455
 
445
- ### Example Attack
456
+ - Depth increments for each **composite field** (objects, interfaces, unions)
457
+ - **Scalar and enum fields** do not increment depth
458
+ - **Fragment spreads** contribute the depth of their expanded selections
459
+ - **Inline fragments** contribute the depth of their selections
460
+ - **`__typename`** is ignored by default (configurable via [`ignoreIntrospection`](#introspectionmode))
461
+ - **`__schema` and `__type`** are counted toward depth by default
462
+ - **Circular fragment references** are detected per-path and stop recursion
463
+
464
+ ### Example
446
465
 
447
466
  ```graphql
448
- query Attack {
467
+ # Depth: 0
468
+ query {
469
+ # Depth: 1
449
470
  user {
450
- friends {
451
- friends {
452
- friends {
453
- friends {
454
- # ... 100 levels deep
455
- # This could fetch millions of records!
456
- }
457
- }
471
+ name # Depth: 1 (scalar, no increment)
472
+ # Depth: 2
473
+ posts {
474
+ title # Depth: 2 (scalar, no increment)
475
+ # Depth: 3
476
+ comments {
477
+ text # Depth: 3 (scalar, no increment)
458
478
  }
459
479
  }
460
480
  }
461
481
  }
482
+ # Maximum depth: 3
462
483
  ```
463
484
 
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
485
+ ## Migrating from v1 to v2
468
486
 
469
- **Depth limiting stops this attack during validation.**
487
+ v2 introduces three **breaking changes** with more secure defaults:
470
488
 
471
- ## Comparison with Complexity Limiting
489
+ ### 1. Introspection fields are no longer fully ignored
472
490
 
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 |
491
+ **v1:** All `__`-prefixed fields (`__typename`, `__schema`, `__type`) were ignored during depth calculation.
479
492
 
480
- **Best Practice:** Use **both** depth and complexity limiting for comprehensive protection.
493
+ **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
494
 
482
- ---
495
+ **To restore v1 behavior:**
483
496
 
484
- ## Requirements
497
+ ```ts
498
+ depthLimit(10, { ignoreIntrospection: "all" });
499
+ ```
500
+
501
+ ### 2. `@depth` directives can no longer relax the global limit
502
+
503
+ **v1:** A `@depth(max: 50)` directive could override a global `maxDepth: 10`, allowing that subtree to nest up to 50 levels deep.
485
504
 
486
- - Node.js 18+
487
- - GraphQL 16+
505
+ **v2:** By default (`directiveMode: "cap"`), directives can only **tighten** below the global max. A `@depth(max: 50)` with `maxDepth: 10` caps at 10.
488
506
 
489
- ## Related Packages
507
+ **To restore v1 behavior:**
508
+
509
+ ```ts
510
+ depthLimit(10, { directiveMode: "override", useDirective: true });
511
+ ```
490
512
 
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
513
+ ### 3. Short-circuit traversal on violations
514
+
515
+ **v1:** The engine always traversed the full query, even after detecting a violation.
516
+
517
+ **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.
518
+
519
+ **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
520
 
494
521
  ## License
495
522
 
496
- MIT License. This code is free to use. It has no opinions. You, I presume, do. Please use them.
523
+ [MIT](LICENSE)
524
+