npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@slopit/behavioral

v0.1.0

Published

Client-side behavioral capture for keystroke dynamics, focus events, and paste detection

Readme

@slopit/behavioral

Client-side behavioral data capture for detecting AI-assisted responses in crowdsourced research.

Installation

pnpm add @slopit/behavioral

Quick Start

import { createBehavioralCapture } from "@slopit/behavioral";

// create capture instance with default configuration
const capture = createBehavioralCapture({
  keystroke: { enabled: true },
  focus: { enabled: true },
  paste: { enabled: true },
});

// attach to a text input element
const textarea = document.getElementById("response") as HTMLTextAreaElement;
capture.attach(textarea);

// later, retrieve captured data and computed metrics
const data = capture.getData();
const flags = capture.getFlags();

// cleanup when done
capture.detach();

API

BehavioralCapture

The main class for capturing behavioral data from DOM elements.

Constructor

new BehavioralCapture(config: BehavioralCaptureConfig)

Methods

attach(element: HTMLElement | Document, startTime?: number): void

Attaches behavioral capture to a DOM element. For text input capture, attach to the input or textarea element. For document-level focus tracking, attach to document.

capture.attach(textarea);
capture.attach(textarea, performance.now());
detach(): void

Detaches capture and cleans up all event listeners.

capture.detach();
reset(newStartTime?: number): void

Resets all collected data without detaching from the element.

capture.reset();
capture.reset(performance.now());
getData(): BehavioralData

Returns all collected behavioral data including keystrokes, focus events, paste events, mouse events, scroll events, and computed metrics.

const data = capture.getData();
console.log(data.keystrokes?.length);
console.log(data.metrics?.keystroke?.meanIKI);
getMetrics(): BehavioralMetrics

Returns computed metrics from the captured data.

const metrics = capture.getMetrics();
if (metrics.keystroke) {
  console.log("Mean IKI:", metrics.keystroke.meanIKI);
  console.log("Pause count:", metrics.keystroke.pauseCount);
}
getFlags(): CaptureFlag[]

Returns flags generated during capture (paste detection, excessive blur, etc.).

const flags = capture.getFlags();
for (const flag of flags) {
  console.log(flag.type, flag.severity, flag.message);
}
on(event, handler): void

Subscribes to capture events for real-time monitoring.

capture.on("keystroke", (event) => {
  console.log("Key pressed:", event.key);
});

capture.on("paste", (event) => {
  console.log("Paste detected:", event.textLength, "characters");
});
off(event, handler): void

Unsubscribes from capture events.

capture.off("keystroke", myHandler);

Configuration

BehavioralCaptureConfig

| Option | Type | Default | Description | |--------|------|---------|-------------| | keystroke | KeystrokeCaptureConfig | enabled | Keystroke capture settings | | focus | FocusCaptureConfig | enabled | Focus/visibility tracking settings | | paste | PasteCaptureConfig | enabled | Paste event capture settings | | clipboard | ClipboardCaptureConfig | enabled | Full clipboard (paste, copy, cut) settings | | mouse | MouseCaptureConfig | enabled | Mouse event capture settings | | scroll | ScrollCaptureConfig | enabled | Scroll event capture settings | | inputDuration | InputDurationCaptureConfig | enabled | Input focus duration tracking |

KeystrokeCaptureConfig

| Option | Type | Default | Description | |--------|------|---------|-------------| | enabled | boolean | true | Whether to capture keystrokes | | captureKeyUp | boolean | true | Whether to capture keyup events in addition to keydown | | includeModifiers | boolean | true | Whether to include modifier key states (shift, ctrl, alt, meta) |

FocusCaptureConfig

| Option | Type | Default | Description | |--------|------|---------|-------------| | enabled | boolean | true | Whether to capture focus events | | useVisibilityAPI | boolean | true | Whether to use the Page Visibility API | | useBlurFocus | boolean | true | Whether to use window blur/focus events |

PasteCaptureConfig

