@acms-subir/client

中文文档
0.1.37发布于 2026/11/05MITTypeScriptES Module零依赖近 30 天下载 3105

Typed, dependency-free client for the ACMS public mobile API.

原始使用文档
文档目录8 节
  1. Bookings, errors and cancellation
  2. Event requests
  3. Push notifications
  4. Live video
  5. Cache and headers
  6. Development
  7. Managing bookings and "My bookings"
  8. Editor apps (staff sign-in)

@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/client
import { 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. site() and pushTopics() take a language since core 0.3.32; an older site rejects it, so the client asks once more without it and gets the main language. book() sends the client's locale in its body, so the booking's emails (and the links in them) speak the app's language; a site before core 0.3.38 refuses that field, so the client books once more without it, with the same key, in the site's main language. Update the site before shipping an app build with a newer client.

MethodOptions / behavior
site(), theme(), home()Identity (including legal.privacy / legal.terms URLs to link in the app), 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, tag, excludeTag, room, q, pagination. The filters take one value or an array (any of them matches)
event(slug, opts)One event by slug, whatever its date (GET /events/{slug}; on sites older than core 0.3.40, the first match in the requested window)
eventDetail(slug, { on? })The event and its next occurrence, or the one on on (YYYY-MM-DD)
calendars()Public calendars
resources(opts), resource(slug, opts)Public resources; slug lookup scans list pages
availability(opts)Required resource, link; optional from, days, minutes
roomFree({ room, start, end })Free or busy for a room the site lets people check, with the busy times and no names; never cached (core 0.3.45+)
rsvpStatus(slug, { start })One event date's RSVP status: open or reason (disabled, ended, closed past the event's deadline, cancelled, full), closesAt (when RSVPs to the date close, or null: open until it ends), live going/left (held waitlist spots count as taken), maxSeats for one RSVP right now, autoConfirm, waitlist (enabled: a full date takes places in line; waiting people for seats seats; maxSeats for one place in line), and form (questions and version, or null). start is the occurrence's start exactly as events() returns it (a UTC ISO string such as 2026-10-05T14:00:00.000Z). Never cached
book(opts)Resource booking or RSVP input (RsvpInput for an RSVP; waitlist: true joins a full date's waitlist, status waitlisted), optional idempotencyKey; sends the client's locale (or opts.locale) for its emails
openapi()Raw OpenAPI document
registerDevice({ token, platform, locale?, topics?, channels? })Register or refresh an Expo push token (call on every app start); { created, device }. channels: Android channels the app created (e.g. ['urgent'])
updateDevice(token, { topics?, locale?, channels? })Change a device's topics, language or channels
unregisterDevice(token)Opt out (idempotent); also unlinks a staff test phone
pushTopics()Topics to offer as notification toggles, named in the client's language (built-in and the site's own, kind: 'site')
bibles(), biblePassage(ref, { bible }), bibleChapter(book, chapter, { bible })Bible verses for the Bible links in posts (Settings → Bible, core 0.3.84+): the site's Bibles, a passage (a link's ?ref=), a chapter with its headings and neighbours; 404 while off
linkStaffPhone(token, code)Staff only: link this registered device with the 6-digit code from the admin's Test phones, so it gets that person's test notifications; { linked, staff: { name } }. A wrong, used or expired code is a 400, too many tries a 429; never retried
unlinkStaffPhone(token)Unlink it (idempotent); { linked: false, staff: null }
links()The links page: profile, socials and visible blocks (link, email, phone, contact, heading, text, spacer, live, embed)
live()Live video: the current broadcast (snapshot or null), serverNow and the owner's display choices. Never cached; 404 while site.features.live is false
announcements({ kind?, pinned?, following?, scope?, page?, limit? }), announcement(id), paginate.announcements()Announcements up right now in the client's language, newest first (pinned: true: the banners, urgent first); 404 while site.features.announcements is false
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
requestOptions(), submitRequest(input, { idempotencyKey? })Event requests without an account: what the form offers (open, types, rooms, replyTime, notice, phone, timezone; never cached), and asking for a new event or a change ({ id }). 404 while site.features.requests is false. See Event requests below
manageBooking(token), cancelBooking(token)The booker's own booking from its manageToken, and cancelling it (or leaving a waitlist, or releasing a held spot); can says what is allowed. A waitlisted RSVP has booking.position (its place in line), an offered one booking.holdUntil
confirmBooking(token)Take a spot the waitlist offered (can.confirm): confirmed, or pending when RSVPs need approval. 410 once holdUntil passed. Core 0.3.40 or later
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

