# graphql-query-depth-limit-esm

[![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)
[![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)
[![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)
[![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)
[![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)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Node >=22.18](https://img.shields.io/badge/node-%3E%3D22.18-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
[![Provenance](https://img.shields.io/badge/provenance-verified-brightgreen?logo=npm)](https://www.npmjs.com/package/graphql-query-depth-limit-esm)

GraphQL query depth limiting as a validation rule. Expanded traversal is capped at 10,000 selection nodes per operation, including repeated fragment spreads; configure `maxNodes` to change this budget. Exceeding it reports `NODE_LIMIT_EXCEEDED`. Prevents denial-of-service attacks from deeply nested queries by enforcing a configurable maximum depth.

## Interactive Demo

**[Try it live](https://lafittemehdy.github.io/graphql-query-depth-limit-esm/)** or run locally:

```bash
cd examples/visualization
npm install
npm run dev
```

Preset queries (simple lookups through deeply nested attacks), an AST tree viewer with depth badges, and a depth gauge with pass/fail verdict.

## Features

- **`@depth` directive** — per-field depth overrides via schema directives
- **Alias-aware paths** — violation paths use aliases when present, matching the response shape
- **Configurable introspection handling** — control whether `__schema` and `__type` count toward depth
- **Callback support** — optional callback reports per-operation depth for monitoring
- **Fragment-safe** — handles named fragments, inline fragments, and circular fragment detection
- **Ignore rules** — skip fields by name, pattern, or custom function
- **Interface directive inheritance** — `@depth` directives on interface fields apply to all implementors
- **Short-circuit traversal** — stops immediately on first violation when no callback is provided
- **Validation rule** — integrates directly with `graphql`'s `validate()` function
- **Zero runtime dependencies** — only `graphql ^16 || ^17` as a peer dependency

## Installation

```bash
npm install graphql-query-depth-limit-esm graphql
```

```bash
pnpm add graphql-query-depth-limit-esm graphql
```

```bash
yarn add graphql-query-depth-limit-esm graphql
```

## Quickstart

```ts
import { specifiedRules, validate } from "graphql";
import { depthLimit } from "graphql-query-depth-limit-esm";

const errors = validate(schema, document, [
	...specifiedRules,
	depthLimit(7, { useDirective: true }),
]);
```

When calling `validate()` directly, include `specifiedRules` unless you intentionally want to replace GraphQL's default validation rules.

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).

### Global Limit with Per-Field Overrides

Include [`depthDirectiveTypeDefs`](#depthdirectivetypedefs) in your schema to declare the `@depth` directive, then annotate individual fields:

```graphql
# depthDirectiveTypeDefs provides this automatically:
# directive @depth(max: Int!) on FIELD_DEFINITION

type User {
	name: String!
	profile: Profile!

	# Self-referential — allow up to 3 levels of nesting
	friends: [User!]! @depth(max: 3)
}

type Post {
	title: String!
	author: User!

	# Recursive comments — allow up to 5 levels deep
	comments: [Comment!]! @depth(max: 5)
}

type Comment {
	text: String!
	replies: [Comment!]! @depth(max: 4)
}
```

Build the schema with the directive type defs, then enable directive support via [`depthLimit()`](#depthlimitmaxdepth-options-callback):

```ts
import { makeExecutableSchema } from "@graphql-tools/schema";
import { depthDirectiveTypeDefs, depthLimit } from "graphql-query-depth-limit-esm";
import { specifiedRules, validate } from "graphql";

const schema = makeExecutableSchema({
	typeDefs: [depthDirectiveTypeDefs, yourTypeDefs],
	resolvers,
});

// Global max of 7 — @depth directives can tighten but never exceed this limit
const errors = validate(schema, document, [
	...specifiedRules,
	depthLimit(7, { useDirective: true }),
]);
```

### Nested Directives

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:

```graphql
type Post {
	# 3 levels from here (absolute max = currentDepth + 3)
	comments: [Comment] @depth(max: 3)
}

type Comment {
	text: String
	# Wants 5 levels, but capped by the ancestor's limit
	replies: [Comment] @depth(max: 5)
}
```

This ensures a parent directive remains a hard ceiling for its entire subtree, preventing deeply nested child directives from punching through ancestor limits.

> **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.

### Interface Directive Inheritance

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:

```graphql
interface Node {
	children: [Node!]! @depth(max: 3)
}

type TreeNode implements Node {
	children: [Node!]! # Inherits @depth(max: 3) from Node interface
	label: String!
}
```

When multiple interfaces define `@depth` on the same field, the **strictest (lowest)** limit is used.

### Why Explicit Opt-In?

The [`useDirective`](#depthlimitoptions) option is `false` by default. This is intentional:

- **Follows GraphQL Convention.** Standard rules like `NoUnusedFragmentsRule` or `KnownDirectivesRule` are configured explicitly, and this library follows that same pattern for consistency.
- **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.
- **Predictable behavior.** Enabling directive support is a deliberate choice, making it clear in code review that per-field overrides are in play.

## Usage

### Basic Depth Limiting

For straightforward global limiting without per-field overrides, call [`depthLimit()`](#depthlimitmaxdepth-options-callback) with only a maximum depth:

```ts
import { depthLimit } from "graphql-query-depth-limit-esm";
import { specifiedRules, validate } from "graphql";

// Reject queries deeper than 10 levels
const rule = depthLimit(10);
const errors = validate(schema, document, [...specifiedRules, rule]);
```

### With Apollo Server

```ts
import { ApolloServer } from "@apollo/server";
import { depthLimit } from "graphql-query-depth-limit-esm";

const server = new ApolloServer({
	schema,
	validationRules: [depthLimit(7, { useDirective: true })],
});
```

### With Yoga

```ts
import { createYoga } from "graphql-yoga";
import { depthLimit } from "graphql-query-depth-limit-esm";

const yoga = createYoga({
	schema,
	plugins: [
		{
			onValidate({ addValidationRule }) {
				addValidationRule(depthLimit(7, { useDirective: true }));
			},
		},
	],
});
```

### Ignore Rules

Skip specific fields during depth calculation using strings, regular expressions, or custom functions. See [`IgnoreRule`](#ignorerule) for the full type definition.

```ts
const rule = depthLimit(5, {
	ignore: [
		// Exact field name match
		"metadata",

		// Regular expression pattern
		/.*Connection$/,

		// Custom function
		(fieldName) => fieldName.startsWith("internal"),
	],
});
```

> **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.

### Ignore Mode

By default, ignored fields only skip the depth increment while still traversing children. Use [`ignoreMode`](#depthlimitoptions) to control this behavior:

```ts
// Default (secure): skip only the depth increment, still traverse children
const rule = depthLimit(5, { ignore: ["metadata"], ignoreMode: "exclude" });

// Optional: skip the field and its entire subtree (use with caution)
const rule = depthLimit(5, { ignore: ["metadata"], ignoreMode: "skip" });
```

With `"exclude"`, the ignored field does not increment the depth counter, but its children are still traversed and subject to depth limits. Non-ignored composite children still increment depth.

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 retain depth protection along that edge, enable [`limitIgnoredRecursion`](#depthlimitoptions) or remove the recursive field from `ignore`. The `maxNodes` budget still limits expanded traversal. `ignoreMode: "skip"` removes the entire subtree from depth protection.

### Recursive Ignore Guard

Enable [`limitIgnoredRecursion`](#depthlimitoptions) to count repeated ignored fields when using `ignoreMode: "exclude"` on recursive fields:

```ts
const rule = depthLimit(10, {
	ignore: ["friends"],
	ignoreMode: "exclude",
	limitIgnoredRecursion: true,
});
```

When enabled, the first ignored field occurrence on a path is still excluded, but repeated occurrences on the same path increment depth normally.

### Short-Circuit Control

By default, short-circuit behavior is auto-detected:

- No callback: short-circuits on first violation (`shortCircuit: true`)
- Callback provided: traverses full tree for exact depth (`shortCircuit: false`)

You can override this explicitly:

```ts
// Force full traversal even without a callback
const exactRule = depthLimit(5, { shortCircuit: false });

// Force early bailout even with a callback
const fastRule = depthLimit(5, { shortCircuit: true }, (depths) => {
	console.log(depths);
});
```

### Case-Insensitive Matching

Enable [`caseInsensitiveIgnore`](#depthlimitoptions) to match string ignore rules regardless of casing:

```ts
const rule = depthLimit(5, {
	caseInsensitiveIgnore: true,
	ignore: ["metadata"], // Matches "metadata", "Metadata", "METADATA", etc.
});
```

### Introspection Handling

By default, only `__typename` is ignored during depth calculation. The `__schema` and `__type` fields are **counted** toward depth, protecting against deeply nested introspection queries.

Scalar fields such as `__typename` never increment depth, including in `"none"` mode. The introspection option affects composite introspection fields such as `__schema` and `__type`.

```ts
// Default: only __typename is ignored
const rule = depthLimit(10);

// Ignore all introspection fields and skip their entire subtree
const rule = depthLimit(10, { ignoreIntrospection: "all" });

// Count composite introspection fields toward depth
const rule = depthLimit(10, { ignoreIntrospection: "none" });
```

> **Note:** When `ignoreIntrospection: "all"` is set, introspection fields and their entire subtree are always skipped — regardless of `ignoreMode`. Those subtrees bypass depth protection. This option does not disable introspection.

### Depth Callback

Monitor query depths with an optional [`callback`](#depthcallback) that receives per-operation depth results:

```ts
const rule = depthLimit(10, {}, (depths) => {
	// { "GetUser": 3, "ListPosts": 5, "[anonymous]": 2 }
	for (const [operation, depth] of Object.entries(depths)) {
		console.log(`Operation "${operation}" has depth ${depth}`);
	}
});
```

If you do not need options, you can pass the callback as the second argument:

```ts
const rule = depthLimit(10, (depths) => {
	console.log(depths);
});
```

> **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.

## API Reference

### `depthLimit(maxDepth, options?, callback?)`

Creates a GraphQL validation rule that limits query depth.

#### Parameters

| Parameter  | Type                                      | Required | Description                                  |
| ---------- | ----------------------------------------- | -------- | -------------------------------------------- |
| `maxDepth` | `number`                                  | Yes      | Maximum allowed depth (non-negative integer) |
| `options`  | [`DepthLimitOptions`](#depthlimitoptions) | No       | Configuration options                        |
| `callback` | [`DepthCallback`](#depthcallback)         | No       | Called with per-operation depth results      |

#### Returns

A GraphQL `ValidationRule` function.

#### Throws

- `Error` if `maxDepth` is not a non-negative integer
- `TypeError` if options, callback, or ignore rules are invalid

### `depthDirectiveTypeDefs`

GraphQL SDL string defining the `@depth` directive. Include this in your schema type definitions when using `{ useDirective: true }`.

```graphql
directive @depth(max: Int!) on FIELD_DEFINITION
```

### `DepthLimitOptions`

| Property                | Type                                        | Default            | Description                                                                                                                                                                 |
| ----------------------- | ------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `caseInsensitiveIgnore` | `boolean`                                   | `false`            | Case-insensitive matching for string ignore rules                                                                                                                           |
| `directiveMode`         | [`DirectiveMode`](#directivemode)           | `"cap"`            | Controls how `@depth` directives interact with the global `maxDepth`                                                                                                        |
| `ignore`                | [`IgnoreRule \| IgnoreRule[]`](#ignorerule) | `undefined`        | Fields to skip during depth calculation                                                                                                                                     |
| `ignoreIntrospection`   | [`IntrospectionMode`](#introspectionmode)   | `"typename"`       | Controls which introspection fields are ignored                                                                                                                             |
| `ignoreMode`            | [`IgnoreMode`](#ignoremode)                 | `"exclude"`        | Controls whether ignored fields skip their entire subtree or only the depth increment                                                                                       |
| `limitIgnoredRecursion` | `boolean`                                   | `false`            | In `ignoreMode: "exclude"`, increments depth for repeated ignored fields on the same path to prevent unbounded recursion                                                    |
| `maxNodes`              | `number`                                    | `10000`            | Positive safe integer limiting expanded selection nodes per operation, including leaf fields and repeated fragment spreads.                                                 |
| `shortCircuit`          | `boolean`                                   | `undefined` (auto) | Auto: `true` without callback, `false` with callback. Set explicitly to force early bailout or full traversal                                                               |
| `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`. |

### `DirectiveMode`

```ts
type DirectiveMode = "cap" | "override";
```

- `"cap"` — directives can only tighten the limit below the global `maxDepth` (secure default)
- `"override"` — the first directive replaces the global limit for its subtree

### `IgnoreMode`

```ts
type IgnoreMode = "exclude" | "skip";
```

- `"exclude"` — skip the depth increment but still traverse children (secure default)
- `"skip"` — skip the field and its entire subtree

### `IntrospectionMode`

```ts
type IntrospectionMode = "all" | "none" | "typename";
```

- `"all"` — ignore every `__`-prefixed field (`__typename`, `__schema`, `__type`, etc.)
- `"typename"` — only ignore `__typename` (secure default)
- `"none"` — count all introspection fields toward depth

### `IgnoreRule`

```ts
type IgnoreRule = string | RegExp | ((fieldName: string) => boolean);
```

### `DepthCallback`

```ts
type DepthCallback = (depths: Record<string, number>) => void;
```

### `ERROR_CODES`

```ts
const ERROR_CODES = {
	IGNORE_RULE_ERROR: "IGNORE_RULE_ERROR",
	NODE_LIMIT_EXCEEDED: "NODE_LIMIT_EXCEEDED",
	QUERY_TOO_DEEP: "QUERY_TOO_DEEP",
} as const;
```

- `QUERY_TOO_DEEP`: query depth exceeded the allowed limit
- `NODE_LIMIT_EXCEEDED`: expanded traversal exceeded `maxNodes`; extensions include `maxNodes` and `nodeCount`
- `IGNORE_RULE_ERROR`: a user-provided ignore rule (`RegExp` or function) threw at validation time

## Error Extensions

Depth violations include structured extensions for programmatic access:

```json
{
	"message": "'GetUser' has depth 8 which exceeds maximum allowed depth of 5 (at user.friends.friends)",
	"extensions": {
		"code": "QUERY_TOO_DEEP",
		"depth": 8,
		"maxDepth": 5,
		"path": ["user", "friends", "friends"],
		"shortCircuit": false
	}
}
```

| Field          | Type       | Description                                                                                                                                                       |
| -------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `code`         | `string`   | Always `"QUERY_TOO_DEEP"`                                                                                                                                         |
| `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.       |
| `maxDepth`     | `number`   | The maximum allowed depth that was exceeded                                                                                                                       |
| `path`         | `string[]` | Field path from the operation root to the violation point (uses aliases when present)                                                                             |
| `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. |

If an ignore rule throws, validation reports:

```json
{
	"message": "Ignore rule function threw for field \"friends\": boom",
	"extensions": {
		"code": "IGNORE_RULE_ERROR"
	}
}
```

## How Depth Is Calculated

- Depth increments for each **composite field** (objects, interfaces, unions)
- **Scalar and enum fields** do not increment depth
- **Fragment spreads** contribute the depth of their expanded selections
- **Inline fragments** contribute the depth of their selections
- **`__typename`** is ignored by default (configurable via [`ignoreIntrospection`](#introspectionmode))
- **`__schema` and `__type`** are counted toward depth by default
- **Circular fragment references** are detected per-path and stop recursion

### Example

```graphql
# Depth: 0
query {
	# Depth: 1
	user {
		name # Depth: 1 (scalar, no increment)
		# Depth: 2
		posts {
			title # Depth: 2 (scalar, no increment)
			# Depth: 3
			comments {
				text # Depth: 3 (scalar, no increment)
			}
		}
	}
}
# Maximum depth: 3
```

Queries that exceed the node budget are rejected even if their depth is within the limit. Operations stopped by node-limit or ignore-rule errors are omitted from the callback payload. If GraphQL aborts this rule at its `maxErrors` cap, the callback is not called.

## Performance

The depth engine uses **iterative DFS** (not recursion) to prevent stack overflow on adversarial queries and to bound JavaScript call-stack usage during depth analysis.

| Characteristic           | Detail                                                                                                                              |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| **Algorithm**            | Iterative depth-first search with explicit stack                                                                                    |
| **Time complexity**      | O(n) selection visits, where n includes repeated fragment expansion; per-path fragment and recursion tracking adds copying overhead |
| **Short-circuit**        | Stops on first violation when no callback is provided                                                                               |
| **Fragment cycles**      | Detected per-path with minimal Set allocations                                                                                      |
| **Stack safety**         | No recursion — immune to stack overflow on deep queries                                                                             |
| **Runtime dependencies** | Zero — only `graphql` as a peer dependency                                                                                          |

Typical validation completes in **sub-millisecond** time for standard queries (< 50 fields). Even adversarial queries with hundreds of nested selections are analyzed in **single-digit milliseconds** thanks to early termination and per-path fragment cycle detection.

## Migrating from v2 to v3

v3 requires Node.js 22.18.0 or newer and supports GraphQL 16 and 17. It adds a default budget of 10,000 expanded selection nodes per operation, including repeated fragment spreads and scalar fields. Previously accepted wide or heavily reused fragment queries may now return `NODE_LIMIT_EXCEEDED`. Set `maxNodes` to a larger positive safe integer if your application needs a higher budget.

Version 3.0.2 fixes validation error-cap handling and selection order, corrects demo operation names, and repairs lint/release checks. Public exports and runtime requirements are unchanged from 3.0.0.

## Migrating from v1 to v2

v2 introduces three **breaking changes** with more secure defaults:

### 1. Introspection fields are no longer fully ignored

**v1:** All `__`-prefixed fields (`__typename`, `__schema`, `__type`) were ignored during depth calculation.

**v2:** Only `__typename` is ignored by default. `__schema` and `__type` now count toward depth, preventing deeply nested introspection queries from bypassing the depth limit.

**To restore v1 behavior:**

```ts
depthLimit(10, { ignoreIntrospection: "all" });
```

### 2. `@depth` directives can no longer relax the global limit

**v1:** A `@depth(max: 50)` directive could override a global `maxDepth: 10`, allowing that subtree to nest up to 50 levels deep.

**v2:** By default (`directiveMode: "cap"`), directives can only **tighten** below the global max. A `@depth(max: 50)` with `maxDepth: 10` caps at 10.

**To restore v1 behavior:**

```ts
depthLimit(10, { directiveMode: "override", useDirective: true });
```

### 3. Short-circuit traversal on violations

**v1:** The engine always traversed the full query, even after detecting a violation.

**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.

**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.

## Development and Release Checks

Use Node.js 24 for maintenance and the pinned pnpm version for the library. The demo has its own npm lockfile.

```bash
npm install --global pnpm@12.15.0
pnpm install --frozen-lockfile
pnpm run lint
pnpm run build
pnpm run check:consumers
pnpm run check:package
pnpm run test:coverage
pnpm audit
cd examples/visualization
npm ci
npm run build
npm audit
```

`check:package` requires a build and registry access. It installs the packed tarball in temporary GraphQL 16/17 consumers, checks ESM/CommonJS runtime behavior and declarations, and removes its temporary files.

Dependabot minor/patch merges wait for CI on the PR head. Automatic library dependency releases require successful branch CI and skip stale commits. For a manual release, first verify CI on the intended commit, then create a matching `v<package.json version>` tag and GitHub release. Publishing uses the existing `RELEASE_TOKEN` for release automation and npm trusted publishing configured for `publish.yml`, with direct publish permission and provenance. Private npm publisher settings and hosted execution require separate verification.

## Related Packages

This package is part of a suite of GraphQL security tools that work independently or together to protect your API:

| Package                                                                                        | Purpose                                                                         |
| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| [`graphql-query-complexity-esm`](https://github.com/lafittemehdy/graphql-query-complexity-esm) | Complexity analysis — assign cost scores to fields and reject expensive queries |
| [`graphql-rate-limit-redis-esm`](https://github.com/lafittemehdy/graphql-rate-limit-redis-esm) | Rate limiting — Redis-backed per-field rate limiting via `@rateLimit` directive |

**Recommended layering:** Use depth limiting as a fast, cheap first gate, complexity analysis for fine-grained cost control, and rate limiting for per-client throttling.

## License

[MIT](LICENSE)