| Option | Type | Default | Description | |--------|------|---------|-------------| | enabled | boolean | true | Whether to capture paste events | | prevent | boolean | false | Whether to prevent paste operations | | warnMessage | string | undefined | Message to show when paste is prevented | | capturePreview | boolean | true | Whether to capture a preview of pasted text | | previewLength | number | 100 | Length of text preview to capture |

ClipboardCaptureConfig

| Option | Type | Default | Description | |--------|------|---------|-------------| | enabled | boolean | true | Whether clipboard capture is enabled | | capturePaste | boolean | true | Whether to capture paste events | | captureCopyCut | boolean | true | Whether to capture copy and cut events | | prevent | boolean | false | Whether to prevent paste operations | | warnMessage | string | undefined | Message to show when paste is prevented | | capturePreview | boolean | true | Whether to capture a preview of clipboard text | | previewLength | number | 100 | Length of text preview to capture |

MouseCaptureConfig

| Option | Type | Default | Description | |--------|------|---------|-------------| | enabled | boolean | true | Whether mouse capture is enabled | | captureClicks | boolean | true | Whether to capture click events | | captureMovement | boolean | true | Whether to capture mousemove events | | throttleMs | number | 50 | Throttle interval for mousemove events | | idleThresholdMs | number | 5000 | Threshold for detecting idle periods |

ScrollCaptureConfig

| Option | Type | Default | Description | |--------|------|---------|-------------| | enabled | boolean | true | Whether to capture scroll events | | throttleMs | number | 100 | Throttle interval for scroll events | | captureDirection | boolean | true | Whether to capture scroll direction changes |

InputDurationCaptureConfig

| Option | Type | Default | Description | |--------|------|---------|-------------| | enabled | boolean | true | Whether to capture input duration events | | trackKeystrokes | boolean | true | Whether to count keystrokes during focus periods | | trackPastes | boolean | true | Whether to count paste events during focus periods |

Individual Collectors

For advanced use cases, you can use the individual collectors directly.

KeystrokeCollector

import { KeystrokeCollector } from "@slopit/behavioral";

const collector = new KeystrokeCollector(
  { enabled: true, captureKeyUp: true, includeModifiers: true },
  performance.now(),
  (event) => console.log("Keystroke:", event.key)
);

collector.attach(textarea);
// ... capture keystrokes
const events = collector.getEvents();
const firstKeystrokeTime = collector.getFirstKeystrokeTime();
collector.detach();

FocusCollector

import { FocusCollector } from "@slopit/behavioral";

const collector = new FocusCollector(
  { enabled: true, useVisibilityAPI: true, useBlurFocus: true },
  performance.now(),
  (event) => console.log("Focus event:", event.event)
);

collector.attach(document);
// ... capture focus events
const events = collector.getEvents();
collector.detach();

PasteCollector

import { PasteCollector } from "@slopit/behavioral";

const collector = new PasteCollector(
  { enabled: true, prevent: false, capturePreview: true, previewLength: 100 },
  performance.now(),
  () => keystrokeCollector.getRecentCount(2000),
  (event) => console.log("Paste:", event.textLength, "chars")
);

collector.attach(textarea);
// ... capture paste events
const events = collector.getEvents();
collector.detach();

ClipboardCollector

import { ClipboardCollector } from "@slopit/behavioral";

const collector = new ClipboardCollector(
  {
    enabled: true,
    capturePaste: true,
    captureCopyCut: true,
    prevent: false,
    capturePreview: true,
    previewLength: 100,
  },
  performance.now(),
  () => keystrokeCollector.getRecentCount(2000),
  (pasteEvent) => console.log("Paste:", pasteEvent.textLength),
  (copyEvent) => console.log("Copy:", copyEvent.event, copyEvent.textLength)
);

collector.attach(document);
// ... capture clipboard events
const pasteEvents = collector.getPasteEvents();
const copyEvents = collector.getCopyEvents();
collector.detach();

MouseCollector

import { MouseCollector } from "@slopit/behavioral";

const collector = new MouseCollector(
  {
    enabled: true,
    captureClicks: true,
    captureMovement: true,
    throttleMs: 50,
    idleThresholdMs: 5000,
  },
  performance.now(),
  (event) => console.log("Mouse:", event.event, event.x, event.y)
);

