
Security News
Rust 1.100 Will Add Dependency Cooldowns to Cargo
Rust 1.100 will add opt-in dependency cooldowns to Cargo, giving security scanners and maintainers time to catch malicious crate releases.
@mapled/client
Advanced tools
Mapled's JavaScript client — read published content from any JavaScript runtime: a browser, Node, an edge function. Preview, publish notifications and forms included; no framework, no dependencies.
The JavaScript client for Mapled, the hosted headless CMS for sites built with AI. It reads published content from any JavaScript runtime — a browser, Node, an edge function — and has no framework in it and no dependencies.
@mapled/react, which is built on this package.@mapled/next instead: the same reads, plus ISR cache tags, draft mode and the webhook handlers.@mapled/vanilla: one script tag and data-mapled-* attributes, with this package inside.npm install @mapled/client
// src/content.ts
import { createClient } from "@mapled/client";
const mapled = createClient({ key: "mk_live_…" });
const home = await mapled.getSingle("homepage");
const { records, total } = await mapled.getRecords("posts", { sort: "-date", limit: 10, expand: ["author"] });
const post = await mapled.getRecordBySlug("posts", "hello-world");
The key is the project's delivery key (Mapled: Integrations → Your site). It reads published content of collections with access Public and nothing else, so it is the one key that is safe in a browser — in a Vite app keep it in .env as VITE_MAPLED_KEY. The server key and the webhook signing secret are never safe there.
getSingle(key) — the record of a single, or null while it is empty.getRecords(collection, options) — { records, total, release }. Options: filter ({ featured: true, price: { gte: 10, lt: 100 }, tag: { in: ["a", "b"] }, title: { contains: "an" } }), sort ("date" / "-date"), limit (1–100), offset, search (full text, below), fields, expand (relation fields, dotted for a second level — linked records arrive under record.expanded; a relation inside a components field's blocks, "sections.picks", arrives under record.expandedBlocks.sections, one entry per block in the list's order).getRecord(collection, id) / getRecordBySlug(collection, slug) — one record, or null when it isn't published.getRelease() — { release, publishedAt } of what is being served now, null before the first publish.release: N to read a published release by its number, locale: "ru" to read in one of the project's languages (a translated field answers in it, or in the first language of the project's fallback chain that has a value; the answer names the language it is in as locale), and signal to abandon it. createClient({ key, locale: "ru" }) reads everything in that language; a language the project doesn't have is a 400 INVALID_QUERY.A record is { id, data }. Rich text values are Markdown — render them with a Markdown component, never as raw HTML. Image and file values are asset ids:
import { assetUrl } from "@mapled/client";
const src = assetUrl(imageId, { width: 1200, format: "webp" });
A failed read throws a MapledError with the API's status, code ("INVALID_QUERY", "RELEASE_NOT_FOUND", "RATE_LIMITED", …) and message. A key gets 1,200 requests a minute.
getRecords takes search — full-text search of the published records of a collection with access Public:
const input = document.querySelector<HTMLInputElement>("#search")!;
let typing: AbortController | undefined;
input.addEventListener("input", async () => {
typing?.abort();
typing = new AbortController();
const q = input.value.trim().slice(0, 200);
if (!q) return show([]);
try {
const { records } = await mapled.getRecords("posts", { search: q, limit: 10, signal: typing.signal });
show(records);
} catch (err) {
if ((err as Error).name !== "AbortError") throw err;
}
});
Every word of the query has to be in the record; the last one may be the start of a word, so results follow the typing. The best match comes first — a match in the record's title weighs more — unless you pass sort; filter, limit and offset work as on any list, and total counts every match. Text and choice fields are searched, a sensitive field never. With locale the search reads that language: its translations (through the fallback chain) and its word forms. The query is 1–200 characters (longer is a 400 INVALID_QUERY), operators in it are plain text, and the answer names it as search.query.
npx @mapled/cli types generate
// src/mapled.ts
import { createClient } from "@mapled/client";
import type { MapledSchema } from "./mapled-types";
export const mapled = createClient<MapledSchema>({ key: import.meta.env.VITE_MAPLED_KEY });
// src/home.ts
import { mapled } from "./mapled";
const home = await mapled.getSingle("homepage");
document.querySelector("h1")!.textContent = home?.data.headline ?? ""; // typed: an unknown key or field is a compile error
Content changes when someone presses Publish in Mapled. Answers for the latest release may be reused by the browser for a minute; an answer pinned with release: N never changes and is cached for good. So a page that was loaded before a publish shows it when it is loaded again — or sooner, if it watches for it.
Mapled's Preview shows drafts on your real site. For a site rendered in the browser there is nothing to set up in code: Mapled opens the page with a one-time token — https://your-site/blog/hello?mapled-preview=mpv_… — and the client, before its first read, takes the token out of the address bar, trades it for a draft session and keeps the session for the tab. Reads made in that tab show drafts until the session ends.
Mapled has to know where the site lives and that it renders in the browser:
framework is "react-spa" or "plain-html" (npx @mapled/cli scan --write and bindings push, or your AI agent's push_site_manifest) — or the project was created with that stack;set_site_url).// src/preview-bar.ts
import { endPreview } from "@mapled/client";
import { mapled } from "./mapled";
if (await mapled.isPreview()) {
// show "You are looking at a preview" with a way out
document.querySelector("#exit-preview")?.addEventListener("click", () => {
endPreview();
location.reload();
});
}
resolvePreview() does the trade on its own, previewSession() answers the tab's session. On a server, keep the draft session wherever your preview route put it and name it per request:
// server/mapled.ts
import { createClient } from "@mapled/client";
export function mapledFor(request: Request) {
const session = /(?:^|;\s*)mapled_draft=([^;]+)/.exec(request.headers.get("cookie") ?? "")?.[1] ?? null;
return createClient({ key: process.env.MAPLED_KEY!, draft: session });
}
draft: null never reads drafts.
A page that stays open can hear about a publish:
// src/live.ts
import { mapled } from "./mapled";
export const stop = mapled.watch(async (next) => {
// read again, pinned to the new release: the browser's cache can't answer with the old one
const home = await mapled.getSingle("homepage", { release: next.release });
document.querySelector("h1")!.textContent = home?.data.headline ?? "";
});
The tab asks Mapled for the release pointer — once a minute by default ({ interval: 30 } for thirty seconds, never under fifteen), only while it is visible, at once when it is shown again. Every check is a request of the delivery key's budget (a quiet one is a 304), so keep the interval honest: a thousand open tabs at a minute is most of the budget.
The watch starts from the release the page shows, so a publish that landed between the page's reads and the first check — or while a tab opened in the background waited to be shown — is heard at once instead of at the publish after it: mapled.watch(…) takes it from the client's own reads (every published answer names its release — and a read that answers after the watch started, from an older release than the watch is on, brings that release to onPublish too, once), and { from: 3 } says it outright — a render pinned to release: 3, or from: null for a page that shows nothing published yet. Without either, a first answer whose release was published after the page was loaded counts as a publish.
A site with a server can spare Mapled the traffic: relay GET /v1/delivery/release from a route of your own, cache it there for a few seconds, and point the watch at it.
// src/live-relay.ts
import { watchRelease } from "@mapled/client";
export const stop = watchRelease({ endpoint: "/api/mapled/release", interval: 10, onPublish: () => location.reload() });
A form is created once — npx @mapled/cli forms create "Contact", or by your AI agent — and gets a key. Submissions show up in Mapled under Forms right away.
// src/contact.ts
import { submitForm } from "@mapled/client";
const form = document.querySelector<HTMLFormElement>("#contact")!;
const status = document.querySelector("#contact-status")!;
form.addEventListener("submit", async (event) => {
event.preventDefault();
const consent = form.querySelector<HTMLInputElement>("[name=consent]");
const answer = await submitForm("contact", form, {
key: "mk_live_…",
consent: consent?.checked ? "I agree to be contacted about my request." : undefined,
});
status.textContent = answer.ok ? "Thank you — we'll be in touch." : "That didn't go through. Try again in a minute.";
});
submitForm takes a <form>, a FormData or a plain object of text, numbers and booleans, and never throws: it answers { ok: true } or { ok: false, status, code, message }. Keep a hidden _gotcha input in the form — people leave it empty, what bots fill in is filed as spam. consent is the exact label of the checkbox the visitor ticked, kept with the submission as evidence; page defaults to the page's path; test: true sends a test submission. A form posted in a tab that shows a preview sends the tab's draft session with it, and Mapled files the submission as a test one: it isn't counted, nobody is notified and no workflow starts.
A sign-up form collects the project's contacts: Mapled mails each address a confirmation link, and the person is subscribed once they open it. Every campaign carries a link to unsubscribe, and an address that unsubscribed is never mailed a campaign again.
// src/newsletter.ts
import { subscribe } from "@mapled/client";
const form = document.querySelector<HTMLFormElement>("#newsletter")!;
const status = document.querySelector("#newsletter-status")!;
form.addEventListener("submit", async (event) => {
event.preventDefault();
const fields = new FormData(form);
const answer = await subscribe(String(fields.get("email")), {
key: "mk_live_…",
tags: ["newsletter"],
gotcha: String(fields.get("_gotcha") ?? ""),
});
status.textContent = answer.ok ? "Check your inbox to confirm." : answer.message;
});
subscribe never throws: it answers { ok: true } or { ok: false, status, code, message }, and the answer is the same whether the address is new, waiting for its link, subscribed or unsubscribed. Keep a hidden _gotcha input in the form and pass its value: what bots fill in signs no one up. A consent box on your own checkout is your server's to send: POST /v1/app/contacts with the server key in x-mapled-server-key and { "email": "…", "confirmed": true } subscribes the person at once (without confirmed, Mapled mails the link); POST /v1/app/contacts/unsubscribe with { "email": "…" } takes an address off from your site's account page.
Operational collections skip the publish cycle. Your server reads and writes them with the project's server key — the client refuses to be made in a browser.
// server/orders.ts
import { createAppClient } from "@mapled/client";
const db = createAppClient({ serverKey: process.env.MAPLED_SERVER_KEY! });
await db.create("orders", { customer: "Ada Lovelace", total: 120 });
const { records } = await db.list("orders", { filter: { status: "new" }, sort: "-total" });
A file that only live data names is private: /files/{id} answers 404 without a signature. Every live-data answer carries a signed URL for each private file its records name, as record.files (one map per page of list), and assetUrl takes it:
// server/applications.ts
import { assetUrl, createAppClient } from "@mapled/client";
const db = createAppClient({ serverKey: process.env.MAPLED_SERVER_KEY! });
/** The link to an application's CV. */
export async function cvUrl(applicationId: string): Promise<string | null> {
const application = await db.get<{ cv: string }>("applications", applicationId);
return application ? assetUrl(application.data.cv, { files: application.files }) : null;
}
The signed URL stops working at files[id].expiresAt — 15 minutes — so make it per request: never in a cached page, never at build time. It may go to the browser; the server key never does.
Your site can have accounts of its own — sign-up with email and password, a verification mail, sign-in, password reset — kept by Mapled per project (turn Site Identity on in the project's Identity settings and name the pages of your site the mails link to). The forms live on your pages; your server talks to Mapled through db.identity with the server key, and holds the session token in an HttpOnly cookie of your own domain — it never reaches the browser's JavaScript. Each call is one request to the Identity API; each refusal a MapledError with the API's status and code (INVALID_CREDENTIALS, EMAIL_NOT_VERIFIED, USER_SUSPENDED, TOKEN_INVALID, IDENTITY_DISABLED, …).
// server/identity.ts
import { createAppClient, type SiteUser } from "@mapled/client";
const db = createAppClient({ serverKey: process.env.MAPLED_SERVER_KEY! });
/** Sign-in: the session token goes into your HttpOnly cookie, until `expiresAt`. */
export async function signIn(email: string, password: string): Promise<{ user: SiteUser; token: string; expiresAt: string }> {
const { user, session } = await db.identity.signIn({ email, password });
return { user, token: session.token, expiresAt: session.expiresAt };
}
/** Every request: who the cookie names — checked with Mapled, never cached, so a suspension is seen at once. */
export async function currentUser(token: string | undefined): Promise<SiteUser | null> {
if (!token) return null;
return (await db.identity.session(token)).user;
}
signUp({ email, password, profile? }) answers the unverified user and mails the verify link (a taken address answers the same — its owner gets a mail instead); verify({ token, password }) confirms the address — the mailed token together with the password the person signed up with, on the verify page's form, never on the page load — and answers { user, session }: they are signed in. The link alone confirms nothing (so a click on a mail nobody asked for can't make someone else's password work): a wrong password is the same 400 TOKEN_INVALID as a spent or expired link and spends nothing, ten tries of one link an hour, and a request without the password is a 400 PASSWORD_REQUIRED; session(token) answers { user, expiresAt } or { user: null, reason: "invalid" | "suspended" }; signOut(token) ends the session; requestReset(email) mails the reset link (202 whatever the address) and reset({ token, password }) sets the password and ends every session of the person. A Next.js site gets the cookie handled for it by @mapled/next/server.
A collection for signed-in users (access class authenticated-site-user, on Live data) belongs to the site's users: a record made under a session is that person's, and the collection's policy decides what a person sees and may change — own (their records only; another's is a 404), read-all-write-own (reads all, changes their own; another's a 403 NOT_OWNER) or read-all (reads only; a write a 403 POLICY_READ_ONLY). Mapled applies the policy: your server reads and writes as the person with db.asSiteUser(token) — the same list, get, create, update and remove, each call carrying the session from your cookie next to the server key — and every record names its owner. The key alone, db itself, is your backend: it sees every record, with owners, and makes records with none. Agents and the browser never reach these records.
// server/savedJobs.ts
import { createAppClient } from "@mapled/client";
const db = createAppClient({ serverKey: process.env.MAPLED_SERVER_KEY! });
/** The signed-in person's saved jobs — `token` from your session cookie. */
export async function savedJobs(token: string) {
const mine = db.asSiteUser(token);
const { records } = await mine.list<{ job: string; note: string }>("saved-jobs", { sort: "-createdAt" });
return records;
}
export async function saveJob(token: string, job: string, note: string) {
return db.asSiteUser(token).create("saved-jobs", { job, note });
}
A session that has ended — signed out elsewhere, expired, the account suspended or deleted — is a 401 SESSION_INVALID on the next call: sign the person out. A token that isn't one is refused by asSiteUser before anything is sent.
npx @mapled/cli doctor
doctor knows a site rendered in the browser: it looks for VITE_MAPLED_KEY, reads ○ «Not needed» for the webhook and the routes such a site doesn't have, and fails any env file that puts a server key or a signing secret under a public prefix.
| Export | |
|---|---|
createClient({ key, apiUrl?, release?, locale?, draft?, appUrl?, fetch? }) | The delivery client: getSingle, getRecords, getRecord, getRecordBySlug, getRelease, isPreview, watch(onPublish, { interval?, from? }) |
createAppClient({ serverKey, apiUrl?, fetch? }) | Live data, server-side only: list, get, create, update, remove — list and get take locale too; every record (and a page of list) carries files, the signed URLs of the private files it names, and on a collection for signed-in users its owner; identity — the site's users: signUp, verify, signIn, session, signOut, requestReset, reset (SiteUser, SiteSession, SiteSessionCheck name the types); asSiteUser(token) — the same live data as one signed-in user, their session on every call (MapledSiteUserClient) |
assetUrl(id, { width?, height?, fit?, format?, quality?, files? }) | The URL of an image or file value; with a record's files, the signed URL of a private one |
watchRelease({ key | endpoint, interval?, from?, onPublish }) | Publish notifications without a client |
resolvePreview(options?), previewSession(), endPreview(), PREVIEW_PARAM | Preview in a browser |
submitForm(form, fields, { key, consent?, page?, test? }) | A visitor's form submission |
subscribe(email, { key, name?, tags?, page?, gotcha? }) | A visitor's sign-up for the project's emails (double opt-in) |
MapledError | status, code, message of a failed read |
The HTTP API underneath — endpoints, the query grammar, caching, the webhook's signature — is described in «Without Next.js: the HTTP API» of the @mapled/next README.
FAQs
Mapled's JavaScript client — read published content from any JavaScript runtime: a browser, Node, an edge function. Preview, publish notifications and forms included; no framework, no dependencies.
The npm package @mapled/client receives a total of 177 weekly downloads. As such, @mapled/client popularity was classified as not popular.
We found that @mapled/client demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
Rust 1.100 will add opt-in dependency cooldowns to Cargo, giving security scanners and maintainers time to catch malicious crate releases.

Security News
Cloudflare is acquiring Deno’s team, which will provide another year of bug fixes and security updates before ending support for the runtime.

Security News
Anthropic’s free OSS Scanner uses Mythos and other Claude models to find open source vulnerabilities and send maintainers reports with proposed fixes.