@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.
Maintainers
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 viteUsage
// 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 (requiresvite, and the admin UI's source, which a published tarball does not ship). Returns{ nodeMiddleware }.{ mode: "auto", distDir, sourceDir }—vite-devwhenNODE_ENV !== "production",staticotherwise.
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— purecreateMediaUrl(mediaConfig), re-exported from@stelstone/blocks/media-urlso the admin can size thumbnails with the same arithmetic factory for building CDN URLs in your site code. Noexpressdependency, 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.jsonPublish 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-draftsonce (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.mjsSyncs 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.mjsWalks 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 runbuild-indexin 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
resolvehook backed by D1, KV, Algolia, Pagefind, or another external index.