collector.attach(document);
// ... capture mouse events
const events = collector.getEvents();
const idlePeriods = collector.getIdlePeriods();
const totalDistance = collector.getTotalDistance();
collector.detach();

ScrollCollector

import { ScrollCollector } from "@slopit/behavioral";

const collector = new ScrollCollector(
  { enabled: true, throttleMs: 100, captureDirection: true },
  performance.now(),
  (event) => console.log("Scroll:", event.direction, event.deltaY)
);

collector.attach(document);
// ... capture scroll events
const events = collector.getEvents();
const directionChanges = collector.getDirectionChanges();
collector.detach();

InputDurationCollector

import { InputDurationCollector } from "@slopit/behavioral";

const collector = new InputDurationCollector(
  { enabled: true, trackKeystrokes: true, trackPastes: true },
  performance.now(),
  (event) => console.log("Focus session:", event.duration, "ms")
);

collector.attach(textarea);
// ... track focus duration
const events = collector.getEvents();
collector.detach();

Detection Module

The detection module identifies potential AI assistance signatures.

ExtensionDetector

Detects AI assistant browser extensions (Grammarly, Copilot, Quillbot, etc.).

import { ExtensionDetector, createExtensionDetector } from "@slopit/behavioral";

// one-shot detection
const detector = new ExtensionDetector();
const result = detector.detect();

if (result.detected) {
  console.log("Extensions found:", result.indicators);
  console.log("Confidence:", result.confidence);
}

// continuous monitoring
const monitoringDetector = createExtensionDetector({
  customSelectors: ["[data-custom-ai-extension]"],
  customGlobals: ["__MY_AI_TOOL__"],
});

monitoringDetector.startMonitoring();
// ... extension elements may be injected dynamically
const indicators = monitoringDetector.getMonitoredIndicators();
monitoringDetector.stopMonitoring();

ExtensionDetectorConfig

| Option | Type | Default | Description | |--------|------|---------|-------------| | customSelectors | string[] | [] | Additional DOM selectors to scan | | customGlobals | string[] | [] | Additional global window properties to check | | monitor | MonitorConfig | all enabled | MutationObserver settings |

TextAppearanceDetector

Detects sudden text appearance without corresponding keystroke or paste events.

import { TextAppearanceDetector } from "@slopit/behavioral";

const detector = new TextAppearanceDetector(
  { minTextLength: 50, timeWindowMs: 2000 },
  {
    getRecentKeystrokes: () => keystrokeCollector.getRecentCount(2000),
    hadRecentPaste: () => pasteEvents.some(p => Date.now() - p.time < 2000),
  }
);

detector.startMonitoring(textarea);
// ... user interaction
const result = detector.detect();
if (result.detected) {
  console.log("Suspicious text appearance:", result.indicators);
}
detector.stopMonitoring();

TextAppearanceDetectorConfig

| Option | Type | Default | Description | |--------|------|---------|-------------| | minTextLength | number | 50 | Minimum character count to flag as suspicious | | timeWindowMs | number | 2000 | Time window for checking keystroke activity |

DetectionResult

All detectors return a DetectionResult:

| Field | Type | Description | |-------|------|-------------| | detected | boolean | Whether the detector found evidence | | confidence | number | Confidence score from 0 to 1 | | message | string | Human-readable result description | | indicators | DetectionIndicator[] | Specific indicators found |

Intervention Module

The intervention module triggers responses when suspicious behavior is detected.

InterventionManager

import { InterventionManager, createInterventionManager } from "@slopit/behavioral";

const manager = createInterventionManager({
  triggers: [
    {
      type: "paste",
      threshold: { paste: { minLength: 100, maxPrecedingKeystrokes: 5 } },
      intervention: "warning",
    },
    {
      type: "blur",
      threshold: { blur: { countThreshold: 5, durationThreshold: 30000 } },
      intervention: "friction",
    },
    {
      type: "timing",
      threshold: { timing: { maxCharactersPerSecond: 15 } },
      intervention: "challenge",
    },
  ],
  escalate: true, // escalate on repeated triggers
});

