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

@vetox-bot/discord-inviter

v1.0.0

Published

Invite tracking for discord.js v14: which invite a member joined with, with bounded memory, paced REST use and a clean shutdown.

Readme

@vetox-bot/discord-inviter

npm CI license

Invite tracking for discord.js v14: which invite, vanity URL or bot authorization every new member came through — with bounded memory, paced REST use and a clean shutdown.

Developed and maintained by Vetox for bots that run for weeks at scale: a vanity URL read never holds a guild's invite list while it waits on Discord's rate limiter, every request settles at a timeout, and memory stays flat through join storms.

Not affiliated with Discord.

Install

npm install @vetox-bot/discord-inviter discord.js

Node.js 22.12 or later and discord.js 14.16 or later.

The client needs the Guilds, GuildInvites and GuildMembers intents. The bot needs Manage Server in a guild to read its invites and vanity URL, and View Audit Log to attribute bot joins. Guilds where it lacks them are skipped without a request.

Quick start

import { Client, GatewayIntentBits } from 'discord.js';
import { InviteTracker } from '@vetox-bot/discord-inviter';

const client = new Client({
  intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildInvites, GatewayIntentBits.GuildMembers],
});
const tracker = new InviteTracker(client);

tracker.on('guildMemberAdd', (member, inviter, invite, error) => {
  if (error) return console.error(error);
  if (invite?.source === 'invite') console.log(`${member.user.tag} joined with ${invite.code} from ${inviter?.tag}`);
  else if (invite?.isVanity) console.log(`${member.user.tag} joined through the vanity URL`);
});

tracker.on('error', (guild, error) => console.error(guild?.id, error));

await client.login(process.env.DISCORD_TOKEN);

CommonJS works too: const { InviteTracker } = require('@vetox-bot/discord-inviter');.

Events

guildMemberAdd

(member, inviter, invite, error)

| Argument | Type | Meaning | | --------- | ---------------------- | --------------------------------------------------------------------- | | member | GuildMember | The member who joined. | | inviter | User \| null | Who invited them, when known and cached. | | invite | JoinInvite \| null | How they joined. Null only together with an error. | | error | Error \| false | false on success. |

JoinInvite:

| Field | Type | Meaning | | ----------- | ---------------- | ---------------------------------------------------------------------------- | | source | 'invite' \| 'vanity' \| 'audit-log' \| 'unknown' | How the join was attributed. | | code | string \| null | The invite or vanity code. | | url | string \| null | https://discord.gg/<code>. | | uses | number \| null | Uses of that invite after this join. | | count | number \| null | Uses of every invite the inviter owns (vanity: the vanity URL's uses). | | inviterId | string \| null | The inviter's id, also when the User is not cached. | | isVanity | boolean | Joined through the vanity URL. |

A join is attributed to the first invite whose use count rose since the last snapshot, else to an invite the snapshot did not have that now has exactly one use, else — when the guild has a vanity URL — to the vanity URL if its use count rose. A bot is attributed to whoever authorized it, from the last five bot additions in the audit log. Anything else is source: 'unknown'.

Until the warm-up has read a guild there is no snapshot to compare against, so only the single-use rule applies.

A vanity join is recognized only when a fresh vanity read can run for it — not while another read for that guild is in flight or an earlier request is still unsettled, not within vanity.minIntervalMs of the last read or during backoff after failures, and not while vanity.maxConcurrent reads are in flight. A rise in uses shows that someone joined through the vanity URL, not who, so a join is credited only when no other join the tracker has seen could account for it. Otherwise it is source: 'unknown' rather than a guess — the price of never queueing behind Discord's rate limit on that endpoint.

error

(guild, error) — a request failed for a reason other than a timeout or shutdown, Discord returned a malformed response, or a guildMemberAdd listener or an overridden handler threw or rejected. It is emitted only while someone listens. An error listener that throws or rejects is ignored, so no listener can break the tracker or leave an unhandled rejection.

Options

new InviteTracker(client, options?)

| Option | Default | Meaning | | --------------------------- | ----------- | -------------------------------------------------------------------------------------- | | trackMemberAdd | true | Listen for joins and emit guildMemberAdd. Turn off to attribute joins yourself. | | trackVanity | true | Read vanity URLs to attribute vanity joins. | | auditLogBots | true | Attribute bot joins from the audit log. | | unknownInviter | 'none' | 'owner' credits unattributed joins to the guild owner. | | warmup | {} | Fetch every readable guild once the client is ready; false to skip. | | warmup.guildsPerBatch | 5 | Guilds fetched per batch. | | warmup.batchIntervalMs | 1000 | Pause between batches. | | requestTimeoutMs | 15000 | Upper bound on each invite-list, audit-log or guild-owner request. | | vanity.minIntervalMs | 30000 | Minimum time between two vanity reads for one guild. | | vanity.timeoutMs | 10000 | A vanity read is abandoned after this. | | vanity.maxConcurrent | 2 | Vanity reads in flight at once, across all guilds. | | vanity.maxBackoffMs | 3600000 | Longest wait after repeated vanity failures. The wait doubles from minIntervalMs, so there is none when that is 0. |

Invalid values throw at construction: TypeError for a wrong type, RangeError for a value out of range. Timeouts and the batch interval are capped at 2147483647 ms, the longest timer Node.js keeps.

