@acms-subir/client
v0.1.4
Published
Typed, dependency-free client for the ACMS public mobile API.
Readme
@acms-subir/client
Dependency-free ESM TypeScript client for ACMS public API v1. Supports Expo/React
Native, browsers and Node 18+ using global fetch, URL, Headers and
AbortController. Supply fetch when your environment needs an adapter.
npm install @acms-subir/clientimport { createClient, isAcmsError } from '@acms-subir/client';
const client = createClient({ baseUrl: 'https://example.com', locale: 'es' });
const site = await client.site();
const home = await client.home();
const page = await client.page('about/team');
for await (const post of client.paginate.posts({ collection: 'blog', limit: 50 })) {
console.log(post.title);
}
const english = client.withLocale('en');baseUrl accepts a site origin or a full /api/v1 URL, with an optional
hosting prefix. All methods return unwrapped data; openapi() returns the
raw document. Every method accepts locale and signal in its options object.
Locale is sent only for routes that support translations. withLocale shares
the cache and transport configuration with the original client.
| Method | Options / behavior |
| --- | --- |
| site(), theme(), home() | Identity, tokens, resolved homepage |
| page(slug, opts) | Resolved page including sections; nested slugs supported |
| content(opts) | kind, collection, q, page, limit, cursor |
| contentById(id, opts) | Optional kind to disambiguate imported IDs |
| posts(opts) | Content filtered to posts; collection, tag, q, pagination |
| post(slug, opts) | Scans post summaries, then loads detail by ID; pass collection for ambiguous slugs |
| pages(opts) | Page summaries, with pagination; use page() for sections |
| search(text, opts) | Top matches in posts, pages and events, plus more per group; types, collection, limit (1–20, default 5). Blank text resolves empty without a request |
| events(opts) | Occurrences with from, to, calendar, q, pagination |
| event(slug, opts) | First matching event from occurrences in the requested window |
| calendars() | Public calendars |
| resources(opts), resource(slug, opts) | Public resources; slug lookup scans list pages |
| availability(opts) | Required resource, link; optional from, days, minutes |
| book(opts) | Resource booking or RSVP input, optional idempotencyKey |
| openapi() | Raw OpenAPI document |
| registerDevice({ token, platform, locale?, topics? }) | Register or refresh an Expo push token (call on every app start); { created, device } |
| updateDevice(token, { topics?, locale? }) | Change a device's topics or language |
| unregisterDevice(token) | Opt out (idempotent) |
| pushTopics() | Topics to offer as notification toggles |
| links() | The links page: profile, socials and visible blocks (link, email, phone, contact, heading, text, spacer, live, embed) |
| form(id), submitForm(id, { answers, version?, idempotencyKey? }) | A published form's questions; send answers (checkbox groups as arrays). Each call makes one key; retries reuse it |
| manageBooking(token), cancelBooking(token) | The booker's own booking from its manageToken, and cancelling it; can says what is allowed |
| rescheduleAvailability(token, { from?, days?, minutes? }), rescheduleBooking(token, { start, minutes? }) | Open times for moving it (its own time counts as free), then the move |
| bookingIcs(token) | The booking as an iCalendar file (string) |
| requestGuestCode(email), guestSignIn({ email, code, deviceName? }), guestBookings(guestToken, { when? }), guestSignOut(guestToken) | Guest sign-in for "My bookings" |
The server has no post-slug, event-detail or resource-detail endpoint. Those
convenience methods may make multiple requests. event() only finds events
with occurrences in its window (default: next 30 UTC days); an unlisted
calendar requires calendar. It does not fetch arbitrary historical events.
If post slugs collide and no collection is supplied, the first listed match wins.
Search needs site.features.search (on unless the site sets
acms({ search: { enabled: false } })). It uses the website's search index and
ranking: titles count most, then excerpts, tags and body text. Every word
must match, and the last one matches as a prefix (birth finds "birthday"). q on content()/posts() returns
ranked summaries, posts before pages, and pages through at most 200 matches per
kind. search() is for a combined results screen: each hit carries a plain-text
snippet with highlights ({ start, end } ranges) to bold, and events add
their next occurrence. Use more.posts to offer "See all", then
posts({ q, collection }). Matching uses the main-language text, so a
translated title is shown but not searched.
Pagination uses page/nextPage, not opaque cursors. cursor is a numeric
alias for page; an explicit page wins. tag filters each fetched page
locally, so a page can be empty with a non-null nextPage. Iterators continue
through these pages. paginate.content/posts/pages/events/resources yield
individual items (events yields occurrences). They do not deduplicate changing
server results or provide snapshot consistency; deduplicate IDs in your app.
Bookings, errors and cancellation
// Persist a UUID v4 with this booking intent before submitting; reuse it until
// the outcome is known. Use your platform's secure UUID/storage facilities.
const idempotencyKey = crypto.randomUUID();
try {
const result = await client.book({
resource: 'hall', link: 'meeting', start: '2026-10-05T14:00:00Z', minutes: 60,
name: 'Example Guest', email: '[email protected]', idempotencyKey,
});
// Store result.manageToken securely; it grants booking-management access.
} catch (error) {
if (isAcmsError(error)) {
console.log(error.status, error.code, error.message, error.retryAfter);
// error.idempotencyKey preserves even an automatically generated key.
}
}
const controller = new AbortController();
const pending = client.home({ signal: controller.signal });
controller.abort();
await pending.catch(() => {});Requests that are safe to repeat (GET, PUT, DELETE, device registration, and
POSTs with an Idempotency-Key: bookings and form responses) retry network
failures, 5xx and 429 responses at most twice (three attempts total), with
250/500ms backoff and at least the server's Retry-After delay (seconds or HTTP
date). A wait that would outlast timeoutMs throws the 429 straight away, with
retryAfter. One-shot writes (guest code requests and sign-in, cancelling and
moving a booking, guest sign-out) are never repeated automatically: on a
network error, check the state before trying again. Booking retries always
reuse the same serialized body and UUID. By default each book() call creates
a key using crypto.randomUUID, falling back to a Math.random UUID v4 where
unavailable. For recovery across calls/app restarts, supply and persist your own
secure UUID. Never create a new intent merely because a response timed out.
timeoutMs defaults to 30,000 and covers the entire request, including retries,
cache access and body reading. A longer rate-limit delay may exhaust that
budget. Explicit aborts and timeouts are not automatically retried; an uncertain
booking must be retried with its existing key. AcmsError exposes status,
code, message, retryAfter (seconds), optional validation issues and
idempotencyKey. Local failures use status 0 and network_error, timeout,
aborted, or invalid_response; non-JSON HTTP errors use http_error.
Convenience lookups apply the timeout separately to each underlying request.
Push notifications
Available when the site enables push (acms({ mobile: { push: true } }));
otherwise these return a 404 AcmsError. Tokens come from
expo-notifications (getExpoPushTokenAsync). The site stores the token,
platform, language and topics only.
const { topics } = await client.pushTopics(); // [{ id: 'news', label: 'News', kind: 'general' }, …]
await client.registerDevice({ token, platform: 'ios', locale: 'es', topics: ['news', 'events'] });
await client.updateDevice(token, { topics: ['events'] });
await client.unregisterDevice(token);Cache and headers
ETag revalidation defaults to an in-memory URL-keyed cache. Each read contacts
the server with If-None-Match; a 304 reuses the saved data. This is not an
offline fallback. Treat returned cached objects as immutable. Use cache: false
to disable it. Availability, booking results and errors are never cached.
Responses without an ETag or with no-store invalidate an older entry.
import type { ClientCache } from '@acms-subir/client';
const cache: ClientCache = {
async get(url) { /* retrieve { etag, body } from app storage */ return null; },
async set(url, entry) { /* persist entry; null means delete */ },
};
const persistent = createClient({ baseUrl: 'https://example.com/api/v1', cache });Adapters may be synchronous or asynchronous. Keep persistent caches scoped to
one site's public API and account for storage limits. Cache adapter failures
propagate to the caller. Custom headers are supported; do not embed privileged
credentials. Requests omit browser credentials. Conditional headers are managed
by the client; a per-call booking key overrides a configured header key.
Development
npm run build -w packages/client
npm run test -w packages/client
npm run check:types -w packages/client
npm run generate:types -w packages/client # after reviewing core schema changesThe contract test imports the actual core OpenAPI generator using core's virtual module test stubs, compares all GET/POST paths to the client method registry, and checks generated request/response types byte-for-byte. HEAD and OPTIONS are transport operations and have no separate data-returning methods. No core code, Zod, or Node built-ins ship in this package's runtime.
Managing bookings and "My bookings"
// a booking the app made (result.manageToken from book()), or one from guestBookings()
const view = await client.manageBooking(manageToken);
if (view.can.reschedule) {
const open = await client.rescheduleAvailability(manageToken, { days: 7 });
await client.rescheduleBooking(manageToken, { start: open.days[0].slots[0].start });
}
if (view.can.cancel) await client.cancelBooking(manageToken);
// guest sign-in (check site.features.guestSignIn first)
await client.requestGuestCode('[email protected]');
const { accessToken } = await client.guestSignIn({ email: '[email protected]', code: '481203' });
const mine = await client.guestBookings(accessToken, { when: 'upcoming' });Booking and guest tokens travel in headers (Booking-Token,
Authorization: Bearer), never in URLs, and their responses are never cached.
Keep both in secure storage (expo-secure-store). can.closed means the
booking type's change cutoff has passed: show "Contact us" instead of the
buttons. A guest token ends after 90 days unused; 401 means sign in again.
Editor apps (staff sign-in)
For sites with acms({ mobile: { editor: { redirectUris } } }). Staff sign in
in the system browser (OAuth 2.0 + PKCE); the app never sees a password.
import { createEditorAuth, createEditorSession, createEditorClient } from '@acms-subir/client';
const auth = createEditorAuth({ baseUrl: 'https://example.com', clientId: 'org.example.staff', redirectUri: 'org.example.staff://auth' });
const session = createEditorSession({ auth, storage /* get/set backed by expo-secure-store */, onSignedOut });
const api = createEditorClient({ baseUrl: 'https://example.com', session });
const { url, verifier, state } = await auth.start({ deviceName: 'Ana’s iPhone' });
// open `url` with expo-web-browser openAuthSessionAsync, then:
const { code } = auth.parseRedirect(returnedUrl, state);
await session.signIn(await auth.exchangeCode({ code, codeVerifier: verifier }));
const doc = await api.getDocument('post', id);
await api.saveDocument('post', id, { revision: doc.revision, fields: { title: 'New title' } });In React Native pass crypto: { randomBytes, sha256 } from expo-crypto to
createEditorAuth (Web Crypto is the default). Refreshes are single-flight;
a 401 is retried once after a refresh, then onAuthError runs. Writes carry
the revision you loaded (409 when someone saved first).
Schedule and setup: schedule({ from, to, resource? }) (up to 63 days of
bookings, blocked time, events and feeds), createBooking() (on someone's
behalf), rescheduleBooking(id, …), listResources() / getResource() /
createResource() / updateResource() / deleteResource(),
createBookingLink() / updateBookingLink() / deleteBookingLink(),
blockTime() / unblockTime(), listCalendars() / createCalendar() /
updateCalendar() / deleteCalendar(), listForms() /
listFormResponses(), getLinksPage() / saveLinksPage(). Setup writes need
me().permissions.schedule.manage; form responses and HTML link blocks need
an administrator or owner (403 otherwise).
