graphql-query-depth-limit-esm 2.0.2 → 2.0.5

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
@@ -6,13 +6,11 @@
6
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
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
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
9
- [![Node >=22](https://img.shields.io/badge/node-%3E%3D22-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
9
+ [![Node >=20](https://img.shields.io/badge/node-%3E%3D20-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
10
10
  [![Provenance](https://img.shields.io/badge/provenance-verified-brightgreen?logo=npm)](https://www.npmjs.com/package/graphql-query-depth-limit-esm)
11
11
 
12
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.
13
13
 
14
- **[Interactive demo](https://lafittemehdy.github.io/graphql-query-depth-limit-esm/)** — see depth analysis in action with preset queries, an AST tree viewer, and a depth gauge.
15
-
16
14
  ## Interactive Demo
17
15
 
18
16
  **[Try it live](https://lafittemehdy.github.io/graphql-query-depth-limit-esm/)** or run locally:
@@ -23,7 +21,7 @@ npm install
23
21
  npm run dev
24
22
  ```
25
23
 
26
- Includes preset queries (simple lookups through deeply nested attacks), an AST tree viewer with depth badges, and a depth gauge with pass/fail verdict.
24
+ Preset queries (simple lookups through deeply nested attacks), an AST tree viewer with depth badges, and a depth gauge with pass/fail verdict.
27
25
 
28
26
  ## Features
29
27
 
@@ -494,6 +492,21 @@ query {
494
492
  # Maximum depth: 3
495
493
  ```
496
494
 
495
+ ## Performance
496
+
497
+ The depth engine uses **iterative DFS** (not recursion) to prevent stack overflow on adversarial queries and to keep traversal overhead constant regardless of nesting depth.
498
+
499
+ | Characteristic | Detail |
500
+ |---|---|
501
+ | **Algorithm** | Iterative depth-first search with explicit stack |
502
+ | **Time complexity** | O(n) where n = total selection nodes in the query |
503
+ | **Short-circuit** | Stops on first violation when no callback is provided |
504
+ | **Fragment cycles** | Detected per-path with minimal Set allocations |
505
+ | **Stack safety** | No recursion — immune to stack overflow on deep queries |
506
+ | **Runtime dependencies** | Zero — only `graphql` as a peer dependency |
507
+
508
+ 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.
509
+
497
510
  ## Migrating from v1 to v2
498
511
 
499
512
  v2 introduces three **breaking changes** with more secure defaults:
package/dist/index.cjs CHANGED
@@ -211,8 +211,11 @@ function calculateDepth(caches, config, fragments, maxDepth, node, parentType, s
211
211
  if (!frame.node.selectionSet) {
212
212
  continue;
213
213
  }
214
- const nextFrames = [];
215
- for (const selection of frame.node.selectionSet.selections) {
214
+ for (let selectionIndex = frame.node.selectionSet.selections.length - 1; selectionIndex >= 0; selectionIndex--) {
215
+ const selection = frame.node.selectionSet.selections[selectionIndex];
216
+ if (!selection) {
217
+ continue;
218
+ }
216
219
  switch (selection.kind) {
217
220
  case import_graphql2.Kind.FIELD: {
218
221
  const fieldName = selection.name.value;
@@ -293,7 +296,7 @@ function calculateDepth(caches, config, fragments, maxDepth, node, parentType, s
293
296
  deepestViolation = violation;
294
297
  }
295
298
  }
296
- nextFrames.push({
299
+ stack.push({
297
300
  currentDepth: newDepth,
298
301
  hasDirectiveLimit,
299
302
  ignoredFieldsOnPath,
@@ -317,7 +320,7 @@ function calculateDepth(caches, config, fragments, maxDepth, node, parentType, s
317
320
  const fragmentVisited = new Set(frame.visitedFragments);
318
321
  fragmentVisited.add(fragmentName);
319
322
  const parentType2 = fragment.typeCondition ? resolveTypeCondition(fragment.typeCondition.name.value, schema, frame.parentType) : frame.parentType;
320
- nextFrames.push({
323
+ stack.push({
321
324
  currentDepth: frame.currentDepth,
322
325
  hasDirectiveLimit: frame.hasDirectiveLimit,
323
326
  ignoredFieldsOnPath: frame.ignoredFieldsOnPath,
@@ -331,7 +334,7 @@ function calculateDepth(caches, config, fragments, maxDepth, node, parentType, s
331
334
  }
332
335
  case import_graphql2.Kind.INLINE_FRAGMENT: {
333
336
  const parentType2 = selection.typeCondition ? resolveTypeCondition(selection.typeCondition.name.value, schema, frame.parentType) : frame.parentType;
334
- nextFrames.push({
337
+ stack.push({
335
338
  currentDepth: frame.currentDepth,
336
339
  hasDirectiveLimit: frame.hasDirectiveLimit,
337
340
  ignoredFieldsOnPath: frame.ignoredFieldsOnPath,
@@ -349,12 +352,6 @@ function calculateDepth(caches, config, fragments, maxDepth, node, parentType, s
349
352
  }
350
353
  }
351
354
  }
352
- for (let index = nextFrames.length - 1; index >= 0; index--) {
353
- const nextFrame = nextFrames[index];
354
- if (nextFrame) {
355
- stack.push(nextFrame);
356
- }
357
- }
358
355
  }
359
356
  return { depth: globalMaxDepth, violation: deepestViolation };
360
357
  }
@@ -455,10 +452,129 @@ function resolveTypeCondition(typeConditionName, schema, currentParentType) {
455
452
  return currentParentType;
456
453
  }
457
454
 
458
- // src/depth-limit.ts
455
+ // src/depth-options.ts
459
456
  var DIRECTIVE_MODES = /* @__PURE__ */ new Set(["cap", "override"]);
460
457
  var IGNORE_MODES = /* @__PURE__ */ new Set(["exclude", "skip"]);
461
458
  var INTROSPECTION_MODES = /* @__PURE__ */ new Set(["all", "none", "typename"]);
459
+ function assertBooleanOption(name, value) {
460
+ if (value !== void 0 && typeof value !== "boolean") {
461
+ throw new TypeError(`Invalid ${name}: expected boolean, received ${typeof value}.`);
462
+ }
463
+ }
464
+ function isIgnoreRule(rule) {
465
+ return typeof rule === "function" || rule instanceof RegExp || typeof rule === "string";
466
+ }
467
+ function normalizeIgnoreRules(ignore) {
468
+ if (ignore == null) {
469
+ return void 0;
470
+ }
471
+ const rules = Array.isArray(ignore) ? ignore : [ignore];
472
+ for (const [index, rule] of rules.entries()) {
473
+ if (!isIgnoreRule(rule)) {
474
+ const receivedType = Array.isArray(rule) ? "array" : rule === null ? "null" : typeof rule;
475
+ throw new TypeError(
476
+ `Invalid ignore rule at index ${index}: expected string, RegExp, or function, received ${receivedType}.`
477
+ );
478
+ }
479
+ if (rule instanceof RegExp) {
480
+ const reason = isUnsafeRegExp(rule);
481
+ if (reason) {
482
+ throw new TypeError(
483
+ `Unsafe RegExp ignore rule at index ${index}: /${rule.source}/${rule.flags} - ${reason}. Use a simpler pattern to avoid catastrophic backtracking.`
484
+ );
485
+ }
486
+ }
487
+ }
488
+ return rules;
489
+ }
490
+ function normalizeDepthLimitOptions(options) {
491
+ assertBooleanOption("caseInsensitiveIgnore", options.caseInsensitiveIgnore);
492
+ if (options.directiveMode !== void 0 && !DIRECTIVE_MODES.has(options.directiveMode)) {
493
+ throw new TypeError(
494
+ `Invalid directiveMode: "${options.directiveMode}". Must be "cap" or "override".`
495
+ );
496
+ }
497
+ if (options.ignoreIntrospection !== void 0 && !INTROSPECTION_MODES.has(options.ignoreIntrospection)) {
498
+ throw new TypeError(
499
+ `Invalid ignoreIntrospection: "${options.ignoreIntrospection}". Must be "all", "none", or "typename".`
500
+ );
501
+ }
502
+ if (options.ignoreMode !== void 0 && !IGNORE_MODES.has(options.ignoreMode)) {
503
+ throw new TypeError(
504
+ `Invalid ignoreMode: "${options.ignoreMode}". Must be "exclude" or "skip".`
505
+ );
506
+ }
507
+ assertBooleanOption("limitIgnoredRecursion", options.limitIgnoredRecursion);
508
+ assertBooleanOption("shortCircuit", options.shortCircuit);
509
+ assertBooleanOption("useDirective", options.useDirective);
510
+ const ignore = normalizeIgnoreRules(options.ignore);
511
+ return { ...options, ignore };
512
+ }
513
+ function normalizeDepthLimitArgs(options, callback) {
514
+ if (callback !== void 0 && typeof callback !== "function") {
515
+ throw new TypeError("Invalid callback: expected a function.");
516
+ }
517
+ if (typeof options === "function") {
518
+ if (callback) {
519
+ throw new TypeError("Invalid depthLimit arguments: callback provided twice.");
520
+ }
521
+ return { callback: options, options: void 0 };
522
+ }
523
+ if (options !== void 0 && (options === null || typeof options !== "object" || Array.isArray(options))) {
524
+ const receivedType = Array.isArray(options) ? "array" : options === null ? "null" : typeof options;
525
+ throw new TypeError(`Invalid options: expected an object, received ${receivedType}.`);
526
+ }
527
+ return {
528
+ callback,
529
+ options: options ? normalizeDepthLimitOptions(options) : void 0
530
+ };
531
+ }
532
+ function createDepthResultRecord() {
533
+ return /* @__PURE__ */ Object.create(null);
534
+ }
535
+ function createOperationNameAllocator(operations) {
536
+ const explicitNames = /* @__PURE__ */ new Set();
537
+ for (const operation of operations) {
538
+ if (operation.name?.value) {
539
+ explicitNames.add(operation.name.value);
540
+ }
541
+ }
542
+ const usedNames = /* @__PURE__ */ new Set();
543
+ const namedCounts = /* @__PURE__ */ new Map();
544
+ let anonymousCount = 0;
545
+ return (operation) => {
546
+ const explicitName = operation.name?.value;
547
+ if (explicitName) {
548
+ let suffix = namedCounts.get(explicitName) ?? 0;
549
+ let candidate2 = suffix === 0 ? explicitName : `${explicitName}_${suffix}`;
550
+ while (usedNames.has(candidate2) || suffix > 0 && explicitNames.has(candidate2)) {
551
+ suffix++;
552
+ candidate2 = `${explicitName}_${suffix}`;
553
+ }
554
+ namedCounts.set(explicitName, suffix + 1);
555
+ usedNames.add(candidate2);
556
+ return candidate2;
557
+ }
558
+ anonymousCount++;
559
+ let candidate = anonymousCount === 1 ? "[anonymous]" : `[anonymous:${anonymousCount}]`;
560
+ while (usedNames.has(candidate) || explicitNames.has(candidate)) {
561
+ anonymousCount++;
562
+ candidate = `[anonymous:${anonymousCount}]`;
563
+ }
564
+ usedNames.add(candidate);
565
+ return candidate;
566
+ };
567
+ }
568
+ function setDepthResult(target, key, value) {
569
+ Object.defineProperty(target, key, {
570
+ configurable: true,
571
+ enumerable: true,
572
+ value,
573
+ writable: true
574
+ });
575
+ }
576
+
577
+ // src/depth-limit.ts
462
578
  function depthLimit(maxDepth, options, callback) {
463
579
  if (!Number.isInteger(maxDepth) || maxDepth < 0) {
464
580
  throw new Error(`Invalid maxDepth: ${maxDepth}. Must be a non-negative integer.`);
@@ -466,11 +582,6 @@ function depthLimit(maxDepth, options, callback) {
466
582
  const normalized = normalizeDepthLimitArgs(options, callback);
467
583
  return createValidationRule(maxDepth, normalized.options, normalized.callback);
468
584
  }
469
- function assertBooleanOption(name, value) {
470
- if (value !== void 0 && typeof value !== "boolean") {
471
- throw new TypeError(`Invalid ${name}: expected boolean, received ${typeof value}.`);
472
- }
473
- }
474
585
  function createValidationRule(maxDepth, options, callback) {
475
586
  const shortCircuit = options?.shortCircuit ?? callback == null;
476
587
  return function depthLimitValidationRule(context) {
@@ -552,118 +663,6 @@ function createValidationRule(maxDepth, options, callback) {
552
663
  return {};
553
664
  };
554
665
  }
555
- function isIgnoreRule(rule) {
556
- return typeof rule === "function" || rule instanceof RegExp || typeof rule === "string";
557
- }
558
- function normalizeDepthLimitArgs(options, callback) {
559
- if (callback !== void 0 && typeof callback !== "function") {
560
- throw new TypeError("Invalid callback: expected a function.");
561
- }
562
- if (typeof options === "function") {
563
- if (callback) {
564
- throw new TypeError("Invalid depthLimit arguments: callback provided twice.");
565
- }
566
- return { callback: options, options: void 0 };
567
- }
568
- if (options !== void 0 && (options === null || typeof options !== "object" || Array.isArray(options))) {
569
- const receivedType = Array.isArray(options) ? "array" : options === null ? "null" : typeof options;
570
- throw new TypeError(`Invalid options: expected an object, received ${receivedType}.`);
571
- }
572
- return {
573
- callback,
574
- options: options ? normalizeDepthLimitOptions(options) : void 0
575
- };
576
- }
577
- function normalizeDepthLimitOptions(options) {
578
- assertBooleanOption("caseInsensitiveIgnore", options.caseInsensitiveIgnore);
579
- if (options.directiveMode !== void 0 && !DIRECTIVE_MODES.has(options.directiveMode)) {
580
- throw new TypeError(
581
- `Invalid directiveMode: "${options.directiveMode}". Must be "cap" or "override".`
582
- );
583
- }
584
- if (options.ignoreIntrospection !== void 0 && !INTROSPECTION_MODES.has(options.ignoreIntrospection)) {
585
- throw new TypeError(
586
- `Invalid ignoreIntrospection: "${options.ignoreIntrospection}". Must be "all", "none", or "typename".`
587
- );
588
- }
589
- if (options.ignoreMode !== void 0 && !IGNORE_MODES.has(options.ignoreMode)) {
590
- throw new TypeError(
591
- `Invalid ignoreMode: "${options.ignoreMode}". Must be "exclude" or "skip".`
592
- );
593
- }
594
- assertBooleanOption("limitIgnoredRecursion", options.limitIgnoredRecursion);
595
- assertBooleanOption("shortCircuit", options.shortCircuit);
596
- assertBooleanOption("useDirective", options.useDirective);
597
- const ignore = normalizeIgnoreRules(options.ignore);
598
- return { ...options, ignore };
599
- }
600
- function normalizeIgnoreRules(ignore) {
601
- if (ignore == null) {
602
- return void 0;
603
- }
604
- const rules = Array.isArray(ignore) ? ignore : [ignore];
605
- for (const [index, rule] of rules.entries()) {
606
- if (!isIgnoreRule(rule)) {
607
- const receivedType = Array.isArray(rule) ? "array" : rule === null ? "null" : typeof rule;
608
- throw new TypeError(
609
- `Invalid ignore rule at index ${index}: expected string, RegExp, or function, received ${receivedType}.`
610
- );
611
- }
612
- if (rule instanceof RegExp) {
613
- const reason = isUnsafeRegExp(rule);
614
- if (reason) {
615
- throw new TypeError(
616
- `Unsafe RegExp ignore rule at index ${index}: /${rule.source}/${rule.flags} \u2014 ${reason}. Use a simpler pattern to avoid catastrophic backtracking.`
617
- );
618
- }
619
- }
620
- }
621
- return rules;
622
- }
623
- function createDepthResultRecord() {
624
- return /* @__PURE__ */ Object.create(null);
625
- }
626
- function createOperationNameAllocator(operations) {
627
- const explicitNames = /* @__PURE__ */ new Set();
628
- for (const operation of operations) {
629
- if (operation.name?.value) {
630
- explicitNames.add(operation.name.value);
631
- }
632
- }
633
- const usedNames = /* @__PURE__ */ new Set();
634
- const namedCounts = /* @__PURE__ */ new Map();
635
- let anonymousCount = 0;
636
- return (operation) => {
637
- const explicitName = operation.name?.value;
638
- if (explicitName) {
639
- let suffix = namedCounts.get(explicitName) ?? 0;
640
- let candidate2 = suffix === 0 ? explicitName : `${explicitName}_${suffix}`;
641
- while (usedNames.has(candidate2) || suffix > 0 && explicitNames.has(candidate2)) {
642
- suffix++;
643
- candidate2 = `${explicitName}_${suffix}`;
644
- }
645
- namedCounts.set(explicitName, suffix + 1);
646
- usedNames.add(candidate2);
647
- return candidate2;
648
- }
649
- anonymousCount++;
650
- let candidate = anonymousCount === 1 ? "[anonymous]" : `[anonymous:${anonymousCount}]`;
651
- while (usedNames.has(candidate) || explicitNames.has(candidate)) {
652
- anonymousCount++;
653
- candidate = `[anonymous:${anonymousCount}]`;
654
- }
655
- usedNames.add(candidate);
656
- return candidate;
657
- };
658
- }
659
- function setDepthResult(target, key, value) {
660
- Object.defineProperty(target, key, {
661
- configurable: true,
662
- enumerable: true,
663
- value,
664
- writable: true
665
- });
666
- }
667
666
  // Annotate the CommonJS export names for ESM import in node:
668
667
  0 && (module.exports = {
669
668
  ERROR_CODES,