@mantlejs/client
Official JavaScript/TypeScript client SDK for Mantle JS — call Mantle services from browsers, Node.js (18+), and React Native over REST, with optional real-time events over Socket.IO.
Installation
Section titled “Installation”npm install @mantlejs/client
# Optional — only needed for real-time service eventsnpm install socket.io-clientThe client has zero required dependencies: REST calls use the native Fetch API. socket.io-client is an optional peer dependency loaded lazily on the first .on() call.
Concepts
Section titled “Concepts”The same Service<T> surface, on the wire
Section titled “The same Service<T> surface, on the wire”A ServiceClient<T> exposes the six method names the server-side Service<T> contract uses — find, get, create, update, patch, remove — dispatched as REST calls (GET/POST/PUT/PATCH/DELETE /:service). Switching between server and client code involves no context switching.
Query serialization
Section titled “Query serialization”params.query is serialized into the bracket-notation query string every Mantle HTTP transport parses back into the identical object (parseQueryString in @mantlejs/mantle):
await api.service("users").find({ query: { age: { $gt: 21 }, $limit: 10, $sort: { name: "asc" } } });// → GET /users?age[$gt]=21&$limit=10&$sort[name]=ascValues arrive server-side as strings — pair services with RepositoryService and a schema for type coercion. undefined values are dropped; null serializes as an empty string, so IS NULL queries need server-side coercion.
Authentication and token rotation
Section titled “Authentication and token rotation”client.authenticate({ strategy, ...credentials }) posts to /authentication and stores the returned accessToken/refreshToken in the configured TokenStorage (default: localStorage in the browser, in-memory elsewhere). Every REST request carries Authorization: Bearer <accessToken>.
On a 401, the client attempts one token rotation — POST /authentication with { strategy: "refresh", refreshToken } — then retries the original request. Concurrent 401s share a single refresh (the server’s rotation treats a reused refresh token as theft and revokes the whole family). If the refresh fails, tokens are cleared, 'logout' is emitted, and the original 401 error is thrown.
Restoring a session on startup
Section titled “Restoring a session on startup”getAccessToken() is synchronous, reading only the in-memory copy — on a fresh page load
(or a new MantleClient instance) that copy is empty until something hydrates it from storage,
which normally only happens as a side effect of the first authenticated REST call. A UI that
checks getAccessToken() before making any request — the natural thing to do to decide whether
to render a logged-in or logged-out view on mount — will see undefined and show its logged-out
state on every refresh, even with a valid session sitting in storage. Use isAuthenticated()
instead, which hydrates first:
const [authenticated, setAuthenticated] = useState<boolean | undefined>(undefined); // undefined = "checking"useEffect(() => { void api.isAuthenticated().then(setAuthenticated);}, []);setTokens({ accessToken, refreshToken }) hydrates the client from a token pair obtained outside
authenticate() — e.g. an OAuth provider’s redirect-back callback landing tokens in a URL
fragment. It stores them and emits 'authenticated', the same as a successful authenticate().
Real-time events
Section titled “Real-time events”When the socket option is configured, service.on("created" | "updated" | "patched" | "removed", handler) subscribes to the server’s Socket.IO broadcasts ("<path> <event>"). The socket connects lazily on the first .on() call, all services share one connection, and multiple handlers for the same event multiplex over a single underlying socket listener. Calling .on() without the socket option throws a GeneralError-shaped MantleClientError.
Event delivery is at-most-once (see the @mantlejs/sync README) — the client emits a 'reconnect' event on every re-connect so callers (e.g. @mantlejs/react) can refetch and bound the staleness from any missed events.
Batch coalescing
Section titled “Batch coalescing”With the batch option enabled, service calls made within the same coalescing window (default:
the same microtask tick) are queued and sent as one POST /batch request instead of N
separate REST calls — the transports mount that endpoint by default. Each caller’s promise still
resolves or rejects independently from its own entry in the batched response, so application code
is unchanged:
const api = mantle({ url: "http://localhost:3030", batch: true });
// One HTTP round trip, three independent promises — the dominant AI-agent call patternconst [user, posts, tags] = await Promise.all([ api.service("users").get(1), api.service("posts").find({ query: { authorId: 1 } }), api.service("tags").find(),]);windowMs widens the window (setTimeout-based) beyond the same tick; queues longer than
maxSize (default 25 — match the server’s max batch size) split into multiple requests. Calls
with per-request headers bypass coalescing and go out individually, as does similar(). If
every entry fails with a 401 (expired token), the client performs its usual single token refresh
and retries just those entries once.
File uploads
Section titled “File uploads”service.upload(file, options?) sends a multipart/form-data request to a service whose create (or, with options.id,
patch) runs @mantlejs/storage’s handleUpload(). It gets the same guarantees as every other call: bearer auth, one
refresh-and-retry on a 401, typed MantleClientErrors. Uploads are never batched.
const attachment = await api.service<Attachment>("attachments").upload(file, { fields: { articleId: "42" }, // ordinary form fields, merged into context.data by handleUpload() onProgress: ({ percent }) => setProgress(percent ?? 0),});fetch can’t report upload progress, so when onProgress is set and XMLHttpRequest exists (browsers, React Native),
the request goes over XHR instead. Elsewhere (Node), fetch is used and onProgress never fires. Pass signal to abort;
the promise rejects with the signal’s reason.
Errors
Section titled “Errors”Non-2xx responses are deserialized into MantleClientError — name ("BadRequest", "NotFound", …), code (HTTP status), data, errors, and the server’s actionable hint when present. Non-JSON bodies (gateway errors) fall back to the HTTP status. Network failures propagate as the native fetch TypeError, unwrapped.
Quick start
Section titled “Quick start”import { mantle } from "@mantlejs/client";
interface Message { id: number; text: string;}
const api = mantle({ url: "http://localhost:3030", socket: {} });
// Authenticate (against @mantlejs/auth + a strategy such as auth-local)await api.authenticate({ strategy: "local", email: "alice@example.com", password: "secret" });
// CRUD — same names as the server-side Service<T>const messages = api.service<Message>("messages");const page = await messages.find({ query: { $limit: 10, $sort: { id: "desc" } } });const one = await messages.get(1);const created = await messages.create({ text: "Hello" });
// Real-timemessages.on("created", (message) => console.log("new message", message.text));api.on("reconnect", () => console.log("socket reconnected — refetch anything stale"));Vector search, against a service that registers the similar custom method (VectorRepositoryService):
const hits = await api.service<Doc>("docs").similar({ vector: [0.1, 0.2, 0.3], topK: 5 });// hits: Array<Doc & { _score: number }>mantle(options)
Section titled “mantle(options)”Creates a MantleClient. Throws TypeError if url is missing.
| Option | Type | Default | Description |
|---|---|---|---|
url |
string |
— (required) | Base URL of the Mantle server, e.g. "http://localhost:3030" |
storage |
TokenStorage |
localStorage browser / in-memory |
Token persistence. Any object with getItem/setItem/removeItem (sync or async) works |
socket |
SocketOptions |
undefined |
Socket.IO connection options, passed to io(url, options). Omit to disable real-time features |
headers |
Record<string, string> |
{} |
Default headers appended to every REST request (per-request params.headers win) |
batch |
boolean | BatchOptions |
false |
Coalesce same-window service calls into one POST /batch request (see Batch coalescing) |
SocketOptions.io optionally overrides the socket factory itself — inject a stub in tests, or supply a pre-bundled io when dynamic import of the optional peer is undesirable.
MantleClient
Section titled “MantleClient”| Member | Description |
|---|---|
service<T>(path) |
Returns the (cached) ServiceClient<T> for a service path |
authenticate(credentials) |
POST /authentication, stores tokens, emits 'authenticated', returns AuthResult. A 2xx without an accessToken throws a GeneralError and stores nothing |
setTokens(tokens) |
Stores a { accessToken, refreshToken? } pair obtained outside authenticate(), emits 'authenticated' |
isAuthenticated() |
Promise<boolean> — hydrates from storage first if needed. Use on startup, not getAccessToken() (see “Restoring a session on startup”) |
logout() |
Clears tokens, emits 'logout', fires a best-effort POST /authentication/logout |
url |
The server’s base URL (trailing slashes removed) — for links to non-service routes, e.g. downloads |
getAccessToken() |
Current access token (synchronous, from the in-memory copy — may be undefined until something hydrates it, even with a valid session in storage) |
on(event, handler) |
Client events: 'authenticated', 'logout', 'reconnect' |
off(event, handler) |
Remove a client event handler |
ServiceClient<T>
Section titled “ServiceClient<T>”| Member | HTTP | Description |
|---|---|---|
find(params?) |
GET /:service |
Promise<T[] | Paginated<T>> |
get(id, params?) |
GET /:service/:id |
Promise<T> |
create(data, params?) |
POST /:service |
Promise<T> |
update(id, data, params?) |
PUT /:service/:id |
Promise<T> |
patch(id, data, params?) |
PATCH /:service/:id |
Promise<T> |
remove(id, params?) |
DELETE /:service/:id |
Promise<T> |
similar(data, params?) |
POST /:service/similar |
Vector-search convention — Promise<Array<T & { _score }>> |
upload(file, options?) |
POST /:service (or PATCH /:service/:id with options.id) |
multipart/form-data for handleUpload() — see “File uploads” |
on(event, handler) |
— | Subscribe to 'created' | 'updated' | 'patched' | 'removed' |
off(event, handler) |
— | Unsubscribe; the socket listener detaches with the last handler |
realtime |
— | true when the client has the socket option — on()/off() are available |
interface ClientParams { query?: Record<string, unknown>; // serialized into the URL headers?: Record<string, string>; // per-request header overrides}
interface TokenStorage { getItem(key: string): string | null | Promise<string | null>; setItem(key: string, value: string): void | Promise<void>; removeItem(key: string): void | Promise<void>;}
interface BatchOptions { windowMs?: number; // coalescing window; 0 (default) = same microtask tick maxSize?: number; // max calls per POST /batch; longer queues split. Default 25}
interface AuthCredentials { strategy: string; [key: string]: unknown;}
interface AuthResult { accessToken: string; refreshToken?: string; user?: unknown;}
interface Paginated<T> { total: number; limit: number; skip: number; data: T[];}
interface UploadOptions { id?: Id; // PATCH /:service/:id instead of POST /:service field?: string; // multipart field handleUpload(field) reads. Default "file" fields?: Record<string, string>; // ordinary form fields, sent before the file part filename?: string; // default: the File's name, or "blob" onProgress?: (progress: { loaded: number; total?: number; percent?: number }) => void; // XHR transport only signal?: AbortSignal; headers?: Record<string, string>;}
class MantleClientError extends Error { code: number; // HTTP status name: string; // server error class: "BadRequest", "NotFound", … className?: string; // kebab-case class name from the wire format data?: unknown; errors?: unknown[]; hint?: string; // server's actionable guidance, when present}Tokens are stored under the keys mantle-access-token and mantle-refresh-token.
Development
Section titled “Development”npx nx build client # compilenpx nx test client # run testsnpx nx lint client # lintPublishing
Section titled “Publishing”Build before publishing:
npx nx build clientFirst publish (scoped packages require --access public):
cd packages/clientnpm publish --access publicSubsequent releases — bump version in packages/client/package.json, then:
cd packages/clientnpm publishTesting locally with Verdaccio
Section titled “Testing locally with Verdaccio”# Terminal 1 — start the local registrynpx nx run @mantle/source:local-registry
# Terminal 2 — publish to itcd packages/clientnpm publish --registry http://localhost:4873
# Install from it in another projectnpm install @mantlejs/client --registry http://localhost:4873