// register callbacks for each intervention type
manager.onIntervention("warning", (result) => {
  alert(result.reason);
});

manager.onIntervention("challenge", (result) => {
  showChallengeDialog();
});

manager.onIntervention("block", (result) => {
  disableSubmitButton();
});

// evaluate behavioral state
const result = manager.evaluate({
  pasteEvents: [{ textLength: 500, precedingKeystrokes: 2 }],
  blurCount: 3,
  totalBlurDuration: 15000,
  charactersPerSecond: 8.5,
});

if (result) {
  console.log("Intervention triggered:", result.type, result.reason);
}

// get intervention history
const history = manager.getHistory();

// reset for new trial
manager.reset();

TriggerConfig

| Field | Type | Description | |-------|------|-------------| | type | "paste" | "blur" | "idle" | "timing" | "composite" | Trigger type | | threshold | object | Threshold values for the trigger type | | intervention | "warning" | "friction" | "challenge" | "block" | Intervention to apply |

Threshold Types

PasteThreshold: | Field | Type | Description | |-------|------|-------------| | minLength | number | Minimum text length to trigger | | maxPrecedingKeystrokes | number | Maximum keystrokes before paste |

BlurThreshold: | Field | Type | Description | |-------|------|-------------| | countThreshold | number | Number of blur events to trigger | | durationThreshold | number | Total blur duration (ms) to trigger |

IdleThreshold: | Field | Type | Description | |-------|------|-------------| | durationMs | number | Idle duration to trigger |

TimingThreshold: | Field | Type | Description | |-------|------|-------------| | maxCharactersPerSecond | number | Maximum typing speed |

Challenges

Built-in challenge implementations for verifying human presence.

TypingTestChallenge

import { TypingTestChallenge, createChallenge } from "@slopit/behavioral";

const challenge = new TypingTestChallenge();
// or: const challenge = createChallenge("typing_test");

const result = await challenge.render(
  {
    type: "typing_test",
    prompt: "Please type the following text exactly as shown:",
    data: { text: "The quick brown fox jumps over the lazy dog." },
    timeLimit: 60000,
  },
  document.getElementById("challenge-container")!
);

console.log("Passed:", result.passed);
console.log("Accuracy:", result.behavioralData?.accuracy);
console.log("Keystrokes captured:", result.behavioralData?.keystrokes.length);

challenge.cleanup();

MemoryRecallChallenge

import { MemoryRecallChallenge } from "@slopit/behavioral";

const challenge = new MemoryRecallChallenge();

const result = await challenge.render(
  {
    type: "memory_recall",
    prompt: "What color was the car in the image you saw earlier?",
    expectedKeywords: ["red", "sedan"],
    timeLimit: 30000,
  },
  document.getElementById("challenge-container")!
);

console.log("Passed:", result.passed);
console.log("Matched keywords:", result.behavioralData?.matchedKeywords);

challenge.cleanup();

Custom Challenges

Implement the ChallengeRenderer interface for custom challenges:

import type { ChallengeRenderer, ChallengeConfig, ChallengeResult } from "@slopit/behavioral";

class AudioTranscriptionChallenge implements ChallengeRenderer {
  private container: HTMLElement | null = null;

  async render(config: ChallengeConfig, container: HTMLElement): Promise<ChallengeResult> {
    this.container = container;
    const audioUrl = config.data?.audioUrl as string;

    // render audio player and text input
    // capture behavioral data during transcription
    // return result when submitted

    return {
      passed: true,
      response: "transcribed text",
      completionTime: 15000,
      behavioralData: { /* captured data */ },
    };
  }

  cleanup(): void {
    if (this.container) {
      this.container.innerHTML = "";
      this.container = null;
    }
  }
}

InputWrapper

A higher-level wrapper that combines capture, detection, and intervention.

import { InputWrapper, createInputWrapper, ExtensionDetector } from "@slopit/behavioral";

