@backendfree/core 0.0.0-stage → 0.1.1

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/README.md CHANGED
@@ -1,3 +1,160 @@
1
- # Temporary Holding Version
1
+ # @backendfree/core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The client every other `@backendfree` package is built on. On its own it
4
+ gives you the project manifest and a typed way to call anything else `/v1`
5
+ offers.
6
+
7
+ `fetch`, `AbortSignal` and `crypto.randomUUID` and nothing else, so the same
8
+ import works in a build script, a Next.js route, a Worker at the edge and a
9
+ browser bundle. No dependencies.
10
+
11
+ ```sh
12
+ npm install @backendfree/core
13
+ ```
14
+
15
+ ```ts
16
+ import { Client } from '@backendfree/core';
17
+
18
+ const client = new Client({
19
+ origin: 'https://backendfree.com',
20
+ key: 'pk_live_...',
21
+ });
22
+
23
+ const project = await client.project();
24
+ // { id, name, timezone, currency, modules, cache_version, livemode }
25
+
26
+ if (project.modules.includes('bookings')) {
27
+ // ...
28
+ }
29
+ ```
30
+
31
+ ## Keys
32
+
33
+ A key identifies the project, which is why no path here names one and why there
34
+ is no project option to get wrong.
35
+
36
+ | | `pk_live_...` | `sk_live_...` |
37
+ |---|---|---|
38
+ | read services, staff, availability, published content | yes | yes |
39
+ | create a booking | yes | yes |
40
+ | read, cancel, move or pay for one booking | with its token | yes |
41
+ | list bookings, read payments, start a checkout | no | yes |
42
+ | safe in a browser bundle | yes | **never** |
43
+
44
+ Neither key refunds a payment or changes the catalogue, the hours or the
45
+ content: those happen in the dashboard, where the account owner signs in. A
46
+ leaked secret key can read the books, and it cannot empty them.
47
+
48
+ Constructing a client with an `sk_` key throws when `window` exists. It is the
49
+ one mistake with this product a deploy cannot take back: a secret key in a
50
+ bundle is every customer and every payment handed to whoever opens the network
51
+ tab.
52
+
53
+ ## Errors
54
+
55
+ Every `/v1` failure arrives in one envelope, and this package keeps its
56
+ vocabulary rather than inventing a second one.
57
+
58
+ ```ts
59
+ import { BackendFreeError, isNotFound, isRateLimited, isRetryable } from '@backendfree/core';
60
+
61
+ try {
62
+ await client.get('/services/' + id);
63
+ } catch (error) {
64
+ if (isNotFound(error)) { /* not there, not yours, or not switched on */ }
65
+ if (isRateLimited(error)) { /* error.retryAfter seconds */ }
66
+ if (isRetryable(error)) { /* send it again with the same idempotency key */ }
67
+ if (error instanceof BackendFreeError) {
68
+ error.type; // 'invalid_request' | 'rate_limit' | ...
69
+ error.code; // 'slot_unavailable', and what to branch on
70
+ error.param; // 'starts_at', or null
71
+ }
72
+ }
73
+ ```
74
+
75
+ `message` is written for a person and may be reworded at any time. Branching on
76
+ it will break; branching on `code` will not.
77
+
78
+ ## Caching
79
+
80
+ Reads of the catalogue, free times, content and the project carry an ETag, so a
81
+ repeat read costs a 304 with no body until something changes. It is on by
82
+ default, with the last 100 responses kept. The diary and one booking are read
83
+ fresh every time, since a stale one sends somebody to an appointment that moved.
84
+
85
+ ```ts
86
+ new Client({ origin, key, cache: false }); // off
87
+ new Client({ origin, key, cache: myStore }); // your own
88
+ await client.send('/project', { cache: false }); // just this one
89
+ ```
90
+
91
+ ## Writes
92
+
93
+ Every method that is not GET carries an `Idempotency-Key`, generated for you. A
94
+ retried booking that arrived twice would be two appointments in one slot, so
95
+ this is not something a developer should have to remember.
96
+
97
+ Pass your own to make a retry from a different process count as the same
98
+ request. Make it one nobody could guess, such as a UUID your order already
99
+ carries rather than its number:
100
+
101
+ ```ts
102
+ await client.post('/bookings', booking, { idempotencyKey: order.uuid });
103
+ ```
104
+
105
+ ## Webhooks
106
+
107
+ The other direction: the platform posts to a URL you gave it, and this says
108
+ whether to believe it. Your endpoint is public, so anybody can post to it, and
109
+ `payment.succeeded` is a message that marks an order paid.
110
+
111
+ ```ts
112
+ import { verifyWebhook } from '@backendfree/core';
113
+
114
+ const event = await verifyWebhook({
115
+ body: await request.text(), // the raw body, before anything parses it
116
+ headers: request.headers,
117
+ secret: process.env.BACKENDFREE_WEBHOOK_SECRET!,
118
+ });
119
+
120
+ event.name; // 'payment.succeeded', from the signed body
121
+ event.delivery; // this delivery's id, from the signed body
122
+ event.data; // the payload
123
+ ```
124
+
125
+ It verifies whatever a project emits, not just payments, so one handler covers
126
+ every module. It throws a `WebhookError` when the signature is missing, wrong,
127
+ or older than five minutes; answer 400 and log it.
128
+
129
+ The name and the id are read from the body, never from the
130
+ `X-BackendFree-Event` and `X-BackendFree-Delivery` headers. The signature covers
131
+ the timestamp and the body and nothing else, so on a captured delivery those two
132
+ headers could be rewritten and the signature would still pass.
133
+
134
+ The body must be the bytes that arrived. The signature covers them exactly, so
135
+ anything that parses first breaks every delivery:
136
+
137
+ ```ts
138
+ app.post('/hooks', express.raw({ type: '*/*' }), handler); // yes
139
+ app.use(express.json()); // not on this route
140
+ ```
141
+
142
+ Two more things follow from how delivery works. Answer quickly and do the work
143
+ afterwards, because a timeout, a refused connection and a 408, 429 or 5xx are
144
+ all retried. And make the handler idempotent on `event.delivery`, because
145
+ delivery is at least once and never exactly once.
146
+
147
+ ## Anything else
148
+
149
+ `request()`, `get()`, `post()` and `send()` reach any endpoint with the same
150
+ auth, timeout, errors, caching and idempotency, so the SDK never blocks you on
151
+ an endpoint it has not learned yet.
152
+
153
+ ```ts
154
+ const slots = await client.get('/availability', {
155
+ query: { service: id, from: '2026-06-10', to: '2026-06-17' },
156
+ });
157
+ ```
158
+
159
+ `send()` returns the status, the ETag and whether the API replayed a stored
160
+ answer, when you need more than the body.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The client every other @backendfree package is built on.
3
+ *
4
+ * `fetch`, `AbortSignal` and `crypto.randomUUID` and nothing else, so the same
5
+ * import works in a build script, a Next.js route, a Worker at the edge and a
6
+ * browser bundle.
7
+ *
8
+ * Five things are worth knowing before reading further.
9
+ *
10
+ * **No path names a project.** The key says which one, which is why there is no
11
+ * site or project option to get wrong and no way to read one project with
12
+ * another's key.
13
+ *
14
+ * **A secret key in a browser throws.** It is the one mistake with this product
15
+ * that cannot be undone by a deploy: a publishable key in a bundle is the
16
+ * design, and a secret one there is every booking, every customer and every
17
+ * payment handed to whoever opens the network tab.
18
+ *
19
+ * **Errors keep the API's own vocabulary.** No second set of names to learn, and
20
+ * `code` is what a caller branches on.
21
+ *
22
+ * **Conditional reads are on by default.** Every response carries an ETag, so a
23
+ * repeat read costs a 304 with no body until something changes.
24
+ *
25
+ * **Writes carry an idempotency key whether or not you pass one.** A retried
26
+ * booking must never be a second appointment, and the header is the only thing
27
+ * that makes a retry safe, so no developer should have to remember it.
28
+ */
29
+ import type { CacheEntry, CacheStore, Manifest, Response as Envelope } from './types.js';
30
+ /** Where `/v1` lives under an origin. */
31
+ export declare const API_PREFIX = "/api/v1";
32
+ /** Ten seconds. Long enough for a cold start, short enough to fail a build fast. */
33
+ export declare const DEFAULT_TIMEOUT = 10000;
34
+ /** How many ETagged responses the built-in cache keeps before evicting. */
35
+ export declare const DEFAULT_CACHE_SIZE = 100;
36
+ /** The API's own ceiling. Asking for more is capped server side. */
37
+ export declare const MAX_PAGE_SIZE = 100;
38
+ export interface ClientOptions {
39
+ /**
40
+ * Where the platform runs, with no trailing path: `https://backendfree.com`.
41
+ * The client appends `/api/v1` itself.
42
+ *
43
+ * Required rather than defaulted. The same client talks to production, to a
44
+ * local server and to any other copy of the platform, and a default would
45
+ * quietly send whatever forgot to set it to production.
46
+ */
47
+ origin: string;
48
+ /**
49
+ * `pk_live_...` in anything a browser can read, `sk_live_...` on a server.
50
+ * The prefix is in the key so it can be told apart at a glance.
51
+ */
52
+ key: string;
53
+ /**
54
+ * Swap in your own `fetch`: a test double, a Worker's bound fetcher, or
55
+ * Next.js's caching one with `{next: {revalidate}}` already baked in.
56
+ */
57
+ fetch?: typeof globalThis.fetch;
58
+ /** Per-request timeout in milliseconds. `0` disables it. */
59
+ timeout?: number;
60
+ /** ETag caching: on (default), off, or your own store. */
61
+ cache?: boolean | CacheStore;
62
+ /** Extra headers on every request: a proxy, a tunnel's bypass. */
63
+ headers?: Record<string, string>;
64
+ }
65
+ export interface RequestOptions {
66
+ method?: string;
67
+ /** `undefined` and `null` are dropped rather than sent as the string. */
68
+ query?: Record<string, string | number | boolean | undefined | null>;
69
+ /** Serialised as JSON. Anything but GET may carry one. */
70
+ body?: unknown;
71
+ headers?: Record<string, string>;
72
+ /**
73
+ * One request is one write, however many times it is sent. Generated for you
74
+ * on every method that is not GET; pass your own to make a retry from a
75
+ * different process count as the same request.
76
+ */
77
+ idempotencyKey?: string;
78
+ /** Pass `false` to bypass the ETag cache for this call. */
79
+ cache?: boolean;
80
+ signal?: AbortSignal;
81
+ }
82
+ export declare class Client {
83
+ #private;
84
+ readonly origin: string;
85
+ readonly publishable: boolean;
86
+ constructor(options: ClientOptions);
87
+ /**
88
+ * What this key can reach: the project, its clock, its currency and which
89
+ * modules are switched on.
90
+ *
91
+ * Worth calling first. It is the difference between "bookings is not enabled
92
+ * for this project" and a 404 from an endpoint the developer was told exists.
93
+ */
94
+ project(options?: RequestOptions): Promise<Manifest>;
95
+ /**
96
+ * Whether a module is switched on, without a second call.
97
+ *
98
+ * Reads the manifest, so it costs a 304 once the first read is cached.
99
+ */
100
+ has(module: string, options?: RequestOptions): Promise<boolean>;
101
+ get<T>(path: string, options?: RequestOptions): Promise<T>;
102
+ post<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T>;
103
+ /**
104
+ * Any call, typed by the caller.
105
+ *
106
+ * Deliberately public. The typed methods on this and every other package are
107
+ * conveniences over it, and when the API grows something the SDK has not
108
+ * learned yet this still reaches it, with the same auth, timeout, errors,
109
+ * caching and idempotency.
110
+ */
111
+ request<T>(path: string, options?: RequestOptions): Promise<T>;
112
+ /** The same call, with the status, the ETag and whether it was replayed. */
113
+ send<T>(path: string, options?: RequestOptions): Promise<Envelope<T>>;
114
+ }
115
+ /**
116
+ * The default ETag store: the last `DEFAULT_CACHE_SIZE` responses, oldest out.
117
+ *
118
+ * A Map keeps insertion order, so re-inserting on read makes this least
119
+ * recently used without a second structure.
120
+ */
121
+ export declare class MemoryCache implements CacheStore {
122
+ #private;
123
+ constructor(limit?: number);
124
+ get(key: string): CacheEntry | undefined;
125
+ set(key: string, entry: CacheEntry): void;
126
+ delete(key: string): void;
127
+ }
128
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAWH,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,QAAQ,EAAE,QAAQ,IAAI,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEzF,yCAAyC;AACzC,eAAO,MAAM,UAAU,YAAY,CAAC;AAEpC,oFAAoF;AACpF,eAAO,MAAM,eAAe,QAAS,CAAC;AAEtC,2EAA2E;AAC3E,eAAO,MAAM,kBAAkB,MAAM,CAAC;AAEtC,oEAAoE;AACpE,eAAO,MAAM,aAAa,MAAM,CAAC;AAEjC,MAAM,WAAW,aAAa;IAC5B;;;;;;;OAOG;IACH,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ;;;OAGG;IACH,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IAChC,4DAA4D;IAC5D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,0DAA0D;IAC1D,KAAK,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC;IAC7B,kEAAkE;IAClE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yEAAyE;IACzE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,GAAG,IAAI,CAAC,CAAC;IACrE,0DAA0D;IAC1D,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,2DAA2D;IAC3D,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,qBAAa,MAAM;;IACjB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;gBAQlB,OAAO,EAAE,aAAa;IA4ClC;;;;;;OAMG;IACG,OAAO,CAAC,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,QAAQ,CAAC;IAI9D;;;;OAIG;IACG,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,OAAO,CAAC;IAOzE,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,CAAC,CAAC;IAI9D,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,CAAC,CAAC;IAI/E;;;;;;;OAOG;IACG,OAAO,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,CAAC,CAAC;IAKxE,4EAA4E;IACtE,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;CAoFhF;AAyID;;;;;GAKG;AACH,qBAAa,WAAY,YAAW,UAAU;;gBAIhC,KAAK,SAAqB;IAItC,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS;IASxC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,IAAI;IASzC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;CAG1B"}
package/dist/client.js ADDED
@@ -0,0 +1,367 @@
1
+ /**
2
+ * The client every other @backendfree package is built on.
3
+ *
4
+ * `fetch`, `AbortSignal` and `crypto.randomUUID` and nothing else, so the same
5
+ * import works in a build script, a Next.js route, a Worker at the edge and a
6
+ * browser bundle.
7
+ *
8
+ * Five things are worth knowing before reading further.
9
+ *
10
+ * **No path names a project.** The key says which one, which is why there is no
11
+ * site or project option to get wrong and no way to read one project with
12
+ * another's key.
13
+ *
14
+ * **A secret key in a browser throws.** It is the one mistake with this product
15
+ * that cannot be undone by a deploy: a publishable key in a bundle is the
16
+ * design, and a secret one there is every booking, every customer and every
17
+ * payment handed to whoever opens the network tab.
18
+ *
19
+ * **Errors keep the API's own vocabulary.** No second set of names to learn, and
20
+ * `code` is what a caller branches on.
21
+ *
22
+ * **Conditional reads are on by default.** Every response carries an ETag, so a
23
+ * repeat read costs a 304 with no body until something changes.
24
+ *
25
+ * **Writes carry an idempotency key whether or not you pass one.** A retried
26
+ * booking must never be a second appointment, and the header is the only thing
27
+ * that makes a retry safe, so no developer should have to remember it.
28
+ */
29
+ import { ApiError, AuthError, ConfigError, NotFoundError, RateLimitError, UnreachableError, } from './errors.js';
30
+ /** Where `/v1` lives under an origin. */
31
+ export const API_PREFIX = '/api/v1';
32
+ /** Ten seconds. Long enough for a cold start, short enough to fail a build fast. */
33
+ export const DEFAULT_TIMEOUT = 10_000;
34
+ /** How many ETagged responses the built-in cache keeps before evicting. */
35
+ export const DEFAULT_CACHE_SIZE = 100;
36
+ /** The API's own ceiling. Asking for more is capped server side. */
37
+ export const MAX_PAGE_SIZE = 100;
38
+ export class Client {
39
+ origin;
40
+ publishable;
41
+ #key;
42
+ #fetch;
43
+ #timeout;
44
+ #headers;
45
+ #cache;
46
+ constructor(options) {
47
+ const origin = (options.origin ?? '').trim().replace(/\/+$/, '');
48
+ if (!origin) {
49
+ throw new ConfigError('origin_required', 'Pass the origin the platform runs on.');
50
+ }
51
+ const key = (options.key ?? '').trim();
52
+ if (!key) {
53
+ throw new ConfigError('key_required', 'Pass a project API key.');
54
+ }
55
+ // The one mistake this SDK refuses to let anybody make. A publishable key in
56
+ // a bundle is how the product is meant to work; a secret one there hands
57
+ // over every customer's name, address and appointment to anybody who opens
58
+ // the network tab, and no deploy takes it back once it has shipped.
59
+ if (key.startsWith('sk_') && typeof window !== 'undefined') {
60
+ throw new ConfigError('secret_key_in_browser', 'A secret key must never reach a browser. Use a publishable key (pk_) in ' +
61
+ 'anything shipped to the client, and keep sk_ on a server.');
62
+ }
63
+ this.origin = origin;
64
+ this.publishable = key.startsWith('pk_');
65
+ this.#key = key;
66
+ // Bound to the global, and that is load-bearing rather than tidy. Stored
67
+ // unbound, `this.#fetch(...)` calls it with the Client as its receiver, and
68
+ // a browser's `fetch` refuses any receiver but the window: every request
69
+ // from a browser fails with "Illegal invocation" before it reaches the
70
+ // network. Node's fetch has no such check, so nothing in this suite could
71
+ // see it and the failure only ever appeared in the one place the SDK is
72
+ // most meant to work.
73
+ //
74
+ // A caller's own fetch is left exactly as they passed it: binding somebody
75
+ // else's method to the global would break a client that is a method of
76
+ // something.
77
+ this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
78
+ this.#timeout = options.timeout ?? DEFAULT_TIMEOUT;
79
+ this.#headers = { ...options.headers };
80
+ this.#cache = resolveCache(options.cache);
81
+ }
82
+ // --- what a key can reach ---------------------------------------------------
83
+ /**
84
+ * What this key can reach: the project, its clock, its currency and which
85
+ * modules are switched on.
86
+ *
87
+ * Worth calling first. It is the difference between "bookings is not enabled
88
+ * for this project" and a 404 from an endpoint the developer was told exists.
89
+ */
90
+ async project(options = {}) {
91
+ return this.get('/project', options);
92
+ }
93
+ /**
94
+ * Whether a module is switched on, without a second call.
95
+ *
96
+ * Reads the manifest, so it costs a 304 once the first read is cached.
97
+ */
98
+ async has(module, options = {}) {
99
+ const manifest = await this.project(options);
100
+ return manifest.modules.includes(module);
101
+ }
102
+ // --- the escape hatch --------------------------------------------------------
103
+ get(path, options = {}) {
104
+ return this.request(path, { ...options, method: 'GET' });
105
+ }
106
+ post(path, body, options = {}) {
107
+ return this.request(path, { ...options, method: 'POST', body });
108
+ }
109
+ /**
110
+ * Any call, typed by the caller.
111
+ *
112
+ * Deliberately public. The typed methods on this and every other package are
113
+ * conveniences over it, and when the API grows something the SDK has not
114
+ * learned yet this still reaches it, with the same auth, timeout, errors,
115
+ * caching and idempotency.
116
+ */
117
+ async request(path, options = {}) {
118
+ const { data } = await this.send(path, options);
119
+ return data;
120
+ }
121
+ /** The same call, with the status, the ETag and whether it was replayed. */
122
+ async send(path, options = {}) {
123
+ const method = (options.method ?? 'GET').toUpperCase();
124
+ const url = this.#url(path, options.query);
125
+ const cacheable = method === 'GET' && options.cache !== false && this.#cache !== null;
126
+ const cached = cacheable ? this.#cache.get(url) : undefined;
127
+ const headers = {
128
+ accept: 'application/json',
129
+ authorization: `Bearer ${this.#key}`,
130
+ ...this.#headers,
131
+ ...lower(options.headers),
132
+ };
133
+ if (cached)
134
+ headers['if-none-match'] = cached.etag;
135
+ let body;
136
+ if (options.body !== undefined && method !== 'GET') {
137
+ body = JSON.stringify(options.body);
138
+ headers['content-type'] = 'application/json';
139
+ }
140
+ if (method !== 'GET') {
141
+ // Generated rather than asked for. A retried booking that arrived twice
142
+ // is two appointments in one slot, and the header is the only thing that
143
+ // makes the retry safe.
144
+ //
145
+ // An empty string falls back to a generated one rather than being sent as
146
+ // an empty header, which the API reads as no key at all: there is no
147
+ // reason to want a write that cannot be safely retried.
148
+ headers['idempotency-key'] = options.idempotencyKey || newIdempotencyKey();
149
+ }
150
+ const response = await this.#fetch2(url, { method, headers, body, signal: options.signal });
151
+ if (response.status === 304 && cached) {
152
+ return { data: cached.body, status: 304, etag: cached.etag, replayed: false };
153
+ }
154
+ if (!response.ok) {
155
+ throw await toError(response, url);
156
+ }
157
+ const etag = response.headers.get('etag');
158
+ const data = response.status === 204 ? undefined : (await readJson(response, url));
159
+ if (cacheable && etag)
160
+ this.#cache.set(url, { etag, body: data });
161
+ return {
162
+ data,
163
+ status: response.status,
164
+ etag,
165
+ replayed: response.headers.get('idempotency-replayed') === 'true',
166
+ };
167
+ }
168
+ #url(path, query) {
169
+ const url = new URL(`${API_PREFIX}${path.startsWith('/') ? path : `/${path}`}`, this.origin);
170
+ for (const [name, value] of Object.entries(query ?? {})) {
171
+ // Dropped rather than sent as "undefined", which the API would read as a
172
+ // filter nobody asked for.
173
+ if (value === undefined || value === null || value === '')
174
+ continue;
175
+ url.searchParams.set(name, String(value));
176
+ }
177
+ return url.toString();
178
+ }
179
+ async #fetch2(url, init) {
180
+ const controller = this.#timeout > 0 ? new AbortController() : null;
181
+ const timer = controller ? setTimeout(() => controller.abort(), this.#timeout) : null;
182
+ const signal = mergeSignals(init.signal ?? null, controller?.signal ?? null);
183
+ try {
184
+ return await this.#fetch(url, { ...init, signal });
185
+ }
186
+ catch (cause) {
187
+ // Every way of not getting an answer, told apart from an answer that says
188
+ // no. A build script should shrug at this one and keep what it has.
189
+ const timedOut = controller?.signal.aborted ?? false;
190
+ throw new UnreachableError(url, timedOut
191
+ ? `No answer from ${url} within ${this.#timeout}ms.`
192
+ : `Could not reach ${url}.`, cause);
193
+ }
194
+ finally {
195
+ if (timer)
196
+ clearTimeout(timer);
197
+ }
198
+ }
199
+ }
200
+ // --- turning a response into an error ---------------------------------------
201
+ const TYPES = new Set([
202
+ 'invalid_request',
203
+ 'authentication_error',
204
+ 'permission_error',
205
+ 'not_found',
206
+ 'conflict',
207
+ 'rate_limit',
208
+ 'api_error',
209
+ ]);
210
+ async function toError(response, url) {
211
+ const envelope = await readEnvelope(response);
212
+ const status = response.status;
213
+ const options = { url, status, param: envelope.param };
214
+ const type = (TYPES.has(envelope.type) ? envelope.type : fallbackType(status));
215
+ if (type === 'authentication_error' || type === 'permission_error') {
216
+ return new AuthError(type, envelope.code, envelope.message, options);
217
+ }
218
+ if (type === 'not_found') {
219
+ return new NotFoundError(type, envelope.code, envelope.message, options);
220
+ }
221
+ if (type === 'rate_limit') {
222
+ return new RateLimitError(envelope.code, envelope.message, {
223
+ url,
224
+ status,
225
+ retryAfter: retryAfter(response),
226
+ });
227
+ }
228
+ return new ApiError(type, envelope.code, envelope.message, options);
229
+ }
230
+ /**
231
+ * The envelope, or something that stands in for it.
232
+ *
233
+ * A 502 from a proxy in front of the platform never went through the error
234
+ * handler, so it answers HTML or nothing at all. That is still a failure a
235
+ * caller has to handle, so it gets the same shape rather than a parse error.
236
+ */
237
+ async function readEnvelope(response) {
238
+ const fallback = {
239
+ type: fallbackType(response.status),
240
+ code: `http_${response.status}`,
241
+ message: `${response.status} ${response.statusText}`.trim(),
242
+ param: null,
243
+ };
244
+ try {
245
+ const payload = (await response.json());
246
+ const error = payload?.error;
247
+ if (!error)
248
+ return fallback;
249
+ return {
250
+ type: typeof error.type === 'string' ? error.type : fallback.type,
251
+ code: typeof error.code === 'string' ? error.code : fallback.code,
252
+ message: typeof error.message === 'string' ? error.message : fallback.message,
253
+ param: typeof error.param === 'string' ? error.param : null,
254
+ };
255
+ }
256
+ catch {
257
+ return fallback;
258
+ }
259
+ }
260
+ function fallbackType(status) {
261
+ if (status === 401)
262
+ return 'authentication_error';
263
+ if (status === 403)
264
+ return 'permission_error';
265
+ if (status === 404)
266
+ return 'not_found';
267
+ if (status === 409)
268
+ return 'conflict';
269
+ if (status === 429)
270
+ return 'rate_limit';
271
+ if (status >= 500)
272
+ return 'api_error';
273
+ return 'invalid_request';
274
+ }
275
+ function retryAfter(response) {
276
+ const header = response.headers.get('retry-after');
277
+ if (!header)
278
+ return null;
279
+ const seconds = Number.parseInt(header, 10);
280
+ return Number.isFinite(seconds) ? seconds : null;
281
+ }
282
+ async function readJson(response, url) {
283
+ try {
284
+ return await response.json();
285
+ }
286
+ catch (cause) {
287
+ throw new ApiError('api_error', 'malformed_response', `${url} answered with something that is not JSON.`, {
288
+ url,
289
+ status: response.status,
290
+ cause,
291
+ });
292
+ }
293
+ }
294
+ // --- small parts --------------------------------------------------------------
295
+ function newIdempotencyKey() {
296
+ const crypto = globalThis.crypto;
297
+ if (typeof crypto?.randomUUID === 'function') {
298
+ return crypto.randomUUID();
299
+ }
300
+ // A browser offers `randomUUID` only on a page served over https, and
301
+ // `getRandomValues` everywhere. Never `Math.random`: a key somebody can
302
+ // guess is one they can send first, and whoever sends a key with the same
303
+ // body is answered with what it made, a booking's manage token included.
304
+ // Not a v4 UUID and does not need to be: the API treats it as an opaque
305
+ // string scoped to one project.
306
+ if (typeof crypto?.getRandomValues === 'function') {
307
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
308
+ return `k_${[...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`;
309
+ }
310
+ throw new ConfigError('crypto_unavailable', 'This runtime has no Web Crypto, so a write cannot carry an unguessable Idempotency-Key. ' +
311
+ 'Pass idempotencyKey yourself.');
312
+ }
313
+ function lower(headers) {
314
+ const out = {};
315
+ for (const [name, value] of Object.entries(headers ?? {}))
316
+ out[name.toLowerCase()] = value;
317
+ return out;
318
+ }
319
+ function mergeSignals(a, b) {
320
+ if (!a)
321
+ return b ?? undefined;
322
+ if (!b)
323
+ return a;
324
+ // Both a caller's cancellation and the timeout have to be able to stop this.
325
+ return AbortSignal.any([a, b]);
326
+ }
327
+ function resolveCache(cache) {
328
+ if (cache === false)
329
+ return null;
330
+ if (cache && typeof cache === 'object')
331
+ return cache;
332
+ return new MemoryCache();
333
+ }
334
+ /**
335
+ * The default ETag store: the last `DEFAULT_CACHE_SIZE` responses, oldest out.
336
+ *
337
+ * A Map keeps insertion order, so re-inserting on read makes this least
338
+ * recently used without a second structure.
339
+ */
340
+ export class MemoryCache {
341
+ #entries = new Map();
342
+ #limit;
343
+ constructor(limit = DEFAULT_CACHE_SIZE) {
344
+ this.#limit = limit;
345
+ }
346
+ get(key) {
347
+ const entry = this.#entries.get(key);
348
+ if (entry) {
349
+ this.#entries.delete(key);
350
+ this.#entries.set(key, entry);
351
+ }
352
+ return entry;
353
+ }
354
+ set(key, entry) {
355
+ this.#entries.delete(key);
356
+ this.#entries.set(key, entry);
357
+ if (this.#entries.size > this.#limit) {
358
+ const oldest = this.#entries.keys().next();
359
+ if (!oldest.done)
360
+ this.#entries.delete(oldest.value);
361
+ }
362
+ }
363
+ delete(key) {
364
+ this.#entries.delete(key);
365
+ }
366
+ }
367
+ //# sourceMappingURL=client.js.map