@velora-cms/client
v0.14.0
Published
Typed client for Velora's public REST API — content, media, document types, and templates, over a pluggable transport.
Readme
@velora-cms/client
A typed client for reading and writing Velora CMS content — content, media, document types, and templates — from any code. One API surface, three ways to hold it.
npm install @velora-cms/clientThree ways to hold it
1. Inside your own Velora server. A running Velora server exposes an
already-connected handle at app.velora — no URL, no API key, nothing to
configure. Add a route in your project's server.js and call it directly:
app.get("/blog", async (request, reply) => {
const posts = await app.velora.content.list();
return reply.view("blog", { posts: posts.items });
});2. Remotely, with an API key. Anywhere else — a serverless function, a
mobile app's backend, a separate service — point createClient at the
site's URL with a key minted in the admin under Settings:
import { createClient } from "@velora-cms/client";
const velora = createClient({
url: "https://your-site.com",
apiKey: process.env.VELORA_API_KEY,
});
const home = await velora.content.getByPath("/");3. Remotely, without a key. If the site has turned on
VELORA_PUBLIC_READS, reads work with no key at all — handy for a static
frontend calling the API straight from the browser:
const velora = createClient({ url: "https://your-site.com" });
const home = await velora.content.getByPath("/");All three return the exact same typed VeloraClient — the same methods,
the same shapes, the same errors — so code written against one works
against the others unchanged.
Quick start
import { createClient } from "@velora-cms/client";
const velora = createClient({ url: "https://your-site.com", apiKey });
// Fetch one item by id or by its published path.
const page = await velora.content.getById("f47a...");
const about = await velora.content.getByPath("/about");
// Locale-aware reads: fall back from a translation to a base locale.
const frPage = await velora.content.getById("f47a...", { locale: "fr", fallback: "en" });
// List and search — every list result is one page plus a lazy walker.
const results = await velora.content.list({ search: "release notes" });
for await (const item of results.all()) {
console.log(item.path);
}
// Writes need a write-scope key.
const draft = await velora.content.create({
documentTypeId: "...",
parentId: null,
slug: "hello-world",
data: {},
});
await velora.content.update(draft.id, { data: { title: "Hello, world" } });
await velora.content.publish(draft.id);
// Delete moves content to the recycle bin — history is preserved; restore
// from the admin. A node with children rejects the call (409) unless you
// pass { cascade: true }, which deletes it and every descendant with it.
await velora.content.delete(draft.id);Media
Reading the library works with a read key (or no key, under
VELORA_PUBLIC_READS); uploading, updating, and deleting a file all need a
write key, the same as the content write methods above:
// List and search, same page-plus-.all() shape as content.
const files = await velora.media.list({ search: "logo" });
for await (const file of files.all()) {
console.log(file.filename, velora.media.urlFor(file));
}
// Upload takes the raw bytes plus a filename; visibility defaults to
// "private" when you don't pass one.
const upload = await velora.media.upload(fileBytes, {
filename: "logo.png",
contentType: "image/png",
visibility: "public",
});
// Flip visibility later — v1 patches visibility only.
await velora.media.update(upload.id, { visibility: "private" });
await velora.media.delete(upload.id);A public file's URL works for anyone; a private file's URL carries a
short-lived token and 404s without it. media.urlFor(file) always returns
the right one — it's the downloadUrl the server already computed for that
item (fresh on every read for a private file), not a URL this package
builds itself, so always call it on a file you just fetched or uploaded
rather than caching the URL past a visibility change.
Binding defaults with with()
with() returns a derived client with locale/fallback bound, so you
don't have to repeat them on every call — a per-call option still wins:
const fr = velora.with({ locale: "fr", fallback: "en" });
const page = await fr.content.getByPath("/about"); // reads as fr, falls back to enErrors
A failed call rejects with one of two shapes. An HTTP-level failure — any
non-2xx response, from any transport — is a VeloraApiError carrying the
server's status and message. A transport-level failure — no network to
reach, or (over app.velora) the in-process call itself throwing — is a
plain Error instead, with no status. A content create/update rejected
for failing field validation also carries fieldFailures, but check its
length, not just whether it's set: a whole-record failure (an unknown
document type, a tree rule) carries an empty array there and puts the real
reason in message instead. Full detail:
https://docs.velora-cms.com/v1/using-velora/client/#errors.
import { VeloraApiError } from "@velora-cms/client";
try {
await velora.content.update(id, { data: { title: "" } });
} catch (error) {
if (!(error instanceof VeloraApiError)) throw error; // network/transport failure
if (error.fieldFailures?.length) {
for (const failure of error.fieldFailures) {
console.log(failure.fieldId, failure.key);
}
}
}An upload past the site's configured size limit rejects the same way, with
status 413 and a message that leads with a plain-language hint before
the server's own:
try {
await velora.media.upload(hugeFile, { filename: "movie.mp4" });
} catch (error) {
if (error instanceof VeloraApiError && error.status === 413) {
// error.message starts with "This file exceeds the site's upload limit."
}
}Security model
- Read key — published content only, safe to use even in a browser. Drafts are indistinguishable from nonexistent content to any keyed caller.
- Write key — server-side only, ever. Never ship a write key to a browser, mobile app, or any client you don't control.
- The in-process handle (
app.velora) — trusted because it is the site's own server process; no token exists for it, and none is needed.
Recommended pattern for a browser frontend: don't call the remote API
directly with a write key. Instead, the browser talks to your own routes;
your routes validate the request, then call app.velora.content.* (or
app.velora.media.*) themselves.
Custom transports
Every method in the surface is built on a single Transport function —
(request) => response. createClient uses the built-in fetch transport;
createClientFromTransport accepts any transport, which is how
@velora-cms/server wires up the in-process app.velora handle without a
network hop, and how this package's own tests stub a server without
running one.
Source & support
Source: velora-starter. Docs: https://docs.velora-cms.com. Apache-2.0.
