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

@stelstone/server

v0.48.0

Published

Runtime-agnostic CMS server built on the Web Fetch API, with pluggable adapters for content, media, auth, and build.

Readme

@stelstone/server

Express-based CMS server with pluggable adapters for content storage, media, auth, and build-status. Designed to be mounted from any Node process or — via stelstone — directly into astro dev.

The server is glue code: routes, JSON wiring, error handling. All behavior comes from adapter factories you instantiate from your cms.config.mjs.

Install

npm i @stelstone/server
# Optional, only needed for the dev-mode admin UI middleware:
npm i -D vite

Usage

// scripts/admin-server.mjs
import path from "path";
import { fileURLToPath } from "url";
import { startCmsServer } from "@stelstone/server";
import cmsConfig, { publicConfig } from "../cms.config.mjs";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.join(__dirname, "..");

await startCmsServer({
  config: cmsConfig,
  publicConfig,
  rootDir: ROOT_DIR,
  realm: "My Site Admin",
  // Optional. Left out, the server resolves the admin UI itself — source when
  // it is present, the built bundle otherwise (see `resolveAdminUiOptions`).
  adminUi: {
    mode: "auto", // vite-dev when NODE_ENV !== "production", static otherwise
    distDir: path.join(ROOT_DIR, "node_modules/@stelstone/admin-ui/dist"),
    sourceDir: path.join(ROOT_DIR, "node_modules/@stelstone/admin-ui"),
  },
});

Most sites need none of this: npx stelstone reads cms.config.mjs, finds the admin UI and serves it. Reach for the API when you are embedding the CMS in a server you already have.

API

createCmsServer({ config, rootDir, publicConfig?, realm? })

Returns { handle, middleware, adapters } — handle is a Fetch handler (Request → Response | null), middleware the same thing adapted to Node's (req, res, next). Nothing listens; you mount it where you like.

resolveAdminUi(options)

