@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.
Files changed (93) hide show
  1. package/README.md +364 -0
  2. package/dist/base64.d.ts +3 -0
  3. package/dist/base64.d.ts.map +1 -0
  4. package/dist/base64.js +17 -0
  5. package/dist/base64.js.map +1 -0
  6. package/dist/broadcasts.d.ts +60 -0
  7. package/dist/broadcasts.d.ts.map +1 -0
  8. package/dist/broadcasts.js +98 -0
  9. package/dist/broadcasts.js.map +1 -0
  10. package/dist/client.d.ts +51 -0
  11. package/dist/client.d.ts.map +1 -0
  12. package/dist/client.js +69 -0
  13. package/dist/client.js.map +1 -0
  14. package/dist/domains.d.ts +34 -0
  15. package/dist/domains.d.ts.map +1 -0
  16. package/dist/domains.js +70 -0
  17. package/dist/domains.js.map +1 -0
  18. package/dist/email.d.ts +40 -0
  19. package/dist/email.d.ts.map +1 -0
  20. package/dist/email.js +68 -0
  21. package/dist/email.js.map +1 -0
  22. package/dist/error.d.ts +67 -0
  23. package/dist/error.d.ts.map +1 -0
  24. package/dist/error.js +173 -0
  25. package/dist/error.js.map +1 -0
  26. package/dist/index.d.ts +17 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +5 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/messages.d.ts +37 -0
  31. package/dist/messages.d.ts.map +1 -0
  32. package/dist/messages.js +68 -0
  33. package/dist/messages.js.map +1 -0
  34. package/dist/pagination.d.ts +3 -0
  35. package/dist/pagination.d.ts.map +1 -0
  36. package/dist/pagination.js +14 -0
  37. package/dist/pagination.js.map +1 -0
  38. package/dist/retry.d.ts +6 -0
  39. package/dist/retry.d.ts.map +1 -0
  40. package/dist/retry.js +14 -0
  41. package/dist/retry.js.map +1 -0
  42. package/dist/streams.d.ts +45 -0
  43. package/dist/streams.d.ts.map +1 -0
  44. package/dist/streams.js +82 -0
  45. package/dist/streams.js.map +1 -0
  46. package/dist/suppressions.d.ts +37 -0
  47. package/dist/suppressions.d.ts.map +1 -0
  48. package/dist/suppressions.js +56 -0
  49. package/dist/suppressions.js.map +1 -0
  50. package/dist/tokens.d.ts +28 -0
  51. package/dist/tokens.d.ts.map +1 -0
  52. package/dist/tokens.js +57 -0
  53. package/dist/tokens.js.map +1 -0
  54. package/dist/transport.d.ts +34 -0
  55. package/dist/transport.d.ts.map +1 -0
  56. package/dist/transport.js +146 -0
  57. package/dist/transport.js.map +1 -0
  58. package/dist/types.d.ts +802 -0
  59. package/dist/types.d.ts.map +1 -0
  60. package/dist/types.js +2 -0
  61. package/dist/types.js.map +1 -0
  62. package/dist/version.d.ts +3 -0
  63. package/dist/version.d.ts.map +1 -0
  64. package/dist/version.js +3 -0
  65. package/dist/version.js.map +1 -0
  66. package/dist/webhook-verify.d.ts +33 -0
  67. package/dist/webhook-verify.d.ts.map +1 -0
  68. package/dist/webhook-verify.js +115 -0
  69. package/dist/webhook-verify.js.map +1 -0
  70. package/dist/webhooks.d.ts +63 -0
  71. package/dist/webhooks.d.ts.map +1 -0
  72. package/dist/webhooks.js +118 -0
  73. package/dist/webhooks.js.map +1 -0
  74. package/package.json +49 -0
  75. package/skills/sendora/SKILL.md +86 -0
  76. package/src/base64.ts +17 -0
  77. package/src/broadcasts.ts +115 -0
  78. package/src/client.ts +88 -0
  79. package/src/domains.ts +84 -0
  80. package/src/email.ts +83 -0
  81. package/src/error.ts +252 -0
  82. package/src/index.ts +16 -0
  83. package/src/messages.ts +88 -0
  84. package/src/pagination.ts +18 -0
  85. package/src/retry.ts +20 -0
  86. package/src/streams.ts +100 -0
  87. package/src/suppressions.ts +73 -0
  88. package/src/tokens.ts +70 -0
  89. package/src/transport.ts +179 -0
  90. package/src/types.ts +848 -0
  91. package/src/version.ts +2 -0
  92. package/src/webhook-verify.ts +147 -0
  93. package/src/webhooks.ts +157 -0
@@ -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
+ }
@@ -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
+ }