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

@bbcd0/payload

v1.2.3

Published

Shared Payload CMS plugins and utilities for bbcd0 projects.

Readme

@bbcd0/payload

Shared plugins and utilities for Payload CMS projects.

Installation

npm install @bbcd0/payload

Requirements

  • Node.js 24
  • Payload CMS >= 3.75.0 and < 4.0.0

Plugins

Cascade relationship cleanup

cascadeRelationshipCleanupPlugin removes references to a collection document after that document is deleted. It prevents stale IDs from remaining in relationship and upload fields across collections and globals.

The plugin:

  • adds an afterDelete hook to every configured collection;
  • detects collections and globals whose fields can reference the deleted collection;
  • handles relationships in regular fields, arrays, blocks, groups, tabs, and nested field layouts;
  • removes a deleted value from hasMany relationships;
  • sets an optional single relationship to null;
  • removes the nearest removable nested row or block when a required relationship can no longer be satisfied;
  • supports polymorphic relationships by checking both relationTo and the referenced ID.

Add the plugin to payload.config.ts:

import { cascadeRelationshipCleanupPlugin } from "@bbcd0/payload/plugins";
import { buildConfig } from "payload";

export default buildConfig({
  collections: [
    // Your collections
  ],
  globals: [
    // Your globals
  ],
  plugins: [cascadeRelationshipCleanupPlugin()],
});

No collection-specific configuration is required. The plugin reads relationship targets from the Payload config.

Required relationships

Payload cannot clear a required relationship on a collection or global document without making that document invalid. When a required relationship exists inside a removable nested structure, such as an array row or block, the plugin removes that nested item. When the relationship is on the root document and no parent item can be removed, the plugin logs a warning and leaves the value unchanged.

Access and error handling

Cleanup uses Payload's Local API with overrideAccess: true so references are removed regardless of the access rules of the affected collection or global.

Errors are logged per cleanup target and do not roll back deletion of the referenced document. This fail-open behavior prevents cleanup failures from blocking normal delete operations, but a failed target can retain stale references and should be investigated through application logs.

Performance

After a document is deleted, the plugin scans every document in each collection that can reference the deleted collection, using pages of 100 documents. It also checks matching globals. This is suitable for modest data sets, but deletion latency can grow with collection size.

Search

searchPlugin keeps a denormalized search index in its own collection and provides an in-memory matcher on top of it. Matching is done by MiniSearch, which ships as a dependency of this package, so prefix search, typo tolerance, field weights and relevance ranking work without an external search engine.

The plugin:

  • adds a hidden index collection, searchIndex unless indexSlug says otherwise, with title, sectionTitle, description and text as searchable fields, plus url, priority and meta for the consumer;
  • adds afterChange and afterDelete hooks to every configured collection and keeps the index in sync with them;
  • lets one document produce several index records, so a long document can be indexed section by section and a result can link to the matching section;
  • denies every REST operation on the index collection: it is written by the hooks and read through the Local API, and accepts overrides for the generated collection config.

Installation

npm install @bbcd0/payload

Add the plugin to payload.config.ts and describe how a document becomes index records:

import { searchPlugin } from "@bbcd0/payload/plugins";
import { buildConfig } from "payload";

export default buildConfig({
  collections: [
    // Your collections
  ],
  plugins: [
    searchPlugin({
      collections: {
        articles: {
          priority: 2,
          toRecords: ({ doc }) => [
            {
              description: doc.description,
              meta: { location: doc.location },
              title: doc.title,
              url: `/articles/${doc.slug}`,
            },
            ...doc.sections.map((section) => ({
              key: section.id,
              sectionTitle: section.title,
              text: section.text,
              title: doc.title,
              url: `/articles/${doc.slug}#${section.id}`,
            })),
          ],
        },
      },
    }),
  ],
});

Import the plugin and toRecords from modules that do not import the Payload config themselves. A barrel file that also exports the search runtime creates a circular import.

The plugin adds a collection, so regenerate types:

payload generate:types

Querying

getPayloadSearch returns the matcher for an index collection, one instance per slug for the whole process. It reads the records through the Local API and reports a version stamp; when the stamp changes, the index is rebuilt on the next query. createSearch and createPayloadSearchSource are available for a custom record source.

import { getPayloadSearch, russianProcessTerm } from "@bbcd0/payload/plugins";