const wrapper = createInputWrapper({
  capture: {
    keystroke: { enabled: true },
    focus: { enabled: true },
    paste: { enabled: true },
  },
  detectors: [new ExtensionDetector()],
  detectionInterval: 5000,
});

// wrap an input element
const textarea = document.getElementById("response") as HTMLTextAreaElement;
wrapper.wrap(textarea);

// subscribe to events
wrapper.on("detection", (result) => {
  if (result.detected) {
    console.log("Detection:", result.message);
  }
});

wrapper.on("flag", (flag) => {
  console.log("Flag:", flag.type, flag.severity);
});

// manually trigger detection
const results = wrapper.runDetection();

// get all captured data
const data = wrapper.getData();
console.log("Keystrokes:", data.keystrokes?.length);
console.log("Flags:", data.flags.length);
console.log("Detections:", data.detectionResults.length);

// cleanup
wrapper.unwrap();

InputWrapperConfig

| Option | Type | Default | Description | |--------|------|---------|-------------| | capture | BehavioralCaptureConfig | {} | Configuration for behavioral capture | | interventionManager | InterventionManager | undefined | Optional intervention manager | | detectors | Detector[] | [] | Array of detectors to run periodically | | detectionInterval | number | 5000 | Interval between detector runs (ms) |

Metrics Computation

computeKeystrokeMetrics

import { computeKeystrokeMetrics } from "@slopit/behavioral";

const metrics = computeKeystrokeMetrics(keystrokeEvents, 2000);

console.log("Total keystrokes:", metrics.totalKeystrokes);
console.log("Printable keystrokes:", metrics.printableKeystrokes);
console.log("Deletions:", metrics.deletions);
console.log("Mean IKI:", metrics.meanIKI);
console.log("Std IKI:", metrics.stdIKI);
console.log("Median IKI:", metrics.medianIKI);
console.log("Pause count:", metrics.pauseCount);
console.log("Product-process ratio:", metrics.productProcessRatio);

computeFocusMetrics

import { computeFocusMetrics } from "@slopit/behavioral";

const metrics = computeFocusMetrics(focusEvents);

console.log("Blur count:", metrics.blurCount);
console.log("Total blur duration:", metrics.totalBlurDuration);
console.log("Hidden count:", metrics.hiddenCount);
console.log("Total hidden duration:", metrics.totalHiddenDuration);

computeTimingMetrics

import { computeTimingMetrics } from "@slopit/behavioral";

const metrics = computeTimingMetrics(keystrokeEvents, totalResponseTime);

console.log("First keystroke latency:", metrics.firstKeystrokeLatency);
console.log("Total response time:", metrics.totalResponseTime);
console.log("Characters per minute:", metrics.charactersPerMinute);

Examples

Basic Capture

Minimal setup for text input monitoring:

import { createBehavioralCapture } from "@slopit/behavioral";

const capture = createBehavioralCapture({});
capture.attach(document.querySelector("textarea")!);

document.querySelector("form")?.addEventListener("submit", () => {
  const data = capture.getData();
  // send data with form submission
  capture.detach();
});

Full Configuration

All options enabled with custom thresholds:

import { createBehavioralCapture } from "@slopit/behavioral";

const capture = createBehavioralCapture({
  keystroke: {
    enabled: true,
    captureKeyUp: true,
    includeModifiers: true,
  },
  focus: {
    enabled: true,
    useVisibilityAPI: true,
    useBlurFocus: true,
  },
  paste: {
    enabled: true,
    prevent: false,
    capturePreview: true,
    previewLength: 200,
  },
  clipboard: {
    enabled: true,
    capturePaste: true,
    captureCopyCut: true,
    capturePreview: true,
    previewLength: 200,
  },
  mouse: {
    enabled: true,
    captureClicks: true,
    captureMovement: true,
    throttleMs: 100,
    idleThresholdMs: 10000,
  },
  scroll: {
    enabled: true,
    throttleMs: 150,
    captureDirection: true,
  },
  inputDuration: {
    enabled: true,
    trackKeystrokes: true,
    trackPastes: true,
  },
});

capture.attach(document.querySelector("textarea")!);

Real-time Monitoring

