@tiempo/sdk 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/LICENSE +21 -0
- package/README.md +269 -2
- package/dist/cjs/client.d.ts +24 -0
- package/dist/cjs/client.js +42 -0
- package/dist/cjs/core.d.ts +72 -0
- package/dist/cjs/core.js +187 -0
- package/dist/cjs/crypto.d.ts +5 -0
- package/dist/cjs/crypto.js +29 -0
- package/dist/cjs/errors.d.ts +86 -0
- package/dist/cjs/errors.js +134 -0
- package/dist/cjs/events.d.ts +23 -0
- package/dist/cjs/events.js +10 -0
- package/dist/cjs/generated/events.d.ts +51 -0
- package/dist/cjs/generated/events.js +51 -0
- package/dist/cjs/generated/meta.d.ts +4 -0
- package/dist/cjs/generated/meta.js +8 -0
- package/dist/cjs/generated/openapi.d.ts +3721 -0
- package/dist/cjs/generated/openapi.js +2 -0
- package/dist/cjs/generated/resources.d.ts +416 -0
- package/dist/cjs/generated/resources.js +621 -0
- package/dist/cjs/generated/types.d.ts +134 -0
- package/dist/cjs/generated/types.js +3 -0
- package/dist/cjs/index.d.ts +16 -0
- package/dist/cjs/index.js +49 -0
- package/dist/cjs/list.d.ts +43 -0
- package/dist/cjs/list.js +78 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/query.d.ts +8 -0
- package/dist/cjs/query.js +47 -0
- package/dist/cjs/shared.d.ts +7 -0
- package/dist/cjs/shared.js +2 -0
- package/dist/cjs/webhooks.d.ts +34 -0
- package/dist/cjs/webhooks.js +115 -0
- package/dist/esm/client.d.ts +24 -0
- package/dist/esm/client.js +38 -0
- package/dist/esm/core.d.ts +72 -0
- package/dist/esm/core.js +181 -0
- package/dist/esm/crypto.d.ts +5 -0
- package/dist/esm/crypto.js +26 -0
- package/dist/esm/errors.d.ts +86 -0
- package/dist/esm/errors.js +120 -0
- package/dist/esm/events.d.ts +23 -0
- package/dist/esm/events.js +8 -0
- package/dist/esm/generated/events.d.ts +51 -0
- package/dist/esm/generated/events.js +48 -0
- package/dist/esm/generated/meta.d.ts +4 -0
- package/dist/esm/generated/meta.js +5 -0
- package/dist/esm/generated/openapi.d.ts +3721 -0
- package/dist/esm/generated/openapi.js +1 -0
- package/dist/esm/generated/resources.d.ts +416 -0
- package/dist/esm/generated/resources.js +603 -0
- package/dist/esm/generated/types.d.ts +134 -0
- package/dist/esm/generated/types.js +2 -0
- package/dist/esm/index.d.ts +16 -0
- package/dist/esm/index.js +10 -0
- package/dist/esm/list.d.ts +43 -0
- package/dist/esm/list.js +74 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/query.d.ts +8 -0
- package/dist/esm/query.js +43 -0
- package/dist/esm/shared.d.ts +7 -0
- package/dist/esm/shared.js +1 -0
- package/dist/esm/webhooks.d.ts +34 -0
- package/dist/esm/webhooks.js +109 -0
- package/package.json +74 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Double-Tap Development Ltd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,270 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tiempo/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
TypeScript client for the Tiempo scheduling API. Node 18+, no runtime dependencies
|
|
4
|
+
(uses the global `fetch` and Web Crypto). Ships ES modules and CommonJS, with types.
|
|
5
|
+
|
|
6
|
+
The types are generated from Tiempo's OpenAPI spec and match the API version the SDK
|
|
7
|
+
pins (`API_VERSION`).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @tiempo/sdk
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
This package was `@doubletap/tiempo` until 0.1.0. To move, change the dependency and your imports;
|
|
16
|
+
nothing else changes.
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { Tiempo } from '@tiempo/sdk'; // ESM
|
|
20
|
+
const { Tiempo } = require('@tiempo/sdk'); // CommonJS
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick start: hold and confirm a booking
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { Tiempo } from '@tiempo/sdk';
|
|
27
|
+
|
|
28
|
+
// A test key (tpt_…) while you build. Calls https://api.tiempo.one unless you give a baseUrl.
|
|
29
|
+
const tiempo = new Tiempo(process.env.TIEMPO_API_KEY!);
|
|
30
|
+
|
|
31
|
+
// One account per business (platform-level call).
|
|
32
|
+
const account = await tiempo.accounts.create({ name: 'Soho Hair', timezone: 'Europe/London' });
|
|
33
|
+
const soho = tiempo.withAccount(account.id);
|
|
34
|
+
|
|
35
|
+
const ann = await soho.resources.create({ name: 'Ann', type: 'worker' });
|
|
36
|
+
await soho.schedules.create({
|
|
37
|
+
resource: ann.id,
|
|
38
|
+
effective_from: '2027-01-04',
|
|
39
|
+
weeks: [{ monday: [{ start: '09:00', end: '17:00' }] }],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// When can Ann do 45 minutes?
|
|
43
|
+
const { slots } = await soho.availability.search({
|
|
44
|
+
from: '2027-01-04T00:00:00Z', // a Monday
|
|
45
|
+
to: '2027-01-05T00:00:00Z',
|
|
46
|
+
interval_minutes: 15,
|
|
47
|
+
segments: [{ resources: [ann.id], duration_minutes: 45 }],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Hold the first slot (status "pending" until expires_at), then confirm it.
|
|
51
|
+
// A slot's segments are a booking's segments: send them back as they are.
|
|
52
|
+
const hold = await soho.bookings.create({
|
|
53
|
+
segments: slots[0].segments,
|
|
54
|
+
metadata: { your_booking_id: 'b_123' },
|
|
55
|
+
});
|
|
56
|
+
const booking = await soho.bookings.confirm(hold.id);
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Every method takes path ids first, then its params (the JSON body for writes, the query
|
|
60
|
+
for reads), then optional per-request options:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
await soho.bookings.cancel(booking.id, { reason: 'customer_request' }, { idempotencyKey: 'cancel-b_123' });
|
|
64
|
+
await soho.attendees.create(sessionId, { seats: 2 }); // POST /v1/bookings/{id}/attendees
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Options
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
new Tiempo(apiKey, {
|
|
71
|
+
baseUrl: 'https://api.tiempo.one', // the default
|
|
72
|
+
account: 'acct_…', // act for one business on every call (same as withAccount)
|
|
73
|
+
apiVersion: '2026-10-04', // default: API_VERSION, the version these types describe
|
|
74
|
+
maxRetries: 2, // retries after the first attempt
|
|
75
|
+
timeout: 30_000, // per attempt, ms
|
|
76
|
+
fetch: customFetch, // default: global fetch
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Per request (last argument): `{ account, idempotencyKey, maxRetries, timeout, signal }`.
|
|
81
|
+
|
|
82
|
+
For a complete app built on this SDK (search, holds, Stripe payment, webhooks and a race test),
|
|
83
|
+
see [booking-starter](https://github.com/tiempo-one/booking-starter), a Next.js barbershop you can
|
|
84
|
+
fork.
|
|
85
|
+
|
|
86
|
+
## Businesses (`withAccount`)
|
|
87
|
+
|
|
88
|
+
A platform key acts for the platform. Business-scoped calls (resources, schedules,
|
|
89
|
+
bookings, …) need a business, sent as the `Tiempo-Account` header:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
const soho = tiempo.withAccount('acct_…'); // shares key and settings
|
|
93
|
+
await soho.bookings.list();
|
|
94
|
+
|
|
95
|
+
await tiempo.bookings.get('bkg_…', { account: 'acct_…' }); // or per request
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Platform-level calls (`accounts`, `webhookEndpoints`, `usage`, `events.get`) don't need
|
|
99
|
+
one. `events.list` lists every business's events, or one business's on a scoped client.
|
|
100
|
+
|
|
101
|
+
## Pagination
|
|
102
|
+
|
|
103
|
+
List methods return a `ListPromise`. Await it for one page:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
const page = await soho.bookings.list({ status: 'confirmed', limit: 50 });
|
|
107
|
+
page.data; // Booking[]
|
|
108
|
+
page.has_more; // boolean
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Or iterate it to walk every page (`starting_after` = the last item's id):
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
for await (const booking of soho.bookings.list({ from: '2027-01-01T00:00:00Z' })) {
|
|
115
|
+
// …
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const first200 = await soho.bookings.list().toArray({ limit: 200 });
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
If you pass `ending_before`, iteration goes backwards (`ending_before` = each page's first
|
|
122
|
+
item) and yields each page's items last to first.
|
|
123
|
+
|
|
124
|
+
Filter by metadata with a map; each entry is sent as `metadata[key]=value` and all must
|
|
125
|
+
match:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
soho.bookings.list({ metadata: { your_booking_id: 'b_123' } });
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Errors
|
|
132
|
+
|
|
133
|
+
Failed calls throw a subclass of `TiempoError`:
|
|
134
|
+
|
|
135
|
+
| Class | When |
|
|
136
|
+
|---|---|
|
|
137
|
+
| `TiempoInvalidRequestError` | 400/404: bad params, unknown id |
|
|
138
|
+
| `TiempoAuthenticationError` | 401: missing or bad key |
|
|
139
|
+
| `TiempoPermissionError` | 403 |
|
|
140
|
+
| `TiempoConflictError` | 409: slot taken, wrong state, … |
|
|
141
|
+
| `TiempoIdempotencyError` | key reused with different params, or still in flight |
|
|
142
|
+
| `TiempoRateLimitError` | 429 |
|
|
143
|
+
| `TiempoAPIError` | 5xx |
|
|
144
|
+
| `TiempoConnectionError` | network failure or timeout (no response) |
|
|
145
|
+
| `TiempoSignatureVerificationError` | webhook signature didn't verify |
|
|
146
|
+
|
|
147
|
+
Each carries `message`, `type`, `code`, `param`, `statusCode`, `headers`, `raw` (the parsed body)
|
|
148
|
+
and `details`: any fields the error carries beyond the first four, such as `conflicts`.
|
|
149
|
+
`TiempoConflictError` has typed shortcuts:
|
|
150
|
+
|
|
151
|
+
| Shortcut | Set on | What it holds |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| `conflicts` | `booking_conflict`, `session_conflicted` | why each segment doesn't fit |
|
|
154
|
+
| `occurrences` | `series_update_conflict` | the occurrences an edit couldn't place |
|
|
155
|
+
| `seatsAvailable` | `seats_unavailable` | seats still free |
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { TiempoConflictError, TiempoInvalidRequestError } from '@tiempo/sdk';
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
await soho.bookings.create({ segments: [{ resource: ann.id, start, end }] });
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (err instanceof TiempoConflictError) {
|
|
164
|
+
// the slot went: err.code === 'booking_conflict'
|
|
165
|
+
for (const c of err.conflicts) console.log(c.segment, c.reason); // 0 "capacity_exceeded"
|
|
166
|
+
} else if (err instanceof TiempoInvalidRequestError) {
|
|
167
|
+
console.log(err.param, err.message); // "name", "Invalid name: must not be blank."
|
|
168
|
+
} else {
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Idempotency and retries
|
|
175
|
+
|
|
176
|
+
Every POST carries an `Idempotency-Key` (a random UUID unless you pass
|
|
177
|
+
`idempotencyKey`). Retries of that call reuse the key, so a retried create can't double
|
|
178
|
+
book: the server replays the stored response. Pass your own key when the retry happens
|
|
179
|
+
outside the SDK (a re-run job, a retried Cloud Function):
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
await soho.bookings.create(params, { idempotencyKey: `booking-${yourBookingId}` });
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
A key reused with different params throws `TiempoIdempotencyError`.
|
|
186
|
+
|
|
187
|
+
The SDK retries network errors, timeouts, 429, 500, 502, 503, 504 and 409
|
|
188
|
+
`idempotency_key_in_use`, up to `maxRetries` (default 2) times. It never retries other
|
|
189
|
+
4xx. Backoff is 0.5s × 2^n with jitter, capped at 8s; a `Retry-After` header (seconds,
|
|
190
|
+
capped at 60) wins when present. `timeout` applies to each attempt. Pass `signal` to
|
|
191
|
+
abort a call, including any wait between retries; the promise rejects with the signal's
|
|
192
|
+
reason.
|
|
193
|
+
|
|
194
|
+
## Webhooks
|
|
195
|
+
|
|
196
|
+
Tiempo signs each delivery with your endpoint's `tpw_…` secret:
|
|
197
|
+
`Tiempo-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`.
|
|
198
|
+
`constructEvent` checks it (constant-time, any `v1` may match, timestamp within
|
|
199
|
+
`tolerance`, default 300s) and returns a typed event. It needs the raw body bytes, not
|
|
200
|
+
re-serialised JSON.
|
|
201
|
+
|
|
202
|
+
Express:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
import express from 'express';
|
|
206
|
+
import { constructEvent, TiempoSignatureVerificationError } from '@tiempo/sdk';
|
|
207
|
+
|
|
208
|
+
const app = express();
|
|
209
|
+
|
|
210
|
+
app.post('/webhooks/tiempo', express.raw({ type: 'application/json' }), async (req, res) => {
|
|
211
|
+
let event;
|
|
212
|
+
try {
|
|
213
|
+
event = await constructEvent(req.body, req.header('Tiempo-Signature'), process.env.TIEMPO_WEBHOOK_SECRET!);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
if (err instanceof TiempoSignatureVerificationError) return res.status(400).send(err.message);
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
switch (event.type) {
|
|
220
|
+
case 'booking.confirmed':
|
|
221
|
+
// event.data.object is a Booking
|
|
222
|
+
break;
|
|
223
|
+
case 'booking.expired':
|
|
224
|
+
break;
|
|
225
|
+
case 'series.occurrence_skipped':
|
|
226
|
+
// event.data.object is a SkippedOccurrence
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
res.sendStatus(200);
|
|
230
|
+
});
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Firebase HTTPS functions expose the raw body as `req.rawBody`; pass that. It's also
|
|
234
|
+
available on a client as `tiempo.webhooks.constructEvent`.
|
|
235
|
+
|
|
236
|
+
`TiempoEvent` is a union on `type`; narrowing gives `data.object` its type.
|
|
237
|
+
`data.previous_attributes` holds the old values of the fields a change altered: on
|
|
238
|
+
`*.updated` events; `status` on changes of state (plus `conflicted` and `conflicts` when a
|
|
239
|
+
conflicted booking is cancelled, expires or finishes); `conflicted` and `conflicts` on
|
|
240
|
+
`booking.conflicted` and `booking.conflict_resolved`; and on `booking.rescheduled`, always
|
|
241
|
+
`segments`, `start` and `end` (compare them with `data.object` to see what moved). It's `null`
|
|
242
|
+
on `*.created`. `EVENT_TYPES` lists every type; it and each type's object are
|
|
243
|
+
generated from the spec's `Event` schema, so new event types arrive with `npm run generate`.
|
|
244
|
+
|
|
245
|
+
Testing your handler:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
import { generateTestHeader } from '@tiempo/sdk';
|
|
249
|
+
|
|
250
|
+
const payload = JSON.stringify({ id: 'evt_1', object: 'event', type: 'booking.confirmed', /* … */ });
|
|
251
|
+
const header = await generateTestHeader({ payload, secret: 'tpw_test' });
|
|
252
|
+
await request(app).post('/webhooks/tiempo').set('Tiempo-Signature', header).type('json').send(payload);
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## API versions
|
|
256
|
+
|
|
257
|
+
Tiempo versions its API by date. The SDK sends `Tiempo-Version: API_VERSION` on every
|
|
258
|
+
request, and its types describe that version, so upgrading the SDK is how you move to a
|
|
259
|
+
new API version. Webhook payloads are rendered in your platform's pinned version
|
|
260
|
+
(`event.api_version`); keep the platform pin and the SDK in step.
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
import { API_VERSION } from '@tiempo/sdk';
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
You can override `apiVersion` in the constructor, but the types won't follow.
|
|
267
|
+
|
|
268
|
+
## Licence
|
|
269
|
+
|
|
270
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type TiempoConfig } from './core.js';
|
|
2
|
+
import { TiempoResources } from './generated/resources.js';
|
|
3
|
+
import { Webhooks } from './webhooks.js';
|
|
4
|
+
/**
|
|
5
|
+
* The Tiempo API client.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* const tiempo = new Tiempo(process.env.TIEMPO_API_KEY!); // https://api.tiempo.one unless you give a baseUrl
|
|
9
|
+
* const business = tiempo.withAccount('acct_…');
|
|
10
|
+
* const booking = await business.bookings.create({ segments: [...] });
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export declare class Tiempo extends TiempoResources {
|
|
14
|
+
/** Webhook signature verification. */
|
|
15
|
+
readonly webhooks: Webhooks;
|
|
16
|
+
private readonly core;
|
|
17
|
+
constructor(apiKey: string, config?: TiempoConfig);
|
|
18
|
+
/** A client that acts for one business (`acct_…`), sharing this one's key and settings. */
|
|
19
|
+
withAccount(account: string): Tiempo;
|
|
20
|
+
/** The business this client acts for, if any. */
|
|
21
|
+
get account(): string | undefined;
|
|
22
|
+
/** The `Tiempo-Version` this client sends. */
|
|
23
|
+
get apiVersion(): string;
|
|
24
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Tiempo = void 0;
|
|
4
|
+
const core_js_1 = require("./core.js");
|
|
5
|
+
const resources_js_1 = require("./generated/resources.js");
|
|
6
|
+
const webhooks_js_1 = require("./webhooks.js");
|
|
7
|
+
/**
|
|
8
|
+
* The Tiempo API client.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* const tiempo = new Tiempo(process.env.TIEMPO_API_KEY!); // https://api.tiempo.one unless you give a baseUrl
|
|
12
|
+
* const business = tiempo.withAccount('acct_…');
|
|
13
|
+
* const booking = await business.bookings.create({ segments: [...] });
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
class Tiempo extends resources_js_1.TiempoResources {
|
|
17
|
+
/** Webhook signature verification. */
|
|
18
|
+
webhooks;
|
|
19
|
+
core;
|
|
20
|
+
constructor(apiKey, config, resolved) {
|
|
21
|
+
const core = new core_js_1.HttpClient(resolved ?? core_js_1.HttpClient.resolve(apiKey, config));
|
|
22
|
+
super(core);
|
|
23
|
+
this.core = core;
|
|
24
|
+
this.webhooks = new webhooks_js_1.Webhooks();
|
|
25
|
+
}
|
|
26
|
+
/** A client that acts for one business (`acct_…`), sharing this one's key and settings. */
|
|
27
|
+
withAccount(account) {
|
|
28
|
+
if (typeof account !== 'string' || !account)
|
|
29
|
+
throw new TypeError('Tiempo: withAccount needs an account id (acct_…)');
|
|
30
|
+
const cfg = this.core.config;
|
|
31
|
+
return new Tiempo(cfg.apiKey, { baseUrl: cfg.baseUrl }, { ...cfg, account });
|
|
32
|
+
}
|
|
33
|
+
/** The business this client acts for, if any. */
|
|
34
|
+
get account() {
|
|
35
|
+
return this.core.config.account;
|
|
36
|
+
}
|
|
37
|
+
/** The `Tiempo-Version` this client sends. */
|
|
38
|
+
get apiVersion() {
|
|
39
|
+
return this.core.config.apiVersion;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
exports.Tiempo = Tiempo;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { ListPromise } from './list.js';
|
|
2
|
+
/** Client options. */
|
|
3
|
+
export interface TiempoConfig {
|
|
4
|
+
/**
|
|
5
|
+
* Where the API lives. Defaults to {@link DEFAULT_BASE_URL}, Tiempo itself; set it for the test
|
|
6
|
+
* deployment (`https://api.uat.tiempo.one`) or a local one (`http://localhost:8080`).
|
|
7
|
+
*/
|
|
8
|
+
baseUrl?: string;
|
|
9
|
+
/**
|
|
10
|
+
* The `Tiempo-Version` to send. Defaults to {@link API_VERSION}, the version
|
|
11
|
+
* this SDK's types describe; change it only if you know the shapes match.
|
|
12
|
+
*/
|
|
13
|
+
apiVersion?: string;
|
|
14
|
+
/** Act for this business (`acct_…`) on every request: sends `Tiempo-Account`. */
|
|
15
|
+
account?: string;
|
|
16
|
+
/** Retries after the first attempt for retryable failures. Default 2. */
|
|
17
|
+
maxRetries?: number;
|
|
18
|
+
/** Per-attempt timeout in milliseconds. Default 30000. */
|
|
19
|
+
timeout?: number;
|
|
20
|
+
/** A `fetch` implementation. Defaults to the global one. */
|
|
21
|
+
fetch?: typeof fetch;
|
|
22
|
+
}
|
|
23
|
+
/** Per-request options; the last argument of every method. */
|
|
24
|
+
export interface RequestOptions {
|
|
25
|
+
/** Act for this business (`acct_…`), overriding the client's account. */
|
|
26
|
+
account?: string;
|
|
27
|
+
/** Idempotency key for a POST. Default: a fresh UUID per call, reused across its retries. */
|
|
28
|
+
idempotencyKey?: string;
|
|
29
|
+
/** Overrides the client's `maxRetries` for this call. */
|
|
30
|
+
maxRetries?: number;
|
|
31
|
+
/** Overrides the client's per-attempt `timeout` (ms) for this call. */
|
|
32
|
+
timeout?: number;
|
|
33
|
+
/** Aborts the call (including any pending retry). The promise rejects with the signal's reason. */
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
}
|
|
36
|
+
/** An HTTP call, as generated resource methods describe it. */
|
|
37
|
+
export interface RequestSpec {
|
|
38
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
39
|
+
path: string;
|
|
40
|
+
query?: object | undefined;
|
|
41
|
+
body?: unknown;
|
|
42
|
+
options?: RequestOptions | undefined;
|
|
43
|
+
/** A POST that only reads (availability search): sent without an Idempotency-Key. */
|
|
44
|
+
readOnly?: boolean;
|
|
45
|
+
}
|
|
46
|
+
/** What generated resource classes call. */
|
|
47
|
+
export interface Core {
|
|
48
|
+
request<T>(spec: RequestSpec): Promise<T>;
|
|
49
|
+
list<T>(spec: RequestSpec, paginates: boolean): ListPromise<T>;
|
|
50
|
+
}
|
|
51
|
+
export interface ResolvedConfig {
|
|
52
|
+
apiKey: string;
|
|
53
|
+
baseUrl: string;
|
|
54
|
+
apiVersion: string;
|
|
55
|
+
account: string | undefined;
|
|
56
|
+
maxRetries: number;
|
|
57
|
+
timeout: number;
|
|
58
|
+
fetch: typeof fetch | undefined;
|
|
59
|
+
}
|
|
60
|
+
/** Tiempo's API, where the SDK points unless `baseUrl` says otherwise. */
|
|
61
|
+
export declare const DEFAULT_BASE_URL = "https://api.tiempo.one";
|
|
62
|
+
/** Backoff before retry `attempt` (0-based): 0.5s × 2^n, capped at 8s, jittered to 50–100%. */
|
|
63
|
+
export declare function backoffMs(attempt: number, random?: () => number): number;
|
|
64
|
+
/** Parses `Retry-After` (seconds or an HTTP date) into ms; undefined if absent or unparseable. */
|
|
65
|
+
export declare function retryAfterMs(header: string | null | undefined, now?: number): number | undefined;
|
|
66
|
+
export declare class HttpClient implements Core {
|
|
67
|
+
readonly config: ResolvedConfig;
|
|
68
|
+
constructor(config: ResolvedConfig);
|
|
69
|
+
static resolve(apiKey: string, config: TiempoConfig | undefined): ResolvedConfig;
|
|
70
|
+
list<T>(spec: RequestSpec, paginates: boolean): ListPromise<T>;
|
|
71
|
+
request<T>(spec: RequestSpec): Promise<T>;
|
|
72
|
+
}
|
package/dist/cjs/core.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HttpClient = exports.DEFAULT_BASE_URL = void 0;
|
|
4
|
+
exports.backoffMs = backoffMs;
|
|
5
|
+
exports.retryAfterMs = retryAfterMs;
|
|
6
|
+
const crypto_js_1 = require("./crypto.js");
|
|
7
|
+
const errors_js_1 = require("./errors.js");
|
|
8
|
+
const meta_js_1 = require("./generated/meta.js");
|
|
9
|
+
const list_js_1 = require("./list.js");
|
|
10
|
+
const query_js_1 = require("./query.js");
|
|
11
|
+
/** Tiempo's API, where the SDK points unless `baseUrl` says otherwise. */
|
|
12
|
+
exports.DEFAULT_BASE_URL = 'https://api.tiempo.one';
|
|
13
|
+
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
14
|
+
const MAX_RETRY_AFTER_SECONDS = 60;
|
|
15
|
+
/** Backoff before retry `attempt` (0-based): 0.5s × 2^n, capped at 8s, jittered to 50–100%. */
|
|
16
|
+
function backoffMs(attempt, random = Math.random) {
|
|
17
|
+
const base = Math.min(8000, 500 * 2 ** attempt);
|
|
18
|
+
return Math.round(base * (0.5 + random() * 0.5));
|
|
19
|
+
}
|
|
20
|
+
/** Parses `Retry-After` (seconds or an HTTP date) into ms; undefined if absent or unparseable. */
|
|
21
|
+
function retryAfterMs(header, now = Date.now()) {
|
|
22
|
+
if (!header)
|
|
23
|
+
return undefined;
|
|
24
|
+
const trimmed = header.trim();
|
|
25
|
+
let seconds;
|
|
26
|
+
if (/^\d+(\.\d+)?$/.test(trimmed)) {
|
|
27
|
+
seconds = Number(trimmed);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const at = Date.parse(trimmed);
|
|
31
|
+
if (Number.isNaN(at))
|
|
32
|
+
return undefined;
|
|
33
|
+
seconds = Math.max(0, (at - now) / 1000);
|
|
34
|
+
}
|
|
35
|
+
return Math.round(Math.min(seconds, MAX_RETRY_AFTER_SECONDS) * 1000);
|
|
36
|
+
}
|
|
37
|
+
function headersToRecord(headers) {
|
|
38
|
+
const out = {};
|
|
39
|
+
headers.forEach((value, key) => {
|
|
40
|
+
out[key.toLowerCase()] = value;
|
|
41
|
+
});
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
function sleep(ms, signal) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
if (signal?.aborted)
|
|
47
|
+
return reject(signal.reason);
|
|
48
|
+
const timer = setTimeout(() => {
|
|
49
|
+
signal?.removeEventListener('abort', onAbort);
|
|
50
|
+
resolve();
|
|
51
|
+
}, ms);
|
|
52
|
+
const onAbort = () => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
reject(signal?.reason);
|
|
55
|
+
};
|
|
56
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const isBrowser = typeof globalThis.window !== 'undefined';
|
|
60
|
+
class HttpClient {
|
|
61
|
+
config;
|
|
62
|
+
constructor(config) {
|
|
63
|
+
this.config = config;
|
|
64
|
+
}
|
|
65
|
+
static resolve(apiKey, config) {
|
|
66
|
+
if (typeof apiKey !== 'string' || apiKey.trim() === '') {
|
|
67
|
+
throw new TypeError('Tiempo: an API key is required, e.g. new Tiempo(process.env.TIEMPO_API_KEY)');
|
|
68
|
+
}
|
|
69
|
+
const baseUrl = config?.baseUrl ?? exports.DEFAULT_BASE_URL;
|
|
70
|
+
if (typeof baseUrl !== 'string' || !/^https?:\/\//.test(baseUrl)) {
|
|
71
|
+
throw new TypeError("Tiempo: `baseUrl` must be an http(s) URL, e.g. new Tiempo(key, { baseUrl: 'http://localhost:8080' })");
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
apiKey,
|
|
75
|
+
baseUrl: baseUrl.replace(/\/+$/, '').replace(/\/v1$/, ''),
|
|
76
|
+
apiVersion: config?.apiVersion ?? meta_js_1.API_VERSION,
|
|
77
|
+
account: config?.account,
|
|
78
|
+
maxRetries: config?.maxRetries ?? 2,
|
|
79
|
+
timeout: config?.timeout ?? 30_000,
|
|
80
|
+
fetch: config?.fetch,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
list(spec, paginates) {
|
|
84
|
+
return new list_js_1.ListPromise((params) => this.request({ ...spec, query: params }), { ...spec.query }, paginates);
|
|
85
|
+
}
|
|
86
|
+
async request(spec) {
|
|
87
|
+
const opts = spec.options ?? {};
|
|
88
|
+
const cfg = this.config;
|
|
89
|
+
const doFetch = cfg.fetch ?? globalThis.fetch;
|
|
90
|
+
if (typeof doFetch !== 'function') {
|
|
91
|
+
throw new TypeError('Tiempo: no global fetch; pass one with { fetch } (Node 18+ has it built in)');
|
|
92
|
+
}
|
|
93
|
+
const maxRetries = Math.max(0, opts.maxRetries ?? cfg.maxRetries);
|
|
94
|
+
const timeout = opts.timeout ?? cfg.timeout;
|
|
95
|
+
const account = opts.account ?? cfg.account;
|
|
96
|
+
const url = cfg.baseUrl + spec.path + (0, query_js_1.encodeQuery)(spec.query);
|
|
97
|
+
const headers = {
|
|
98
|
+
Accept: 'application/json',
|
|
99
|
+
Authorization: `Bearer ${cfg.apiKey}`,
|
|
100
|
+
'Tiempo-Version': cfg.apiVersion,
|
|
101
|
+
};
|
|
102
|
+
if (!isBrowser)
|
|
103
|
+
headers['User-Agent'] = `tiempo-typescript/${meta_js_1.SDK_VERSION}`;
|
|
104
|
+
if (account)
|
|
105
|
+
headers['Tiempo-Account'] = account;
|
|
106
|
+
let body;
|
|
107
|
+
if (spec.body !== undefined) {
|
|
108
|
+
headers['Content-Type'] = 'application/json';
|
|
109
|
+
body = JSON.stringify(spec.body);
|
|
110
|
+
}
|
|
111
|
+
if (spec.method === 'POST' && !spec.readOnly) {
|
|
112
|
+
headers['Idempotency-Key'] = opts.idempotencyKey ?? (await (0, crypto_js_1.webCrypto)()).randomUUID();
|
|
113
|
+
}
|
|
114
|
+
for (let attempt = 0;; attempt++) {
|
|
115
|
+
if (opts.signal?.aborted)
|
|
116
|
+
throw opts.signal.reason;
|
|
117
|
+
const canRetry = attempt < maxRetries;
|
|
118
|
+
let response;
|
|
119
|
+
let text;
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
let timedOut = false;
|
|
122
|
+
const timer = setTimeout(() => {
|
|
123
|
+
timedOut = true;
|
|
124
|
+
controller.abort();
|
|
125
|
+
}, timeout);
|
|
126
|
+
const onAbort = () => controller.abort(opts.signal?.reason);
|
|
127
|
+
opts.signal?.addEventListener('abort', onAbort, { once: true });
|
|
128
|
+
try {
|
|
129
|
+
const init = { method: spec.method, headers, signal: controller.signal };
|
|
130
|
+
if (body !== undefined)
|
|
131
|
+
init.body = body;
|
|
132
|
+
response = await doFetch(url, init);
|
|
133
|
+
text = await response.text();
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
if (opts.signal?.aborted)
|
|
137
|
+
throw opts.signal.reason;
|
|
138
|
+
if (canRetry) {
|
|
139
|
+
await sleep(backoffMs(attempt), opts.signal);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
throw new errors_js_1.TiempoConnectionError({
|
|
143
|
+
message: timedOut
|
|
144
|
+
? `Request to Tiempo timed out after ${timeout}ms (${spec.method} ${spec.path})`
|
|
145
|
+
: `Could not connect to Tiempo (${spec.method} ${spec.path}): ${err?.message ?? err}`,
|
|
146
|
+
cause: err,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
opts.signal?.removeEventListener('abort', onAbort);
|
|
152
|
+
}
|
|
153
|
+
const responseHeaders = headersToRecord(response.headers);
|
|
154
|
+
let parsed = text;
|
|
155
|
+
if (text.length > 0) {
|
|
156
|
+
try {
|
|
157
|
+
parsed = JSON.parse(text);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// keep text
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (response.ok) {
|
|
164
|
+
if (typeof parsed === 'string' && text.length > 0) {
|
|
165
|
+
// A 2xx that wasn't JSON: don't hand back a string typed as an object.
|
|
166
|
+
throw new errors_js_1.TiempoAPIError({
|
|
167
|
+
message: `Expected a JSON response from Tiempo, got: ${text.slice(0, 200)}`,
|
|
168
|
+
statusCode: response.status,
|
|
169
|
+
headers: responseHeaders,
|
|
170
|
+
raw: text,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return (text.length > 0 ? parsed : undefined);
|
|
174
|
+
}
|
|
175
|
+
const error = (0, errors_js_1.errorFromResponse)(response.status, responseHeaders, parsed);
|
|
176
|
+
const retryable = RETRYABLE_STATUS.has(response.status) ||
|
|
177
|
+
(response.status === 409 && error.code === 'idempotency_key_in_use');
|
|
178
|
+
if (retryable && canRetry) {
|
|
179
|
+
const delay = retryAfterMs(response.headers.get('retry-after')) ?? backoffMs(attempt);
|
|
180
|
+
await sleep(delay, opts.signal);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
exports.HttpClient = HttpClient;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.webCrypto = webCrypto;
|
|
4
|
+
let cached;
|
|
5
|
+
/**
|
|
6
|
+
* Web Crypto: the global `crypto` (Node 19+, browsers, edge runtimes), else
|
|
7
|
+
* `node:crypto`'s `webcrypto` (Node 18).
|
|
8
|
+
*/
|
|
9
|
+
async function webCrypto() {
|
|
10
|
+
if (cached)
|
|
11
|
+
return cached;
|
|
12
|
+
const g = globalThis.crypto;
|
|
13
|
+
if (g?.subtle && typeof g.randomUUID === 'function') {
|
|
14
|
+
cached = g;
|
|
15
|
+
return g;
|
|
16
|
+
}
|
|
17
|
+
const specifier = 'node:crypto';
|
|
18
|
+
try {
|
|
19
|
+
const mod = (await Promise.resolve(`${specifier}`).then(s => require(s)));
|
|
20
|
+
if (mod.webcrypto?.subtle) {
|
|
21
|
+
cached = mod.webcrypto;
|
|
22
|
+
return cached;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// fall through
|
|
27
|
+
}
|
|
28
|
+
throw new Error('Tiempo: Web Crypto is not available in this runtime (need Node 18+ or a runtime with globalThis.crypto).');
|
|
29
|
+
}
|