@sendora/sdk 1.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/README.md +364 -0
- package/dist/base64.d.ts +3 -0
- package/dist/base64.d.ts.map +1 -0
- package/dist/base64.js +17 -0
- package/dist/base64.js.map +1 -0
- package/dist/broadcasts.d.ts +60 -0
- package/dist/broadcasts.d.ts.map +1 -0
- package/dist/broadcasts.js +98 -0
- package/dist/broadcasts.js.map +1 -0
- package/dist/client.d.ts +51 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +69 -0
- package/dist/client.js.map +1 -0
- package/dist/domains.d.ts +34 -0
- package/dist/domains.d.ts.map +1 -0
- package/dist/domains.js +70 -0
- package/dist/domains.js.map +1 -0
- package/dist/email.d.ts +40 -0
- package/dist/email.d.ts.map +1 -0
- package/dist/email.js +68 -0
- package/dist/email.js.map +1 -0
- package/dist/error.d.ts +67 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/error.js +173 -0
- package/dist/error.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/messages.d.ts +37 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +68 -0
- package/dist/messages.js.map +1 -0
- package/dist/pagination.d.ts +3 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +14 -0
- package/dist/pagination.js.map +1 -0
- package/dist/retry.d.ts +6 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +14 -0
- package/dist/retry.js.map +1 -0
- package/dist/streams.d.ts +45 -0
- package/dist/streams.d.ts.map +1 -0
- package/dist/streams.js +82 -0
- package/dist/streams.js.map +1 -0
- package/dist/suppressions.d.ts +37 -0
- package/dist/suppressions.d.ts.map +1 -0
- package/dist/suppressions.js +56 -0
- package/dist/suppressions.js.map +1 -0
- package/dist/tokens.d.ts +28 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +57 -0
- package/dist/tokens.js.map +1 -0
- package/dist/transport.d.ts +34 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +146 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +802 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/dist/webhook-verify.d.ts +33 -0
- package/dist/webhook-verify.d.ts.map +1 -0
- package/dist/webhook-verify.js +115 -0
- package/dist/webhook-verify.js.map +1 -0
- package/dist/webhooks.d.ts +63 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +118 -0
- package/dist/webhooks.js.map +1 -0
- package/package.json +49 -0
- package/skills/sendora/SKILL.md +86 -0
- package/src/base64.ts +17 -0
- package/src/broadcasts.ts +115 -0
- package/src/client.ts +88 -0
- package/src/domains.ts +84 -0
- package/src/email.ts +83 -0
- package/src/error.ts +252 -0
- package/src/index.ts +16 -0
- package/src/messages.ts +88 -0
- package/src/pagination.ts +18 -0
- package/src/retry.ts +20 -0
- package/src/streams.ts +100 -0
- package/src/suppressions.ts +73 -0
- package/src/tokens.ts +70 -0
- package/src/transport.ts +179 -0
- package/src/types.ts +848 -0
- package/src/version.ts +2 -0
- package/src/webhook-verify.ts +147 -0
- package/src/webhooks.ts +157 -0
package/src/messages.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { paginate } from './pagination.ts';
|
|
2
|
+
import type { Transport } from './transport.ts';
|
|
3
|
+
import type {
|
|
4
|
+
Message,
|
|
5
|
+
MessageDetail,
|
|
6
|
+
MessagePage,
|
|
7
|
+
MessageSearch,
|
|
8
|
+
MessageSearchBody,
|
|
9
|
+
RequestOptions,
|
|
10
|
+
} from './types.ts';
|
|
11
|
+
|
|
12
|
+
/** The message log of the token's server and each message's timeline. */
|
|
13
|
+
export class MessagesResource {
|
|
14
|
+
readonly #transport: Transport;
|
|
15
|
+
|
|
16
|
+
constructor(transport: Transport) {
|
|
17
|
+
this.#transport = transport;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One message as the log holds it: its recipients with their state, its
|
|
22
|
+
* attachments described, and every event on its timeline. Bodies are
|
|
23
|
+
* never returned.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* const message = await sendora.messages.get(messageId);
|
|
27
|
+
* const delivered = message.recipients.every((recipient) => recipient.status === 'delivered');
|
|
28
|
+
*/
|
|
29
|
+
get(messageId: string, options: RequestOptions = {}): Promise<MessageDetail> {
|
|
30
|
+
return this.#transport.request<MessageDetail>({
|
|
31
|
+
method: 'GET',
|
|
32
|
+
path: `/v1/messages/${encodeURIComponent(messageId)}`,
|
|
33
|
+
idempotent: true,
|
|
34
|
+
signal: options.signal,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One page of the log, newest first. Every filter is optional; pass the
|
|
40
|
+
* page's `next` as `after` for the following page, or use `searchAll`.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* const page = await sendora.messages.search({ tag: 'invoice', status: 'bounced', limit: 20 });
|
|
44
|
+
*/
|
|
45
|
+
search(search: MessageSearch = {}, options: RequestOptions = {}): Promise<MessagePage> {
|
|
46
|
+
return this.#transport.request<MessagePage>({
|
|
47
|
+
method: 'POST',
|
|
48
|
+
path: '/v1/messages/search',
|
|
49
|
+
body: encodeSearch(search),
|
|
50
|
+
idempotent: true,
|
|
51
|
+
signal: options.signal,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every message the filters match, page by page, for `for await`.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* for await (const message of sendora.messages.searchAll({ recipient: 'anna@example.com' })) {
|
|
60
|
+
* console.log(message.messageId, message.subject);
|
|
61
|
+
* }
|
|
62
|
+
*/
|
|
63
|
+
searchAll(search: MessageSearch = {}, options: RequestOptions = {}): AsyncIterable<Message> {
|
|
64
|
+
return paginate(
|
|
65
|
+
(after) => this.search({ ...search, after }, options),
|
|
66
|
+
(page) => page.messages,
|
|
67
|
+
(page) => page.next,
|
|
68
|
+
search.after,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The body as the API takes it: times as ISO 8601 strings. */
|
|
74
|
+
export function encodeSearch(search: MessageSearch): MessageSearchBody {
|
|
75
|
+
const { from, to, ...fields } = search;
|
|
76
|
+
const body: MessageSearchBody = { ...fields };
|
|
77
|
+
if (from !== undefined) {
|
|
78
|
+
body.from = isoOf(from);
|
|
79
|
+
}
|
|
80
|
+
if (to !== undefined) {
|
|
81
|
+
body.to = isoOf(to);
|
|
82
|
+
}
|
|
83
|
+
return body;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isoOf(value: Date | string): string {
|
|
87
|
+
return typeof value === 'string' ? value : value.toISOString();
|
|
88
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Walks a cursor-paged list: fetches a page, yields its items, follows `next` until it is null. */
|
|
2
|
+
export async function* paginate<Page, Item>(
|
|
3
|
+
fetchPage: (after: string | undefined) => Promise<Page>,
|
|
4
|
+
itemsOf: (page: Page) => Item[],
|
|
5
|
+
nextOf: (page: Page) => string | null,
|
|
6
|
+
start: string | undefined,
|
|
7
|
+
): AsyncGenerator<Item, void, undefined> {
|
|
8
|
+
let after = start;
|
|
9
|
+
for (;;) {
|
|
10
|
+
const page = await fetchPage(after);
|
|
11
|
+
yield* itemsOf(page);
|
|
12
|
+
const next = nextOf(page);
|
|
13
|
+
if (next === null) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
after = next;
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/retry.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const DEFAULT_MAX_RETRIES = 2;
|
|
2
|
+
|
|
3
|
+
/** The most a call waits across all its retries; a longer Retry-After is handed to the caller instead. */
|
|
4
|
+
export const MAX_TOTAL_WAIT_MS = 5000;
|
|
5
|
+
|
|
6
|
+
const BASE_DELAY_MS = 500;
|
|
7
|
+
const MAX_BACKOFF_MS = 2000;
|
|
8
|
+
|
|
9
|
+
/** Retry-After in full when the API gave one; otherwise 500 ms doubling to 2 s, jittered between half and one and a half. */
|
|
10
|
+
export function retryDelayMs(
|
|
11
|
+
attempt: number,
|
|
12
|
+
retryAfterSeconds: number | null,
|
|
13
|
+
random: () => number,
|
|
14
|
+
): number {
|
|
15
|
+
if (retryAfterSeconds !== null) {
|
|
16
|
+
return retryAfterSeconds * 1000;
|
|
17
|
+
}
|
|
18
|
+
const backoff = Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
|
19
|
+
return Math.round(backoff * (0.5 + random()));
|
|
20
|
+
}
|
package/src/streams.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { Transport } from './transport.ts';
|
|
2
|
+
import type {
|
|
3
|
+
CreateStreamRequest,
|
|
4
|
+
RequestOptions,
|
|
5
|
+
Stream,
|
|
6
|
+
StreamList,
|
|
7
|
+
UpdateStreamRequest,
|
|
8
|
+
} from './types.ts';
|
|
9
|
+
|
|
10
|
+
/** The streams of the token's own server: the default transactional one, and any the account added. */
|
|
11
|
+
export class StreamsResource {
|
|
12
|
+
readonly #transport: Transport;
|
|
13
|
+
|
|
14
|
+
constructor(transport: Transport) {
|
|
15
|
+
this.#transport = transport;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Adds a stream to the server. A transactional stream is available to
|
|
20
|
+
* every account; a broadcast stream needs broadcast enabled for the
|
|
21
|
+
* account (`broadcast_not_enabled`); an inbound stream receives mail at
|
|
22
|
+
* its own address, one live per server (`inbound_stream_exists`), on an
|
|
23
|
+
* active account (`tenant_not_active`). A taken name answers
|
|
24
|
+
* `stream_exists` with `existingId`.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* const stream = await sendora.streams.create({ kind: 'transactional', name: 'Aviseringar' });
|
|
28
|
+
* await sendora.email.send({ ...message, streamId: stream.streamId });
|
|
29
|
+
*/
|
|
30
|
+
create(request: CreateStreamRequest, options: RequestOptions = {}): Promise<Stream> {
|
|
31
|
+
return this.#transport.request<Stream>({
|
|
32
|
+
method: 'POST',
|
|
33
|
+
path: '/v1/streams',
|
|
34
|
+
body: request,
|
|
35
|
+
idempotent: false,
|
|
36
|
+
signal: options.signal,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Every stream of the server, archived ones included, oldest first. */
|
|
41
|
+
list(options: RequestOptions = {}): Promise<StreamList> {
|
|
42
|
+
return this.#transport.request<StreamList>({
|
|
43
|
+
method: 'GET',
|
|
44
|
+
path: '/v1/streams',
|
|
45
|
+
idempotent: true,
|
|
46
|
+
signal: options.signal,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One stream by id. */
|
|
51
|
+
get(streamId: string, options: RequestOptions = {}): Promise<Stream> {
|
|
52
|
+
return this.#transport.request<Stream>({
|
|
53
|
+
method: 'GET',
|
|
54
|
+
path: `/v1/streams/${encodeURIComponent(streamId)}`,
|
|
55
|
+
idempotent: true,
|
|
56
|
+
signal: options.signal,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Renames a stream, or sets how long an inbound stream keeps received
|
|
62
|
+
* content; the id stays. A name another stream of the server carries
|
|
63
|
+
* answers `stream_exists`.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* await sendora.streams.update(streamId, { name: 'Driftinformation' });
|
|
67
|
+
* await sendora.streams.update(inboundStreamId, { contentRetentionDays: 7 });
|
|
68
|
+
*/
|
|
69
|
+
update(
|
|
70
|
+
streamId: string,
|
|
71
|
+
request: UpdateStreamRequest,
|
|
72
|
+
options: RequestOptions = {},
|
|
73
|
+
): Promise<Stream> {
|
|
74
|
+
return this.#transport.request<Stream>({
|
|
75
|
+
method: 'PATCH',
|
|
76
|
+
path: `/v1/streams/${encodeURIComponent(streamId)}`,
|
|
77
|
+
body: request,
|
|
78
|
+
idempotent: true,
|
|
79
|
+
signal: options.signal,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The stream takes no new messages from now on; what it already holds
|
|
85
|
+
* is still delivered. Archiving again changes nothing. The default
|
|
86
|
+
* stream cannot be archived (`default_stream`).
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* const archived = await sendora.streams.archive(streamId);
|
|
90
|
+
* console.log(archived.archivedAt);
|
|
91
|
+
*/
|
|
92
|
+
archive(streamId: string, options: RequestOptions = {}): Promise<Stream> {
|
|
93
|
+
return this.#transport.request<Stream>({
|
|
94
|
+
method: 'POST',
|
|
95
|
+
path: `/v1/streams/${encodeURIComponent(streamId)}/archive`,
|
|
96
|
+
idempotent: true,
|
|
97
|
+
signal: options.signal,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { paginate } from './pagination.ts';
|
|
2
|
+
import type { Transport } from './transport.ts';
|
|
3
|
+
import type {
|
|
4
|
+
DeleteSuppressionRequest,
|
|
5
|
+
RequestOptions,
|
|
6
|
+
Suppression,
|
|
7
|
+
SuppressionPage,
|
|
8
|
+
SuppressionQuery,
|
|
9
|
+
} from './types.ts';
|
|
10
|
+
|
|
11
|
+
/** The addresses the token's server no longer sends to, one list per stream. */
|
|
12
|
+
export class SuppressionsResource {
|
|
13
|
+
readonly #transport: Transport;
|
|
14
|
+
|
|
15
|
+
constructor(transport: Transport) {
|
|
16
|
+
this.#transport = transport;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One page of a stream's list, newest first: hard bounces, spam
|
|
21
|
+
* complaints and addresses added by hand. The default stream's list
|
|
22
|
+
* unless `streamId` names another. A send to any of them is refused
|
|
23
|
+
* with `recipient_suppressed`.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* const { suppressions, next } = await sendora.suppressions.list({ streamId, limit: 200 });
|
|
27
|
+
*/
|
|
28
|
+
list(query: SuppressionQuery = {}, options: RequestOptions = {}): Promise<SuppressionPage> {
|
|
29
|
+
return this.#transport.request<SuppressionPage>({
|
|
30
|
+
method: 'GET',
|
|
31
|
+
path: '/v1/suppressions',
|
|
32
|
+
query: { streamId: query.streamId, limit: query.limit, after: query.after },
|
|
33
|
+
idempotent: true,
|
|
34
|
+
signal: options.signal,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Every suppressed address, page by page, for `for await`.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* for await (const entry of sendora.suppressions.listAll()) {
|
|
43
|
+
* console.log(entry.address, entry.reason);
|
|
44
|
+
* }
|
|
45
|
+
*/
|
|
46
|
+
listAll(query: SuppressionQuery = {}, options: RequestOptions = {}): AsyncIterable<Suppression> {
|
|
47
|
+
return paginate(
|
|
48
|
+
(after) => this.list({ ...query, after }, options),
|
|
49
|
+
(page) => page.suppressions,
|
|
50
|
+
(page) => page.next,
|
|
51
|
+
query.after,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Lifts a hard bounce or a manual entry from a stream's list so the
|
|
57
|
+
* server may send to the address again on that stream; the default
|
|
58
|
+
* stream's list unless `streamId` names another. A spam complaint cannot
|
|
59
|
+
* be lifted this way: `spam_complaint_locked`.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* await sendora.suppressions.delete({ address: 'anna@example.com' });
|
|
63
|
+
*/
|
|
64
|
+
delete(request: DeleteSuppressionRequest, options: RequestOptions = {}): Promise<void> {
|
|
65
|
+
return this.#transport.request<undefined>({
|
|
66
|
+
method: 'POST',
|
|
67
|
+
path: '/v1/suppressions/delete',
|
|
68
|
+
body: request,
|
|
69
|
+
idempotent: true,
|
|
70
|
+
signal: options.signal,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/tokens.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Transport } from './transport.ts';
|
|
2
|
+
import type {
|
|
3
|
+
CreatedToken,
|
|
4
|
+
CreateTokenRequest,
|
|
5
|
+
RequestOptions,
|
|
6
|
+
Token,
|
|
7
|
+
TokenList,
|
|
8
|
+
} from './types.ts';
|
|
9
|
+
|
|
10
|
+
/** The API tokens of the token's own server. Every token of a server has the same rights. */
|
|
11
|
+
export class TokensResource {
|
|
12
|
+
readonly #transport: Transport;
|
|
13
|
+
|
|
14
|
+
constructor(transport: Transport) {
|
|
15
|
+
this.#transport = transport;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Creates another live token and answers its value once; it is never
|
|
20
|
+
* shown again, so store it at once.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* const { token, tokenId } = await sendora.tokens.create({ name: 'Fakturasystemet' });
|
|
24
|
+
*/
|
|
25
|
+
create(request: CreateTokenRequest, options: RequestOptions = {}): Promise<CreatedToken> {
|
|
26
|
+
return this.#transport.request<CreatedToken>({
|
|
27
|
+
method: 'POST',
|
|
28
|
+
path: '/v1/tokens',
|
|
29
|
+
body: request,
|
|
30
|
+
idempotent: false,
|
|
31
|
+
signal: options.signal,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Every token of the server, revoked ones included. */
|
|
36
|
+
list(options: RequestOptions = {}): Promise<TokenList> {
|
|
37
|
+
return this.#transport.request<TokenList>({
|
|
38
|
+
method: 'GET',
|
|
39
|
+
path: '/v1/tokens',
|
|
40
|
+
idempotent: true,
|
|
41
|
+
signal: options.signal,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One token by id; its value is never part of the answer. */
|
|
46
|
+
get(tokenId: string, options: RequestOptions = {}): Promise<Token> {
|
|
47
|
+
return this.#transport.request<Token>({
|
|
48
|
+
method: 'GET',
|
|
49
|
+
path: `/v1/tokens/${encodeURIComponent(tokenId)}`,
|
|
50
|
+
idempotent: true,
|
|
51
|
+
signal: options.signal,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The token stops working at once. The last live token of a server
|
|
57
|
+
* cannot be revoked (`last_token`), so a server is never locked out.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* await sendora.tokens.revoke(tokenId);
|
|
61
|
+
*/
|
|
62
|
+
revoke(tokenId: string, options: RequestOptions = {}): Promise<void> {
|
|
63
|
+
return this.#transport.request<undefined>({
|
|
64
|
+
method: 'DELETE',
|
|
65
|
+
path: `/v1/tokens/${encodeURIComponent(tokenId)}`,
|
|
66
|
+
idempotent: true,
|
|
67
|
+
signal: options.signal,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { errorFromAnswer, SendoraError } from './error.ts';
|
|
2
|
+
import { MAX_TOTAL_WAIT_MS, retryDelayMs } from './retry.ts';
|
|
3
|
+
|
|
4
|
+
export type Method = 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
|
5
|
+
|
|
6
|
+
export interface RequestSpec {
|
|
7
|
+
method: Method;
|
|
8
|
+
path: string;
|
|
9
|
+
query?: Record<string, string | number | undefined> | undefined;
|
|
10
|
+
body?: unknown;
|
|
11
|
+
headers?: Record<string, string> | undefined;
|
|
12
|
+
/** Safe to send again after a lost connection: reads, deletes and sends under an idempotency key. */
|
|
13
|
+
idempotent: boolean;
|
|
14
|
+
signal?: AbortSignal | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TransportOptions {
|
|
18
|
+
baseUrl: string;
|
|
19
|
+
token: string;
|
|
20
|
+
fetch: typeof fetch;
|
|
21
|
+
timeoutMs: number;
|
|
22
|
+
maxRetries: number;
|
|
23
|
+
userAgent: string;
|
|
24
|
+
/** Waits between attempts; tests replace it. */
|
|
25
|
+
sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
26
|
+
/** The jitter source in [0, 1); tests pin it. */
|
|
27
|
+
random?: (() => number) | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Attempt<T> = { ok: true; value: T } | { ok: false; error: SendoraError };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One request path for every resource: builds the request, sends it,
|
|
34
|
+
* turns the answer into a value or a SendoraError, and repeats it within
|
|
35
|
+
* the retry budget when repeating could help and cannot cause harm.
|
|
36
|
+
*/
|
|
37
|
+
export class Transport {
|
|
38
|
+
readonly #options: TransportOptions;
|
|
39
|
+
readonly #sleep: (ms: number) => Promise<void>;
|
|
40
|
+
readonly #random: () => number;
|
|
41
|
+
|
|
42
|
+
constructor(options: TransportOptions) {
|
|
43
|
+
this.#options = options;
|
|
44
|
+
this.#sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
45
|
+
this.#random = options.random ?? Math.random;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async request<T>(spec: RequestSpec): Promise<T> {
|
|
49
|
+
const url = this.#url(spec);
|
|
50
|
+
const body = spec.body === undefined ? undefined : JSON.stringify(spec.body);
|
|
51
|
+
let waitedMs = 0;
|
|
52
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
53
|
+
const outcome = await this.#attempt<T>(spec, url, body);
|
|
54
|
+
if (outcome.ok) {
|
|
55
|
+
return outcome.value;
|
|
56
|
+
}
|
|
57
|
+
const { error } = outcome;
|
|
58
|
+
const mayRepeat =
|
|
59
|
+
spec.idempotent || error.code === 'rate_limited' || error.code === 'sending_disabled';
|
|
60
|
+
if (attempt >= this.#options.maxRetries || !error.retryable || !mayRepeat) {
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
const delay = retryDelayMs(attempt, error.retryAfter, this.#random);
|
|
64
|
+
if (waitedMs + delay > MAX_TOTAL_WAIT_MS) {
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
waitedMs += delay;
|
|
68
|
+
await this.#sleep(delay);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async #attempt<T>(spec: RequestSpec, url: string, body: string | undefined): Promise<Attempt<T>> {
|
|
73
|
+
const timeout = AbortSignal.timeout(this.#options.timeoutMs);
|
|
74
|
+
const signal = spec.signal === undefined ? timeout : AbortSignal.any([spec.signal, timeout]);
|
|
75
|
+
const init: RequestInit = {
|
|
76
|
+
method: spec.method,
|
|
77
|
+
headers: this.#headers(spec, body),
|
|
78
|
+
signal,
|
|
79
|
+
redirect: 'manual',
|
|
80
|
+
};
|
|
81
|
+
if (body !== undefined) {
|
|
82
|
+
init.body = body;
|
|
83
|
+
}
|
|
84
|
+
let response: Response;
|
|
85
|
+
try {
|
|
86
|
+
response = await this.#options.fetch(url, init);
|
|
87
|
+
} catch (cause) {
|
|
88
|
+
if (spec.signal?.aborted) {
|
|
89
|
+
throw cause;
|
|
90
|
+
}
|
|
91
|
+
if (timeout.aborted) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
error: new SendoraError({
|
|
95
|
+
code: 'timeout',
|
|
96
|
+
status: null,
|
|
97
|
+
message: `The API did not answer within ${String(this.#options.timeoutMs)} ms.`,
|
|
98
|
+
cause,
|
|
99
|
+
}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
error: new SendoraError({
|
|
105
|
+
code: 'connection_failed',
|
|
106
|
+
status: null,
|
|
107
|
+
message: 'The connection to the API failed.',
|
|
108
|
+
cause,
|
|
109
|
+
}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (response.status === 204) {
|
|
113
|
+
await response.body?.cancel();
|
|
114
|
+
return { ok: true, value: undefined as T };
|
|
115
|
+
}
|
|
116
|
+
const json = parseJson(await response.text());
|
|
117
|
+
if (response.status === 0 || (response.status >= 300 && response.status < 400)) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
error: new SendoraError({
|
|
121
|
+
code: 'unexpected_response',
|
|
122
|
+
status: response.status,
|
|
123
|
+
message: `The API answered a redirect; check baseUrl (${this.#options.baseUrl}).`,
|
|
124
|
+
}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (response.ok) {
|
|
128
|
+
if (json === undefined) {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
error: new SendoraError({
|
|
132
|
+
code: 'unexpected_response',
|
|
133
|
+
status: response.status,
|
|
134
|
+
message: `The API answered ${String(response.status)} without a JSON body.`,
|
|
135
|
+
}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return { ok: true, value: json as T };
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
error: errorFromAnswer(response.status, json, response.headers.get('retry-after')),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#headers(spec: RequestSpec, body: string | undefined): Record<string, string> {
|
|
147
|
+
const headers: Record<string, string> = {
|
|
148
|
+
authorization: `Bearer ${this.#options.token}`,
|
|
149
|
+
accept: 'application/json',
|
|
150
|
+
'user-agent': this.#options.userAgent,
|
|
151
|
+
...spec.headers,
|
|
152
|
+
};
|
|
153
|
+
if (body !== undefined) {
|
|
154
|
+
headers['content-type'] = 'application/json';
|
|
155
|
+
}
|
|
156
|
+
return headers;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#url(spec: RequestSpec): string {
|
|
160
|
+
const url = new URL(`${this.#options.baseUrl.replace(/\/+$/, '')}${spec.path}`);
|
|
161
|
+
for (const [name, value] of Object.entries(spec.query ?? {})) {
|
|
162
|
+
if (value !== undefined) {
|
|
163
|
+
url.searchParams.set(name, String(value));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return url.toString();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parseJson(text: string): unknown {
|
|
171
|
+
if (text === '') {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
return JSON.parse(text) as unknown;
|
|
176
|
+
} catch {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
}
|