Event subscriptions and live metrics:

import { createBehavioralCapture } from "@slopit/behavioral";

const capture = createBehavioralCapture({});
const textarea = document.querySelector("textarea")!;
capture.attach(textarea);

// real-time keystroke monitoring
capture.on("keystroke", (event) => {
  const metrics = capture.getMetrics();
  updateTypingSpeedDisplay(metrics.timing?.charactersPerMinute ?? 0);
});

// paste detection
capture.on("paste", (event) => {
  if (event.textLength > 100 && event.precedingKeystrokes < 5) {
    showPasteWarning();
  }
});

// focus tracking
capture.on("focus", (event) => {
  if (event.event === "blur") {
    console.log("User left the page");
  }
});

Detection Integration

Using ExtensionDetector and TextAppearanceDetector:

import {
  createBehavioralCapture,
  ExtensionDetector,
  TextAppearanceDetector,
} from "@slopit/behavioral";

const capture = createBehavioralCapture({});
const textarea = document.querySelector("textarea")!;
capture.attach(textarea);

// check for extensions on page load
const extensionDetector = new ExtensionDetector();
const extensionResult = extensionDetector.detect();
if (extensionResult.detected) {
  console.log("AI extensions detected:", extensionResult.message);
}

// monitor for text appearance anomalies
const textDetector = new TextAppearanceDetector(
  { minTextLength: 50 },
  {
    getRecentKeystrokes: () => {
      const data = capture.getData();
      const now = performance.now();
      return data.keystrokes?.filter(k => now - k.time < 2000).length ?? 0;
    },
  }
);
textDetector.startMonitoring(textarea);

// check before submission
document.querySelector("form")?.addEventListener("submit", (e) => {
  const textResult = textDetector.detect();
  if (textResult.detected) {
    e.preventDefault();
    showSuspiciousTextWarning(textResult.indicators);
  }
  textDetector.stopMonitoring();
});

Intervention Setup

Configuring triggers and challenges:

import {
  createBehavioralCapture,
  createInterventionManager,
  TypingTestChallenge,
} from "@slopit/behavioral";

const capture = createBehavioralCapture({});
capture.attach(document.querySelector("textarea")!);

const manager = createInterventionManager({
  triggers: [
    {
      type: "paste",
      threshold: { paste: { minLength: 100, maxPrecedingKeystrokes: 5 } },
      intervention: "warning",
    },
    {
      type: "composite",
      threshold: {
        paste: { minLength: 50, maxPrecedingKeystrokes: 10 },
        blur: { countThreshold: 3, durationThreshold: 10000 },
      },
      intervention: "challenge",
    },
  ],
  escalate: true,
});

manager.onIntervention("warning", (result) => {
  showWarningModal(result.reason);
});

manager.onIntervention("challenge", async (result) => {
  const challenge = new TypingTestChallenge();
  const challengeResult = await challenge.render(
    {
      type: "typing_test",
      prompt: "Please type this sentence to continue:",
      data: { text: "I am completing this survey myself without AI assistance." },
      timeLimit: 30000,
    },
    document.getElementById("challenge-container")!
  );
  challenge.cleanup();

  if (!challengeResult.passed) {
    disableSubmission();
  }
});

// evaluate periodically
capture.on("paste", () => {
  const data = capture.getData();
  const state = {
    pasteEvents: data.paste?.map(p => ({
      textLength: p.textLength,
      precedingKeystrokes: p.precedingKeystrokes,
    })),
  };
  manager.evaluate(state);
});

Event Types

BehavioralCaptureEvents

| Event | Payload | Description | |-------|---------|-------------| | keystroke | KeystrokeEvent | Fired for each keydown/keyup event | | focus | FocusEvent | Fired for blur, focus, and visibility changes | | paste | PasteEvent | Fired when text is pasted | | clipboard | ClipboardCopyEvent | Fired for copy/cut events | | mouse | MouseEvent | Fired for mouse events | | scroll | ScrollEvent | Fired for scroll events | | inputDuration | InputDurationEvent | Fired when input focus sessions end |

License

MIT