const search = getPayloadSearch({
  fieldWeights: { description: 4, sectionTitle: 6, text: 1, title: 10 },
  payload,
  processTerm: russianProcessTerm,
  staleAfterMs: 5_000,
});

const hits = await search.query("query text", {
  filter: (record) => record.meta?.location !== "archive",
  limit: 200,
});

Every option has a default: prefix search is on, fuzziness is applied to terms of four characters and longer with a distance of 0.2, and minScoreRatio of 0.2 drops hits scoring below a fifth of the best text relevance, which keeps the result count meaningful. Pass fuzzy: false for exact matching or minScoreRatio: 0 to keep every hit.

A hit groups the records of one document: best is the most relevant record, fragments holds every matched record, score includes the priority multiplier and rawScore is the text relevance before priorities. minScoreRatio is applied to rawScore, so a high priority document cannot hide weaker but still relevant matches.

processTerm is applied both when indexing and when querying; returning null drops the term. The package ships Russian support: russianProcessTerm normalizes ё, drops stop words and single characters, and stems the rest with the Porter algorithm. stemRussian and RUSSIAN_STOP_WORDS are exported separately, and another language is a matter of passing its own processTerm.

Rich text

description and text accept a string, a Lexical document, an array of nodes, or anything else built from root, children and text keys. The plugin walks the structure, joins the text nodes, collapses whitespace and stores a plain string capped at 20000 characters, so a rich text field can be passed to toRecords as is:

toRecords: ({ doc }) =>
  doc.sections.map((section) => ({
    sectionTitle: section.title,
    text: section.content,
    title: doc.title,
  })),

The same normalizer is exported as toPlainText for reuse outside the plugin. Markup, formatting and embedded blocks are dropped: only text nodes reach the index.

Rebuilding the index

The hooks index a document when it is saved, so documents that already exist stay out of the index until then. The plugin covers that on onInit: it rebuilds the index when the index is empty, or when indexVersion differs from the version stored on the records. Changing toRecords therefore needs no manual step, only a bumped indexVersion in the same commit:

searchPlugin({
  collections: { articles: { toRecords } },
  indexVersion: 2,
});

Without indexVersion a non-empty index is left alone. rebuildOnInit: true rebuilds on every boot, false never does. A failure is logged and does not break the boot, and the host onInit is called first.

rebuildSearchIndex is exported for the cases the boot hook does not cover, such as a one-off rebuild against another database. It is safe to run at any time:

import { rebuildSearchIndex } from "@bbcd0/payload/plugins";

await rebuildSearchIndex({
  collections: {
    articles: { priority: 2, toRecords },
  },
  payload,
});

Access and error handling

Indexing uses Payload's Local API. Hook failures are logged and swallowed, so a failed index update never breaks a save; the index can drift until the next save or rebuild.

Instances booting at the same time against an empty index all rebuild it. Records of one document are deleted before they are inserted, so the result converges, but a rebuild started by hand while another one is running can produce duplicates until the next rebuild.

Performance

The index lives in the memory of each process, so every instance keeps its own copy and warms it up on the first query. Freshness is checked no more often than staleAfterMs, through a document count and the latest updatedAt. This suits collections of thousands of records; for much larger data sets the whole index no longer belongs in memory.

SmartCaptcha

SmartCaptcha is a utility module, not a plugin that changes Payload collections or hooks. Protect each public request before saving data or queueing jobs. Admin operations and background jobs do not need a browser captcha.

The module has separate entry points:

| Import | Purpose | | -------------------------------------- | ------------------------------------------------------ | | @bbcd0/payload/smart-captcha | Server-side token verification and its types | | @bbcd0/payload/smart-captcha/react | Client-key context, React hook and its types | | @bbcd0/payload/smart-captcha/valibot | Token field schema and a custom-message schema factory |

React 19 is required only by the React entry. Valibot >= 1.2 and < 2 is required only by the Valibot entry. These are optional peer dependencies; install the ones your application uses. The Yandex React SDK ships as a dependency. The React entry preserves "use client" in the build and does not import the server verifier.

Server verification

Import verifySmartCaptchaToken in the request handler. After validating the request body, pass the token, the application's server key, its logger and optionally the visitor IP:

import { verifySmartCaptchaToken } from "@bbcd0/payload/smart-captcha";