import type { RsvpInput } from '@acms-subir/client';

// 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.
  }
}

// RSVP to one date of an event (needs only the calendar: site.features.rsvp)
const { occurrences } = await client.events({ q: 'supper' });
const date = occurrences[0];
const status = await client.rsvpStatus(date.event.slug, { start: date.start });
if (status.open) {
  const rsvp: RsvpInput = {
    event: date.event.slug, start: date.start, seats: 2, name: 'Example Guest', email: '[email protected]',
    // field id → answer; checkbox groups send a list
    answers: { meal: 'Vegetarian', help: ['Setup', 'Music'] },
    // required when the event asks questions: a changed form answers 409, so read the status again
    ...(status.form ? { formVersion: status.form.version } : {}),
  };
  // an email that already has an RSVP for this date: AcmsError 409 with code 'duplicate' (its link is emailed again)
  const result = await client.book({ ...rsvp, idempotencyKey: crypto.randomUUID() });
  // pending when the organiser approves RSVPs; status.form?.confirmation is the thank-you text.
  // result.manageToken changes or cancels it later (manageBooking, cancelBooking), like the email's link:
  // keep it in secure storage only.
  console.log(result.booking.status);
} else if (status.reason === 'full' && status.waitlist.enabled) {
  // a place in line (status 'waitlisted', no seats); when a spot opens the site emails them and holds it
  // for 24 hours: then manageBooking shows status 'offered' and holdUntil, and confirmBooking(token) takes it
  const result = await client.book({
    event: date.event.slug, start: date.start, seats: 2, name: 'Example Guest', email: '[email protected]', waitlist: true,
    ...(status.form ? { formVersion: status.form.version } : {}), idempotencyKey: crypto.randomUUID(),
  });
  console.log(result.booking.status);
}

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, form responses and event requests) 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, confirming a held spot, 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.

Event requests

A site can take event requests from people without an account (acms({ requests: true }), so site.features.requests is true; staff answer them in Calendar → Requests). Someone asks for something new, or for a change to an event, and gives a name, an email and optionally a phone. Nothing goes on the calendar until staff approve it.

import type { RequestInput } from '@acms-subir/client';

const options = await client.requestOptions();
// { open, types: [{ id, label }], rooms: [{ slug, name, color }], replyTime: '3 days', notice, phone, timezone }
if (!options.open) {
  // say "We aren't taking requests here right now." and show options.phone; keep the screen itself reachable
}
const ask: RequestInput = {
  kind: 'new', type: options.types[0]?.id, title: 'Guitar workshop',
  // the site's wall clock (options.timezone), in the future; the end may be just HH:mm on the same day
  start: '2026-11-07T18:00', end: '20:00',
  repeat: 'weekly',                       // 'once' (default), 'weekly', or 'other' with repeatText
  rooms: ['fellowship-hall'],             // slugs from options.rooms, or roomsUnsure: true
  people: 12, message: 'We bring our own chairs.',
  name: 'Example Guest', email: '[email protected]', phone: '555 0100',
};
const { id } = await client.submitRequest(ask, { idempotencyKey: crypto.randomUUID() });