Turns an admin UI choice into something mountable. Modes:

  • { mode: "static", dir } — serves a prebuilt SPA bundle. Returns { fetchHandler }.
  • { mode: "vite-dev", root, base? } — dev middleware with HMR (requires vite, and the admin UI's source, which a published tarball does not ship). Returns { nodeMiddleware }.
  • { mode: "auto", distDir, sourceDir } — vite-dev when NODE_ENV !== "production", static otherwise.

Either mode answers /admin with a 301 to /admin/. The SPA links its bundle relatively, so without the trailing slash a browser resolves ./assets/… against /admin, gets a 404, and shows a blank page with nothing in the console to explain it.

resolveAdminUiOptions({ dev?, previewThemeCss?, onWarn? })

The source-then-dist decision, shared so callers cannot drift: the admin UI's source when it is there and dev is set, the built bundle otherwise, null when neither exists. startCmsServer and stelstone both use it.

startCmsServer(opts)

Convenience: builds the handler, mounts the admin UI, listens. Returns { server, handle, adapters, stopScheduler }.

Subpath exports

  • @stelstone/server/media-url — pure createMediaUrl(mediaConfig), re-exported from @stelstone/blocks/media-url so the admin can size thumbnails with the same arithmetic factory for building CDN URLs in your site code. No express dependency, safe to import from Astro components.
  • @stelstone/server/adapters — direct access to each adapter factory if you want to compose your own server.

Routes

| Method | Path | Purpose | | ------- | -------------------------------------- | ------------------------ | | GET | /api/config | sanitized publicConfig (allowlisted before auth) | | GET | /api/collections | collection summaries | | GET | /api/collections/:c | list entries | | GET | /api/collections/:c/:file | read entry | | PUT | /api/collections/:c/:file | update entry | | POST | /api/collections/:c | create entry | | DELETE | /api/collections/:c/:file | delete entry | | POST | /api/collections/:c/:file/publish | publish ONE record — only its file is committed (501 on backends that publish on save) | | GET | /api/links | every page's public path, for the link field's picker | | GET | /api/assets | grouped local-assets list (allowlisted before auth) | | GET | /api/assets/file?path= | one repository image's bytes, for thumbnails (public; assetsDir only) | | GET | /api/assets/:folder | files in one local folder | | POST | /api/assets/upload | upload into assetsDir — on disk, or a commit via the GitHub API | | GET | /api/media/folders | CDN folders (proxy) | | GET | /api/media/folder/:folder | CDN files (proxy) | | POST | /api/media/upload | upload to CDN (proxy) | | POST | /api/publish | git commit + push (everything under publishPaths) | | GET | /api/publish/status | pending changes, per file, plus perEntryPublish capability | | GET | /api/content/status | can the CMS reach its content repo, and if not why (missing-token, bad-token, no-repo-access, read-only, no-branch, rate-limited, unreachable); never includes the token | | GET | /api/deploy/status | Netlify deploy status |

Adapters

All under src/adapters/. Each is a pure factory; no module-scoped state.

| Factory | Implements | Notes | | ------------------------ | ----------------- | -------------------------------------- | | createFsJsonContent | ContentAdapter | JSON files on disk + git push | | createLocalAssetsMedia | MediaAdapter | assetsDir images on disk | | createGitHubAssetsMedia| MediaAdapter | assetsDir images via the Git Data API; uploads commit to the deploy branch with [skip ci] | | createCdnProxyMedia | MediaAdapter | proxies CloudFront/S3 listing + upload | | createBasicAuth | AuthAdapter | HTTP Basic + HMAC-SHA256 JWT for media | | createNetlifyBuild | BuildAdapter | Netlify deploy status | | createMediaUrl | (URL helper) | cdnBase + resize-prefix → URLs |

JSDoc contracts in src/adapters/types.mjs. Swap in your own implementation by passing different adapter instances; the server glue is agnostic.

Collection listing & the content index

The problem

Listing a collection traditionally fetches every entry's full JSON via GraphQL or a fallback REST approach (one request per file). On Cloudflare Workers this exceeds the 50-subrequest limit. On GitHub's GraphQL, large collections return 502 errors. The solution: a lightweight per-collection _index.json manifest containing only entry metadata (id, slug, lang, collection, title, and brief meta). The server reads one manifest per collection instead of fetching every entry.

How _index.json works

Each collection maintains a manifest at <pagesDir>/<collection>/_index.json:

{
  "entries": [
    {
      "id": "entry-1",
      "slug": "my-first-post",
      "lang": "en",
      "collection": "blog",
      "title": "My First Post",
      "file": "entry-1.json",
      "meta": { /* custom fields from metaFields */ }
    }
  ]
}

The index is maintained incrementally: createPage, writePage, and deletePage upsert or remove entries. On batch operations, writeBatch regenerates the _index.json in the same commit. Normal CMS edits keep the index in sync automatically — no rebuild needed. A collection that has files but no manifest yet (records pushed from code) gets one built from the directory on its first list or write, so nothing already there is left out.

Forms — config.mail + config.forms

The replacement for Netlify Forms, served by the same handler on both runtimes (POST /api/forms/:name, public). Delivery is Resend; the sender domain must be verified there (SPF/DKIM) or mail will not arrive.

mail: { from: "Site <[email protected]>" },   // keyEnv: "RESEND_API_KEY" is the default
forms: {
  iletisim: {
    to: "[email protected]",
    subject: (fields) => `Mesaj — ${fields["Ad ve Soyad"]}`,  // optional
    replyTo: "E-Posta",          // reply-to comes from this field
    redirect: "/tesekkurler/",   // plain HTML posts get a 303 here
    honeypot: "bot-field",       // default
    turnstile: false,            // true → verify cf-turnstile-response (TURNSTILE_SECRET)
  },
},
<form method="POST" action="/api/forms/iletisim">
  <p hidden><input name="bot-field" tabindex="-1" autocomplete="off" /></p>
  <input name="Ad ve Soyad" required />
  <input name="E-Posta" type="email" required />
  <textarea name="Mesaj" required></textarea>
  <button>Gönder</button>
</form>

Gates, in order: unknown form → 404 · filled honeypot → silent 200, nothing delivered (an error would teach the bot which field to skip) · size limits (30 fields, 5000 chars/field) → 400 · per-IP rate limit (5/min; in-memory on Node, KV-backed on Workers) → 429 · unconfigured mail → 503 · delivery failure → 502. JS-driven forms get JSON; plain HTML posts get the redirect.

Serving the Worker admin from the site's own address

A deployed CMS Worker answers at <name>.workers.dev, which is neither an address to hand a client nor same-origin with the site. Pick by where the site is hosted; the first two make siteadi.com/admin literal, which also ends CORS configuration — auth headers never travel cross-origin:

| Site hosting | Setup | | --- | --- | | Cloudflare | Attach a route to the Worker: siteadi.com/admin* (and /api/*). True same-origin. | | Netlify | Proxy in _redirects: /admin/* https://<name>.workers.dev/admin/:splat 200 (same for /api/*). Netlify proxies server-side; the browser sees one origin. | | Elsewhere | Give the Worker a custom domain (admin.siteadi.com). Not same-origin, but presentable; keep cors.origin pinned to the site. |

workers.dev is a fallback for testing, not an address to ship.

Configuration: content.draftBranch — saving without publishing

Without it, this backend commits every save straight to content.branch; when CI deploys that branch, saving a live page publishes it instantly — there is no "work on it, publish when ready". Set a draft branch and the model becomes the fs backend's: saves land on the draft branch, the site keeps serving the published version, and Publish (the whole site or one record) moves files across with a single commit.

content: {
  provider: "github",
  branch: "main",             // what CI deploys
  draftBranch: "cms-drafts",  // where saves go; created automatically
  // ...
}

Two things to know: the _index.json manifests live on the draft branch and are never published (the site build ignores _-prefixed files), and content edits pushed to main outside the CMS will show up in pendingChanges as a diff — with a draft branch configured, content should change through the CMS. Config validation warns when the github backend runs without a draft branch.

Configuration: content.draftHistory — what the draft branch keeps

Each time a server starts, it merges content.branch into the draft branch so the admin sees what changed outside the CMS. Nothing ever merges back — Publish is a plain commit on content.branch — so the draft branch gains one merge commit per change to the published branch and never gets shorter. It works; in git log --graph --all it is a slope that grows for as long as the site lives.

content: {
  provider: "github",
  branch: "main",
  draftBranch: "cms-drafts",
  draftHistory: "squash",     // "commits" (default) | "squash"
}

| Value | The draft branch is | Revision history shows | | --- | --- | --- | | "commits" (default) | every save as a commit, every sync as a merge | every save, each one restorable | | "squash" | content.branch plus one commit holding the pending drafts, rewritten after each sync | every save's author and time; only published versions and the current draft can be restored |

In squash mode the commits that said who saved what are gone, so that is kept in commit messages instead, one line per save:

cms: unpublished drafts

Saved-by: Ada Lovelace <[email protected]>; 2026-09-28T11:37:28Z; src/pages-data/blog/tr-a.json
Saved-by: Bora Kaya <[email protected]>; 2026-09-28T12:30:54Z; src/pages-data/blog/tr-a.json

Publish copies the lines of the files it publishes into its own commit on content.branch, with a Co-authored-by: for each author who is not the publisher — so the published branch's history says who wrote what was published, permanently.

What squash mode costs, and why it is opt-in:

  • An unpublished intermediate save cannot be restored. History still lists it, marked as a record only. Saves made since the last sync are still real commits and restorable until the next one.
  • The rewrite is a forced ref update, and GitHub has no compare-and-swap for that. The branch head is read again just before; if a save moved it, the rewrite is skipped until the next sync. A save landing in the instant between that read and the update is lost.
  • Saves made before the option was switched on name no files in their commit message, so the automatic squash leaves them out of the log. Run flatten-drafts once (below) to bring them in.

Switching back to "commits" needs nothing: the branch just stops being rewritten.

One-off cleanup: the flatten-drafts CLI

npx stelstone flatten-drafts --config ./cms.config.mjs

Syncs the draft branch with content.branch and rewrites it as that branch plus one commit — the same thing squash mode does after a sync, on demand and whatever draftHistory is set to. Use it to clear the merges a branch collected before squash mode was on, or to start a draft branch over from a known shape. No draft changes: the new commit points at the tree the branch already has.

Unlike the automatic squash it reads each old save commit to learn which file it touched (one request per commit), so earlier saves reach the Saved-by: log. It needs the GitHub token in the environment, exits non-zero and changes nothing when the two branches conflict or the draft branch was written to while it ran, and has the same costs as squash mode — run it when nobody is editing.

Configuration: the content.list block

In cms.config.mjs, configure listing behavior under content.list:

content: {
  provider: "github",
  // ...
  list: {
    strategy: "index",        // default; reads _index.json
    rebuild: "build",         // "build" (default) | "lazy"
    indexFile: "_index.json", // manifest filename
    resolve: undefined,       // optional: custom listing function
  },
}

| Option | Values | Description | | --------- | ----------------------- | ----------- | | strategy | "index" | Built-in strategy: reads the _index.json manifest. Only option currently shipped. | | rebuild | "build" (default), "lazy" | What happens to a manifest the server has reason to distrust. A missing manifest is rebuilt from the directory (one GraphQL request) and persisted under both — on the first list, and on the first write into a collection whose files arrived from code. The two differ after a draft-branch sync merges files in: "build" rebuilds the touched manifests at once, "lazy" on each collection's next list. Very large collections can exceed what one GraphQL request returns; commit an index built by the CLI so the rebuild never has to run. | | indexFile | string | Override the manifest filename (default: _index.json). | | resolve | async (collection, { sortConfig }) => entries[] \| null | Optional: bring your own listing logic. Completely replaces the built-in strategy. Return null for unknown collections. Plug in D1, KV, Algolia, Pagefind, or any external index here — this is the scale/search extension point. |

Out-of-band rebuild: the build-index CLI

Regenerate all _index.json manifests locally:

npx stelstone build-index --config ./cms.config.mjs

Walks the local pagesDir, regenerates _index.json for every collection, and prints per-collection entry counts. Commit the result.

When to run:

  • Once when adopting the index on an existing repo.
  • After bulk imports or migrations.
  • After content changed outside the CMS (e.g., direct git edits).
  • Recommended in CI/deployment for serverless: build and commit the index locally or in your CI pipeline, so the Worker only ever reads it (pairs with rebuild: "build").

Quick decision guide

  • Small site or self-hosted: rebuild: "lazy" keeps setup simple.
  • Serverless (Cloudflare Workers) or large collections (default, recommended): Use rebuild: "build" and run build-index in your build/deploy pipeline. Commit the index and the Worker reads it once per request — zero surprise subrequests.
  • Real full-text search, faceting, or huge scale: Provide a resolve hook backed by D1, KV, Algolia, Pagefind, or another external index.