const captcha = await verifySmartCaptchaToken({
  serverKey: process.env.YANDEX_SMARTCAPTCHA_SERVER_KEY,
  logger: payload.logger,
  token: input.smartCaptchaToken,
  ip: clientIp,
});

if (!captcha.ok) {
  return Response.json({ error: "captcha" }, { status: captcha.status });
}

Here input is the validated request body, payload is the application's Payload instance, and clientIp is resolved by the application using its trusted-proxy configuration. The verifier does not read environment variables or forwarding headers itself and has no Payload runtime dependency.

Only { ok: true } permits the protected operation. Rejected tokens return 403; a missing key, an unavailable service or an invalid JSON response returns 503. Verification has a three-second timeout and does not retry a token. Never persist the token or expose the server key to the browser.

Verify in the same request that creates the record. A separate public verification endpoint does not protect an unguarded record-creation endpoint.

React forms

Provide the public key above the forms using SmartCaptchaContext. In Next.js, the server layout can pass the application's runtime key to the client context:

import { SmartCaptchaContext } from "@bbcd0/payload/smart-captcha/react";

<SmartCaptchaContext value={clientKey}>{children}</SmartCaptchaContext>;

Inside a client form:

import { useSmartCaptcha } from "@bbcd0/payload/smart-captcha/react";

const { captcha, hasConnectionError, requestToken, cancelTokenRequest } = useSmartCaptcha({ lang: "ru" });

Render captcha inside the form. In its submit handler, await requestToken() and stop submission if it returns undefined; otherwise add the returned value as smartCaptchaToken to the request. Tokens are requested at submission time rather than cached while the visitor fills in the form. lang accepts the SDK's language values and defaults to "ru". hideShield defaults to true. Set it to false to show the SDK's data-processing notice. When hiding it, provide an alternative notice linking to Yandex SmartCaptcha's data-processing terms. test defaults to false. Use useSmartCaptcha({ test: true }) during development to make SmartCaptcha always show a challenge. Do not enable this mode in production.

Call cancelTokenRequest() immediately when closing a dialog, before resetting its form. Unmounting the hook also settles a pending request. Old widget events cannot complete a newer request. Challenge dismissal and network errors allow another attempt. Once the SDK is loaded, JavaScript errors affect only their own widget: a pending request ends without a token, and the widget can be recreated for another attempt. A broken idle widget is replaced on the next submit, not in an automatic remount loop.

Script-loading errors while the SDK is unavailable fail current and later token requests for the page lifetime because the SDK caches script loading. Reload the page after resolving that failure.

hasConnectionError reports script-loading failures and SDK network errors so the form can show connection guidance and a page-reload action instead of a generic submission error. Script-loading failure is shared by current and later hook consumers and persists until reload; a widget's network error clears when a new token request starts. Dismissal, cancellation, missing client keys, and widget JavaScript errors while the SDK is available do not set this flag. It does not detect VPN use or report server-side token-verification failures.

Valibot schemas

Keep the form schema separate from the API schema: the form validates contact fields, while the API also requires the transport token.

import {
  SMART_CAPTCHA_TOKEN_FIELD_SCHEMA,
  createSmartCaptchaTokenFieldSchema,
} from "@bbcd0/payload/smart-captcha/valibot";
import * as v from "valibot";

const CONTACT_FORM_SCHEMA = v.object({
  email: v.pipe(v.string(), v.email()),
});

const CONTACT_API_SCHEMA = v.object({
  ...CONTACT_FORM_SCHEMA.entries,
  smartCaptchaToken: SMART_CAPTCHA_TOKEN_FIELD_SCHEMA,
});

const ENGLISH_TOKEN_FIELD_SCHEMA = createSmartCaptchaTokenFieldSchema("Please confirm that you are not a robot");

The default schema rejects non-string and empty tokens. The factory changes the empty-token error message; the default is "Не удалось подтвердить, что вы не робот".

Development

npm install
npm run test
npm run typecheck
npm run fmt
npm run lint
npm run build

When testing a consuming pnpm application with a local file: dependency, rebuild this package with npm run build, then run pnpm update @bbcd0/payload in the application to refresh its installed snapshot. pnpm install alone can keep the previous build.

Local paths are for development. Before deploying the application, publish the package and replace its local dependency with the published version.