// a change to a public event: one date of it (YYYY-MM-DD, the site's day), or leave date out for every date
await client.submitRequest({ kind: 'change', event: 'fall-fair', date: '2026-11-08', message: 'Could it start at 10?', name: 'Example Guest', email: '[email protected]' });
  • Check whether a room looks free first with roomFree({ room, start, end }) (needs bookings on the site; the times there take a zone, so convert the wall-clock times with options.timezone).
  • Keep one Idempotency-Key per request and reuse it for retries: a retry answers the same { id }, and the same key with other details is a 409 changed. Each call without idempotencyKey makes a new one.
  • The client's locale (or input.locale) goes in the body, so the emails about the request speak the app's language.
  • A refusal is an AcmsError whose code says why: closed (409, requests closed meanwhile), a field (400: title, type, start, end, past, far, repeat, room, people, event, date, what (the change's message), name, email, phone), changed (409), or busy / full (429, with retryAfter). 10 requests a minute per address, plus the site's daily cap.

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

Following one event: event.follow.topic (event:<id>, null when it can't be followed now) goes in the device's topics like any other; the site drops it once the event has no dates left. The announcements list takes the events a phone follows:

const topics = [...device.topics, event.follow.topic!];
await client.updateDevice(token, { topics });
const following = followedEvents(topics);                          // event ids
await client.announcements({ following });                          // + their messages and notices (kind 'message')
await client.announcements({ following, scope: 'following' });      // only those ("My classes")

A follower can also pick a reminder before each date, among event.follow.reminders ([{ id: '1h' | 'evening', topic, at }], at the evening's time, HH:mm site time; [] when the calendar offers none): its topic (event:<id>:remind:1h) goes in the topics beside the event's, one at a time; event.follow.reminderStart is what a new follower starts on ('none' = no reminder until they pick). The site keeps a reminder only beside its event's topic, and drops it when the calendar stops offering it.

import { reminderOf, withReminder } from '@acms-subir/client';
reminderOf(device.topics, event.id);                                             // '1h' | 'evening' | null
await client.updateDevice(token, { topics: withReminder(device.topics, event.id, 'evening') }); // "The evening before, at 7:00 PM"
await client.updateDevice(token, { topics: withReminder(device.topics, event.id, null) });      // no reminder

event.hosts names its hosts when its calendar shows them; event.hostsLabel is what the calendar calls them in the request's language ("Maestro/a"), or '' when it sets nothing (use your own words, like "Led by").

Staff can link their own phone for test notifications ("Send test to my phone" in the admin): the admin's Test phones panel shows a one-time 6-digit code (10 minutes, single use) that the app sends with this phone's token. device.staff (from registerDevice and updateDevice) and the link response name who it's linked to (their name only), or null. Test pushes carry data.test: true.

const { staff } = await client.linkStaffPhone(token, '123456');  // { name: 'Ana López' }; 400 for a wrong, used or expired code
await client.unlinkStaffPhone(token);                             // turning notifications off unlinks it too

Live video

site.features.live is true while the site's live video (Integrations → Live video) isn't off. live() returns the same snapshot the website shows: status (live, upcoming or offline for the "Watch our channel" fallback), title, watchUrl, an absolute thumbUrl (empty when there's none), startedAt, expiresAt and optional channel and viewers. Times are Unix milliseconds on the server's clock: keep serverNow - Date.now() as an offset, and stop treating a snapshot as current once expiresAt (when nonzero) passes on that clock, even if later requests fail. display carries the owner's choices (style: poster, split or row; bar, header, dock) so an app can follow the website. Poll about every 30 seconds while the app is in the foreground; @acms-subir/native's useLive() does all of this. The "we're live" notification is the push topic live.

const { snapshot, serverNow } = await client.live();
const offset = serverNow - Date.now();
const current = snapshot && (!snapshot.expiresAt || snapshot.expiresAt > Date.now() + offset) ? snapshot : null;
if (current?.status === 'live') console.log(`Live: ${current.title} → ${current.watchUrl}`);

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, live status, RSVP status, 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 changes

The 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(). updateBookingLink(resourceId, linkId, { order: 0 }) puts a booking type first in its room, the one its booking page and apps lead with. Setup writes need me().permissions.schedule.manage; form responses and HTML link blocks need an administrator or owner (403 otherwise).

版本与发布记录

@acms-subir/client 共发布 38 个版本,首次发布于 2026/10/01;过去 12 个月发布了 38 个版本。

版本发布日期距今
0.1.37latest2026/11/052 天前
0.1.362026/10/299 天前
0.1.352026/10/2612 天前
0.1.342026/10/2612 天前
0.1.332026/10/2612 天前
0.1.322026/10/2613 天前

关于 @acms-subir/client 的常见问题

@acms-subir/client 是什么?

Typed, dependency-free client for the ACMS public mobile API.

如何安装 @acms-subir/client?

在项目目录运行 npm install @acms-subir/client,或使用 pnpm add / yarn add / bun add。

@acms-subir/client 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@acms-subir/client 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@acms-subir/client 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=18。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@acms-subir/client 可以商用吗?

@acms-subir/client 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@acms-subir/client 的下载量有多少?

根据 npm 官方下载统计,@acms-subir/client 在 2026/10/05 – 2026/11/03 这 30 天内被下载 3,105 次,日均约 104 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@acms-subir/client 还在维护吗?

最新版本 0.1.37 发布于 2026/11/05(2 天前);至今共发布 38 个版本,其中过去 12 个月发布了 38 个;首次发布于 2026/10/01。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。