@acms-subir/client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,244 @@
1
+ import { AcmsError } from './errors.js';
2
+ export { AcmsError, isAcmsError } from './errors.js';
3
+ export * from './editor.js';
4
+ // The contract test compares this registry with the core OpenAPI generator.
5
+ export const endpoints = {
6
+ site: { method: 'GET', path: '/site' }, theme: { method: 'GET', path: '/theme' },
7
+ home: { method: 'GET', path: '/pages/home' }, page: { method: 'GET', path: '/pages/{slug}' },
8
+ content: { method: 'GET', path: '/content' }, contentById: { method: 'GET', path: '/content/{id}' },
9
+ events: { method: 'GET', path: '/events' }, calendars: { method: 'GET', path: '/calendars' },
10
+ resources: { method: 'GET', path: '/resources' }, availability: { method: 'GET', path: '/availability' },
11
+ book: { method: 'POST', path: '/bookings' }, openapi: { method: 'GET', path: '/openapi.json' },
12
+ // push notifications (only when the site enables push; 404 otherwise)
13
+ registerDevice: { method: 'POST', path: '/devices' }, updateDevice: { method: 'PUT', path: '/devices/{token}' },
14
+ unregisterDevice: { method: 'DELETE', path: '/devices/{token}' }, pushTopics: { method: 'GET', path: '/push/topics' },
15
+ };
16
+ const localized = new Set(['home', 'page', 'content', 'contentById', 'events']);
17
+ function uuid() {
18
+ if (globalThis.crypto?.randomUUID)
19
+ return globalThis.crypto.randomUUID();
20
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
21
+ const n = Math.floor(Math.random() * 16);
22
+ return (c === 'x' ? n : (n & 3) | 8).toString(16);
23
+ });
24
+ }
25
+ function abortable(work, signal) {
26
+ return new Promise((resolve, reject) => {
27
+ const abort = () => { cleanup(); reject(signal.reason); };
28
+ const cleanup = () => signal.removeEventListener('abort', abort);
29
+ work.then(v => { cleanup(); resolve(v); }, e => { cleanup(); reject(e); });
30
+ if (signal.aborted)
31
+ abort();
32
+ else
33
+ signal.addEventListener('abort', abort, { once: true });
34
+ });
35
+ }
36
+ function pause(ms, signal) {
37
+ return new Promise((resolve, reject) => {
38
+ const abort = () => { clearTimeout(timer); signal.removeEventListener('abort', abort); reject(signal.reason); };
39
+ const timer = setTimeout(() => { signal.removeEventListener('abort', abort); resolve(); }, Math.min(ms, 2_147_483_647));
40
+ if (signal.aborted)
41
+ abort();
42
+ else
43
+ signal.addEventListener('abort', abort, { once: true });
44
+ });
45
+ }
46
+ const retrySeconds = (value) => {
47
+ if (!value)
48
+ return undefined;
49
+ if (/^\d+(\.\d+)?$/.test(value))
50
+ return Number(value);
51
+ const date = Date.parse(value);
52
+ return Number.isFinite(date) ? Math.max(0, (date - Date.now()) / 1000) : undefined;
53
+ };
54
+ export function createClient(options) {
55
+ const base = new URL(options.baseUrl);
56
+ if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash)
57
+ throw new TypeError('baseUrl must be an HTTP(S) URL without credentials, query or fragment.');
58
+ const prefix = base.pathname.replace(/\/+$/, '');
59
+ base.pathname = prefix.endsWith('/api/v1') ? prefix : prefix + '/api/v1';
60
+ const baseUrl = base.href.replace(/\/$/, '');
61
+ const fetcher = options.fetch ?? globalThis.fetch?.bind(globalThis);
62
+ if (!fetcher)
63
+ throw new TypeError('A fetch implementation is required.');
64
+ const timeoutMs = options.timeoutMs ?? 30_000;
65
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
66
+ throw new TypeError('timeoutMs must be a positive finite timer duration.');
67
+ const memory = new Map();
68
+ const cache = options.cache === false ? false : !options.cache || options.cache === 'memory' ? {
69
+ get: url => memory.get(url), set: (url, entry) => { if (entry)
70
+ memory.set(url, entry);
71
+ else
72
+ memory.delete(url); },
73
+ } : options.cache;
74
+ const defaultHeaders = new Headers(options.headers);
75
+ async function request(name, opts = {}, query = {}, param, payload, key) {
76
+ const endpoint = endpoints[name];
77
+ const path = endpoint.path.replace(/\{\w+\}/, () => {
78
+ if (!param || param.split('/').some(p => p === '.' || p === '..'))
79
+ throw new TypeError('A nonempty identifier without dot segments is required.');
80
+ return name === 'page' ? param.split('/').map(encodeURIComponent).join('/') : encodeURIComponent(param);
81
+ });
82
+ const url = new URL(baseUrl + path);
83
+ for (const [k, v] of Object.entries(query))
84
+ if (v !== undefined)
85
+ url.searchParams.set(k, String(v));
86
+ const locale = opts.locale ?? options.locale;
87
+ if (localized.has(name) && locale !== undefined)
88
+ url.searchParams.set('locale', locale);
89
+ const controller = new AbortController();
90
+ const abort = () => controller.abort(new AcmsError(0, 'aborted', 'Request aborted.', undefined, undefined, key));
91
+ if (opts.signal?.aborted)
92
+ abort();
93
+ else
94
+ opts.signal?.addEventListener('abort', abort, { once: true });
95
+ const timer = setTimeout(() => controller.abort(new AcmsError(0, 'timeout', 'Request timed out.', undefined, undefined, key)), timeoutMs);
96
+ const signal = controller.signal;
97
+ const cacheable = endpoint.method === 'GET' && name !== 'availability' && cache;
98
+ try {
99
+ if (signal.aborted)
100
+ throw signal.reason;
101
+ const cached = cacheable ? await abortable(Promise.resolve(cacheable.get(url.href)), signal) : undefined;
102
+ const headers = new Headers(defaultHeaders);
103
+ headers.set('Accept', 'application/json');
104
+ // Conditional requests are owned by this cache, including when caching is disabled.
105
+ headers.delete('If-None-Match');
106
+ if (cached)
107
+ headers.set('If-None-Match', cached.etag);
108
+ if (key)
109
+ headers.set('Idempotency-Key', key);
110
+ if (payload !== undefined)
111
+ headers.set('Content-Type', 'application/json');
112
+ const body = payload !== undefined ? JSON.stringify(payload) : undefined;
113
+ for (let attempt = 0;; attempt++) {
114
+ let res;
115
+ try {
116
+ res = await abortable(fetcher(url.href, { method: endpoint.method, headers, body, signal, credentials: 'omit' }), signal);
117
+ }
118
+ catch (error) {
119
+ if (signal.aborted)
120
+ throw signal.reason;
121
+ if (attempt < 2) {
122
+ await pause(250 * 2 ** attempt, signal);
123
+ continue;
124
+ }
125
+ throw new AcmsError(0, 'network_error', error instanceof Error ? error.message : 'Network request failed.', undefined, undefined, key);
126
+ }
127
+ const retryAfter = retrySeconds(res.headers.get('Retry-After'));
128
+ if ((res.status === 429 || res.status >= 500) && attempt < 2) {
129
+ // Release the response stream before retrying.
130
+ if (res.body)
131
+ await abortable(res.body.cancel().catch(() => { }), signal);
132
+ await pause(Math.max(250 * 2 ** attempt, (retryAfter ?? 0) * 1000), signal);
133
+ continue;
134
+ }
135
+ if (res.status === 304 && cached)
136
+ return cached.body;
137
+ let json;
138
+ try {
139
+ json = await abortable(res.json(), signal);
140
+ }
141
+ catch (error) {
142
+ if (signal.aborted)
143
+ throw signal.reason;
144
+ // fetch can succeed before a network failure interrupts the body stream.
145
+ if (error instanceof TypeError) {
146
+ if (attempt < 2) {
147
+ await pause(250 * 2 ** attempt, signal);
148
+ continue;
149
+ }
150
+ throw new AcmsError(0, 'network_error', error.message, undefined, undefined, key);
151
+ }
152
+ throw new AcmsError(res.status, res.ok ? 'invalid_response' : 'http_error', `HTTP ${res.status}: expected a JSON response.`, retryAfter, undefined, key);
153
+ }
154
+ if (!res.ok)
155
+ throw new AcmsError(res.status, json?.error?.code ?? 'http_error', json?.error?.message ?? `HTTP ${res.status}`, retryAfter, json?.error?.issues, key);
156
+ if (name !== 'openapi' && (json?.version !== 1 || !Object.prototype.hasOwnProperty.call(json, 'data')))
157
+ throw new AcmsError(res.status, 'invalid_response', 'Expected an ACMS v1 response.', undefined, undefined, key);
158
+ const data = name === 'openapi' ? json : json.data;
159
+ if (cacheable) {
160
+ const etag = res.headers.get('ETag');
161
+ await abortable(Promise.resolve(cacheable.set(url.href, etag && !/\bno-store\b/i.test(res.headers.get('Cache-Control') ?? '') ? { etag, body: data } : null)), signal);
162
+ }
163
+ return data;
164
+ }
165
+ }
166
+ finally {
167
+ clearTimeout(timer);
168
+ opts.signal?.removeEventListener('abort', abort);
169
+ }
170
+ }
171
+ const paging = (o) => ({ page: o.page ?? o.cursor, limit: o.limit });
172
+ async function* iterate(load, field, opts) {
173
+ let page = opts.page ?? opts.cursor ?? 1;
174
+ const seen = new Set();
175
+ while (!seen.has(page)) {
176
+ seen.add(page);
177
+ const result = await load({ ...opts, page });
178
+ for (const item of result[field]) {
179
+ if (opts.signal?.aborted)
180
+ throw new AcmsError(0, 'aborted', 'Request aborted.');
181
+ yield item;
182
+ }
183
+ if (result.pagination.nextPage === null)
184
+ return;
185
+ page = result.pagination.nextPage;
186
+ }
187
+ throw new AcmsError(0, 'invalid_response', 'Pagination repeated a page.');
188
+ }
189
+ const content = (o = {}) => request('content', o, { ...paging(o), kind: o.kind, collection: o.collection });
190
+ const posts = async (o = {}) => {
191
+ const result = await content({ ...o, kind: 'post' });
192
+ return { ...result, items: result.items.filter(p => !o.tag || p.tags.includes(o.tag)) };
193
+ };
194
+ const pages = (o = {}) => content({ ...o, kind: 'page' });
195
+ const events = (o = {}) => request('events', o, { ...paging(o), from: o.from, to: o.to, calendar: o.calendar, q: o.q });
196
+ const resources = (o = {}) => request('resources', o, paging(o));
197
+ const paginate = {
198
+ content: (o = {}) => iterate(content, 'items', o),
199
+ posts: (o = {}) => iterate(posts, 'items', o),
200
+ pages: (o = {}) => iterate(pages, 'items', o),
201
+ events: (o = {}) => iterate(events, 'occurrences', o),
202
+ resources: (o = {}) => iterate(resources, 'resources', o),
203
+ };
204
+ return {
205
+ site: (o = {}) => request('site', o),
206
+ theme: (o = {}) => request('theme', o),
207
+ home: (o = {}) => request('home', o),
208
+ page: (slug, o = {}) => request('page', o, {}, slug),
209
+ content, posts, pages, events, resources, paginate,
210
+ contentById: (id, o = {}) => request('contentById', o, { kind: o.kind }, id),
211
+ async post(slug, o = {}) {
212
+ for await (const item of paginate.posts(o))
213
+ if (item.slug === slug)
214
+ return request('contentById', o, { kind: 'post' }, item.id);
215
+ throw new AcmsError(404, 'not_found', 'Post not found.');
216
+ },
217
+ /** Searches occurrences in the supplied window (server default: next 30 UTC days). */
218
+ async event(slug, o = {}) {
219
+ for await (const item of paginate.events(o))
220
+ if (item.event.slug === slug)
221
+ return item.event;
222
+ throw new AcmsError(404, 'not_found', 'Event not found in the requested window.');
223
+ },
224
+ async resource(slug, o = {}) {
225
+ for await (const item of paginate.resources(o))
226
+ if (item.slug === slug)
227
+ return item;
228
+ throw new AcmsError(404, 'not_found', 'Resource not found.');
229
+ },
230
+ calendars: (o = {}) => request('calendars', o),
231
+ availability: (o) => request('availability', o, { resource: o.resource, link: o.link, from: o.from, days: o.days, minutes: o.minutes }),
232
+ book: (o) => {
233
+ const { locale, signal, idempotencyKey, ...body } = o;
234
+ const key = idempotencyKey ?? defaultHeaders.get('Idempotency-Key') ?? uuid();
235
+ return request('book', { locale, signal }, {}, undefined, body, key);
236
+ },
237
+ openapi: (o = {}) => request('openapi', o),
238
+ registerDevice: (input, o = {}) => request('registerDevice', o, {}, undefined, input),
239
+ updateDevice: (token, input, o = {}) => request('updateDevice', o, {}, token, input),
240
+ unregisterDevice: (token, o = {}) => request('unregisterDevice', o, {}, token),
241
+ pushTopics: (o = {}) => request('pushTopics', o),
242
+ withLocale: (locale) => createClient({ ...options, locale, cache, headers: defaultHeaders }),
243
+ };
244
+ }