At the defaults the warm-up reads 5 guilds a second, so a bot in 10,000 guilds has every snapshot after about 35 minutes. Sharded bots share Discord's 50 requests/second global limit across every process: keep guildsPerBatch × 1000 / batchIntervalMs × processes well below it.

API

InviteTracker

| Member | Description | | ------------------------------------- | ----------------------------------------------------------------------------------------------- | | invites: Map<string, TrackedInvite[]> | Per guild, the invites as of the last fetch: the baseline joins are compared against. | | invitesVanity: Map<string, VanitySnapshot> | Per guild, the vanity URL as of the last successful read. | | stats | { guilds, invites, vanityGuilds, pendingRefreshes, lockedGuilds, vanityReadsInFlight, vanityRequestsOutstanding }. | | destroyed | True after destroy(). | | withGuildLock(guildId, task) | Runs task after every earlier task for that guild settled. Not re-entrant; see below. | | getInvites(guildId) | A guild's snapshot, or undefined. | | fetchInvites(guild) | The guild's current invites under the tracker's bounds; null without Manage Server, on failure, or after requestTimeoutMs. | | resolveInvites(guild, invites) | Stores invites (a Collection, Map or array) as the guild's snapshot, synchronously. | | refresh(guild) | Re-reads a guild's invite list; concurrent calls are coalesced. | | ready() | Runs the warm-up (also triggered by clientReady). | | tempInvites(invite) | Converts one invite into a snapshot entry. | | destroy() | Detaches from the client, stops waiting on in-flight requests, clears state. Idempotent. | | static getMemberInvites(member) | { count, codes } of the invites a member owns. | | static getTopInvites(guild) | Inviters ranked by total uses: [{ user, count, codes }]. | | static getInfo(client, code) | Any invite's public data, or null. |

TrackedInvite holds code, uses, maxUses and inviterId; url and inviter are computed on read. It never holds a discord.js object, so a snapshot cannot keep users or invites alive.

inviteTracker is exported as an alias of InviteTracker, and the event handlers (memberAdd, inviteUpdate, guildCreate, guildDelete, ready) are overridable methods.

The static helpers are plain one-shot calls with no timeout of their own.

findUsedInvite(baseline, current)

The attribution rule as a pure function, for code that fetches invites itself.

Attributing joins yourself

Turn off the built-in handler and do the fetch, the comparison and the snapshot update inside withGuildLock, so your writes and the tracker's refreshes never overwrite each other with an older list. fetchInvites keeps the lock bounded: it skips guilds without Manage Server and gives up after requestTimeoutMs. The lock is not re-entrant: inside a task, never await refresh(), ready() or memberAdd() for the same guild — each queues behind the task that waits for it.

import { InviteTracker, findUsedInvite } from '@vetox-bot/discord-inviter';

const tracker = new InviteTracker(client, { trackMemberAdd: false, trackVanity: false });

client.on('guildMemberAdd', async member => {
  try {
    const used = await tracker.withGuildLock(member.guild.id, async () => {
      const current = await tracker.fetchInvites(member.guild);
      if (!current) return null;
      const hit = findUsedInvite(tracker.getInvites(member.guild.id), current.values());
      await tracker.resolveInvites(member.guild, current);
      return hit;
    });
    // use `used` (an Invite, or null)
  } catch (error) {
    // discord.js re-emits a rejected listener as the client's `error` event, which ends the process when nothing
    // listens to it
    console.error(error);
  }
});

Memory and rate limits

  • Nothing waits on Discord while holding an invite list. A snapshot is replaced synchronously; the fetched list is released before any other request.
  • Vanity reads are bounded. Discord rate-limits the vanity endpoint hard, and @discordjs/rest waits out a 429 and retries without limit. Here each read settles within vanity.timeoutMs whatever the REST client does, reads are spaced and back off after failures, and at most vanity.maxConcurrent are in flight at once. A request the REST client still holds after its read gave up blocks a new read of its own guild only — never the others — so there is at most one per guild; stats.vanityRequestsOutstanding counts them.
  • One writer per guild at a time. Joins, refreshes and the warm-up of a guild are serialized, so an older list can never overwrite a newer one; invite events that arrive while a refresh waits share it.
  • No request that must fail. Guilds without Manage Server are skipped instead of answering 403 — invalid requests count toward Discord's invalid-request limit.
  • Every request is bounded. Each request the tracker makes settles within requestTimeoutMs (vanity reads: vanity.timeoutMs) or at destroy(), even while @discordjs/rest sleeps out a rate limit. Its REST requests leave the queue when they give up; the discord.js manager calls it makes — the invite fetch for a join, the audit log, the guild owner — take no signal, so discord.js finishes them in the background and the tracker discards the result.
  • Clean shutdown. destroy() removes the tracker's client listeners and stops waiting on its requests; nothing it started records a snapshot or emits afterwards. stats.vanityRequestsOutstanding keeps counting requests the REST client still holds, down to zero as it lets go of them.
  • No cache it did not ask for. Invites are read with cache: false or from the raw API, so guild.invites.cache stays as your own code leaves it. The invite fetch for a join adds each inviter to client.users.cache, as every discord.js invite fetch does.

Contributing

Issues and pull requests are welcome — see CONTRIBUTING.md. Report security issues as described in SECURITY.md.

License

MIT © Vetox Inc.