New:Introducing Socket Scanning for VS Code Marketplace Extensions.Learn more →
Get Started

@mapled/client

Package Overview
Dependencies
Maintainers
1
Versions
8
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@mapled/client

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.

npmnpm
Version
0.4.0
Version published
Weekly downloads
177
-28.05%
Maintainers
1
Weekly downloads
 
Created
Source

@mapled/client

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.

Install

npm install @mapled/client

Read published content

// 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).
  • 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.
  • Every read takes 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.

Typed by your schema

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

Freshness

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.

Preview

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:

  • the site manifest's 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;
  • Production domain in Project settings holds the site's public address (an AI agent sets it with 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.

Live updates

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() });

Forms

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.

Live data

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" });

Private files

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.

Site users

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) spends the mailed token — on a click, never on the page load; 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.

Data of signed-in people

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.

Check the integration

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.

API

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_PARAMPreview in a browser
submitForm(form, fields, { key, consent?, page?, test? })A visitor's form submission
MapledErrorstatus, 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.

Keywords

mapled

FAQs

Package last updated on 04 Oct 2026

Related posts