@the-smithy/hearth 0.0.0-stage → 0.13.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/LICENSE +6 -0
- package/README.md +47 -2
- package/dist/chunk-4VLAG4JX.js +39 -0
- package/dist/chunk-ZK7X4FDQ.js +284 -0
- package/dist/client-DCiaDdvu.d.ts +338 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +32 -0
- package/dist/next.d.ts +235 -0
- package/dist/next.js +225 -0
- package/dist/preview.d.ts +36 -0
- package/dist/preview.js +88 -0
- package/dist/react.d.ts +45 -0
- package/dist/react.js +42 -0
- package/dist/testing.d.ts +65 -0
- package/dist/testing.js +86 -0
- package/dist/webhooks.d.ts +14 -0
- package/dist/webhooks.js +10 -0
- package/llms.md +79 -0
- package/package.json +74 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
Copyright (c) The Smithy. All rights reserved.
|
|
2
|
+
|
|
3
|
+
You may use this package to build software that works with The Smithy's services (Hearth and Ember), with an
|
|
4
|
+
account on the service it connects to. It's provided as is, without warranty of any kind.
|
|
5
|
+
|
|
6
|
+
Questions: hello@thesmithy.io
|
package/README.md
CHANGED
|
@@ -1,3 +1,48 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @the-smithy/hearth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The typed client for [Hearth](https://hearth.thesmithy.io), The Smithy's headless CMS: a site's frontend
|
|
4
|
+
uses it to read its pages, events and settings, render SEO tags, submit forms and show live previews.
|
|
5
|
+
|
|
6
|
+
**It needs API access to Hearth.** Every request is made with a Hearth site's API key, so the package does
|
|
7
|
+
nothing without a Hearth account. Developer access is by invitation: request it at
|
|
8
|
+
[hearth.thesmithy.io/developers](https://hearth.thesmithy.io/developers).
|
|
9
|
+
|
|
10
|
+
## Getting started
|
|
11
|
+
|
|
12
|
+
Most sites start from the starter template, which has this package set up already:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
pnpm create @the-smithy/hearth-site my-site --site <your-site>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
To add it to an existing project instead:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pnpm add @the-smithy/hearth
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```dotenv
|
|
25
|
+
# .env.local
|
|
26
|
+
HEARTH_SITE=<your-site>
|
|
27
|
+
HEARTH_API_KEY=<a key from the site's Settings → Developers in Hearth>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// lib/hearth.ts (server only: the key is secret)
|
|
32
|
+
import { createHearthClient } from "@the-smithy/hearth";
|
|
33
|
+
|
|
34
|
+
export const hearth = createHearthClient({
|
|
35
|
+
site: process.env.HEARTH_SITE!,
|
|
36
|
+
apiKey: process.env.HEARTH_API_KEY!,
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Documentation
|
|
41
|
+
|
|
42
|
+
Once you have developer access, the full documentation (every method, Next.js helpers, rendering blocks,
|
|
43
|
+
previews, forms and webhooks) is in your Hearth account under **API docs**, with a Markdown version to give
|
|
44
|
+
an AI coding assistant.
|
|
45
|
+
|
|
46
|
+
## License
|
|
47
|
+
|
|
48
|
+
For use with Hearth. See [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// src/webhooks.ts
|
|
2
|
+
var encoder = new TextEncoder();
|
|
3
|
+
function hexToBytes(hex) {
|
|
4
|
+
if (!/^[0-9a-f]+$/i.test(hex) || hex.length % 2) return null;
|
|
5
|
+
const out = new Uint8Array(hex.length / 2);
|
|
6
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
7
|
+
return out;
|
|
8
|
+
}
|
|
9
|
+
async function verifyWebhook(rawBody, signatureHeader, secret) {
|
|
10
|
+
if (!secret || !signatureHeader?.startsWith("sha256=")) return false;
|
|
11
|
+
const given = hexToBytes(signatureHeader.slice(7));
|
|
12
|
+
if (!given) return false;
|
|
13
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
|
|
14
|
+
return crypto.subtle.verify("HMAC", key, given, encoder.encode(rawBody));
|
|
15
|
+
}
|
|
16
|
+
async function parseWebhook(request, secret) {
|
|
17
|
+
const raw = await request.text();
|
|
18
|
+
if (!await verifyWebhook(raw, request.headers.get("x-hearth-signature"), secret)) return null;
|
|
19
|
+
try {
|
|
20
|
+
const payload = JSON.parse(raw);
|
|
21
|
+
return typeof payload?.site === "string" && typeof payload?.kind === "string" ? payload : null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function tagsForWebhook(payload, base = "hearth") {
|
|
27
|
+
const tags = [base];
|
|
28
|
+
if (payload.kind === "page" && payload.slug) tags.push(`${base}:page:${payload.slug}`);
|
|
29
|
+
if (payload.kind === "event" || payload.kind === "scheduled") tags.push(`${base}:events`);
|
|
30
|
+
if (payload.kind === "settings") tags.push(`${base}:site`);
|
|
31
|
+
if (payload.kind === "giving") tags.push(`${base}:giving`);
|
|
32
|
+
return tags;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
verifyWebhook,
|
|
37
|
+
parseWebhook,
|
|
38
|
+
tagsForWebhook
|
|
39
|
+
};
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var HearthError = class extends Error {
|
|
3
|
+
status;
|
|
4
|
+
code;
|
|
5
|
+
details;
|
|
6
|
+
constructor(code, message, { status = 0, details, cause } = {}) {
|
|
7
|
+
super(message, { cause });
|
|
8
|
+
this.name = "HearthError";
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.details = details;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
var isHearthError = (error) => error instanceof HearthError;
|
|
15
|
+
var codeForStatus = (status) => {
|
|
16
|
+
if (status === 401) return "unauthorized";
|
|
17
|
+
if (status === 403) return "feature_disabled";
|
|
18
|
+
if (status === 404) return "not_found";
|
|
19
|
+
if (status === 400 || status === 422) return "bad_request";
|
|
20
|
+
if (status === 429) return "rate_limited";
|
|
21
|
+
return "server_error";
|
|
22
|
+
};
|
|
23
|
+
async function errorFromResponse(res) {
|
|
24
|
+
let body;
|
|
25
|
+
try {
|
|
26
|
+
body = await res.json();
|
|
27
|
+
} catch {
|
|
28
|
+
}
|
|
29
|
+
const known = ["unauthorized", "not_found", "bad_request", "feature_disabled", "rate_limited", "server_error"];
|
|
30
|
+
const code = known.includes(body?.error?.code) ? body.error.code : codeForStatus(res.status);
|
|
31
|
+
return new HearthError(code, body?.error?.message ?? `Hearth responded with ${res.status}`, { status: res.status, details: body?.error?.details });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// src/client.ts
|
|
35
|
+
var DEFAULT_BASE_URL = "https://hearth.thesmithy.io";
|
|
36
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
37
|
+
function normalizeSlug(slug) {
|
|
38
|
+
const joined = Array.isArray(slug) ? slug.join("/") : slug ?? "";
|
|
39
|
+
const clean = joined.split("/").map((s) => s.trim()).filter(Boolean).join("/").toLowerCase();
|
|
40
|
+
return clean || "home";
|
|
41
|
+
}
|
|
42
|
+
function timeoutSignal(ms, outer) {
|
|
43
|
+
const timeout = AbortSignal.timeout(ms);
|
|
44
|
+
if (!outer) return timeout;
|
|
45
|
+
if (typeof AbortSignal.any === "function") return AbortSignal.any([outer, timeout]);
|
|
46
|
+
return outer;
|
|
47
|
+
}
|
|
48
|
+
function toBody(values) {
|
|
49
|
+
if (typeof FormData !== "undefined" && values instanceof FormData) {
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const [k, v] of values.entries()) if (typeof v === "string") out[k] = v;
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
return values;
|
|
55
|
+
}
|
|
56
|
+
function createCore(options) {
|
|
57
|
+
if (!options.site) throw new Error("Hearth: `site` is required.");
|
|
58
|
+
if (!options.apiKey) throw new Error("Hearth: `apiKey` is required.");
|
|
59
|
+
if (typeof window !== "undefined" && typeof document !== "undefined" && !options.allowBrowser) {
|
|
60
|
+
throw new Error("Hearth: the API key is secret, so the client only runs on the server. Fetch content in server code, or pass allowBrowser: true if you understand the risk.");
|
|
61
|
+
}
|
|
62
|
+
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
63
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
64
|
+
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
65
|
+
const retries = options.retries ?? 2;
|
|
66
|
+
const hooks = options.hooks ?? {};
|
|
67
|
+
const sitePath = `/api/v1/sites/${encodeURIComponent(options.site)}`;
|
|
68
|
+
async function request(path, init = {}) {
|
|
69
|
+
const method = init.method ?? "GET";
|
|
70
|
+
const resource = init.resource ?? `${method} ${path}`;
|
|
71
|
+
const attempts = method === "GET" ? retries + 1 : 1;
|
|
72
|
+
let lastError;
|
|
73
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
74
|
+
const ctx = { resource, method, path, attempt };
|
|
75
|
+
let req = new Request(`${baseUrl}${sitePath}${path}`, {
|
|
76
|
+
method,
|
|
77
|
+
headers: {
|
|
78
|
+
Accept: "application/json",
|
|
79
|
+
Authorization: `Bearer ${options.apiKey}`,
|
|
80
|
+
...init.body !== void 0 ? { "Content-Type": "application/json" } : {},
|
|
81
|
+
...init.preview ? { "X-Hearth-Preview": init.preview } : {},
|
|
82
|
+
...init.headers
|
|
83
|
+
},
|
|
84
|
+
body: init.body !== void 0 ? JSON.stringify(init.body) : void 0
|
|
85
|
+
});
|
|
86
|
+
req = await hooks.beforeRequest?.(req, ctx) ?? req;
|
|
87
|
+
try {
|
|
88
|
+
let res = await doFetch(req, {
|
|
89
|
+
signal: timeoutSignal(timeoutMs, init.signal),
|
|
90
|
+
...init.preview ? { cache: "no-store" } : init.cache ? { cache: init.cache } : {},
|
|
91
|
+
...init.next && !init.preview ? { next: init.next } : {}
|
|
92
|
+
});
|
|
93
|
+
res = await hooks.afterResponse?.(res, ctx) ?? res;
|
|
94
|
+
if (res.ok) {
|
|
95
|
+
const json = await res.json().catch(() => {
|
|
96
|
+
throw new HearthError("invalid_response", "Hearth returned a response that isn't JSON.", { status: res.status });
|
|
97
|
+
});
|
|
98
|
+
return json.data;
|
|
99
|
+
}
|
|
100
|
+
const error = await errorFromResponse(res);
|
|
101
|
+
const retryable = res.status === 429 || res.status >= 500;
|
|
102
|
+
if (!retryable || attempt === attempts) throw error;
|
|
103
|
+
lastError = error;
|
|
104
|
+
const retryAfter = Number(res.headers.get("retry-after"));
|
|
105
|
+
await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter * 1e3, 1e4) : backoff(attempt));
|
|
106
|
+
} catch (err) {
|
|
107
|
+
const error = isHearthError(err) ? err : err?.name === "TimeoutError" ? new HearthError("timeout", `Hearth didn't respond within ${timeoutMs} ms.`, { cause: err }) : err?.name === "AbortError" ? new HearthError("network", "The request was cancelled.", { cause: err }) : new HearthError("network", "Couldn't reach Hearth.", { cause: err });
|
|
108
|
+
const retryable = error.code === "network" || error.code === "timeout" || error.code === "rate_limited" || error.code === "server_error";
|
|
109
|
+
if (init.signal?.aborted || !retryable || attempt === attempts) {
|
|
110
|
+
hooks.onError?.(error, ctx);
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
lastError = error;
|
|
114
|
+
await sleep(backoff(attempt));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
throw lastError ?? new HearthError("network", "Couldn't reach Hearth.");
|
|
118
|
+
}
|
|
119
|
+
async function maybe(path, init) {
|
|
120
|
+
try {
|
|
121
|
+
return await request(path, init);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (isHearthError(err) && err.code === "not_found") return null;
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function check(value, kind) {
|
|
128
|
+
if (!options.validate || !value) return value;
|
|
129
|
+
const { blockSchema, blockTypes } = await import("@the-smithy/hearth-schema");
|
|
130
|
+
const blocks = value.blocks ?? [];
|
|
131
|
+
const problems = blocks.filter((b) => blockTypes.includes(b.type) && b.type !== "upcomingEvents" && b.type !== "eventSpotlight").map((b) => ({ b, r: blockSchema.safeParse(b) })).filter((x) => !x.r.success);
|
|
132
|
+
if (problems.length) {
|
|
133
|
+
const message = `Hearth ${kind} has ${problems.length} block(s) that don't match the schema: ${problems.map((p) => p.b.type).join(", ")}`;
|
|
134
|
+
if (options.validate === "warn") console.warn(message);
|
|
135
|
+
else throw new HearthError("invalid_response", message, { details: problems.map((p) => p.r.error?.issues) });
|
|
136
|
+
}
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
/** The site's address in Hearth. */
|
|
141
|
+
siteSlug: options.site,
|
|
142
|
+
request,
|
|
143
|
+
site: {
|
|
144
|
+
get: (o = {}) => request("", { ...o, resource: "site.get" })
|
|
145
|
+
},
|
|
146
|
+
pages: {
|
|
147
|
+
list: (o = {}) => request("/pages", { ...o, resource: "pages.list" }),
|
|
148
|
+
get: async (slug, o = {}) => {
|
|
149
|
+
const s = normalizeSlug(slug);
|
|
150
|
+
const page = await maybe(`/pages/${s.split("/").map(encodeURIComponent).join("/")}`, { ...o, resource: "pages.get" });
|
|
151
|
+
return check(page, "page");
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
events: {
|
|
155
|
+
/** `category` is "group/category", e.g. "status/featured". */
|
|
156
|
+
list: (opts = {}, o = {}) => {
|
|
157
|
+
const q = new URLSearchParams();
|
|
158
|
+
if (opts.when) q.set("when", opts.when);
|
|
159
|
+
if (opts.limit) q.set("limit", String(opts.limit));
|
|
160
|
+
if (opts.collection) q.set("collection", opts.collection);
|
|
161
|
+
if (opts.category) q.set("category", opts.category);
|
|
162
|
+
return request(`/events${q.size ? `?${q}` : ""}`, { ...o, resource: "events.list" });
|
|
163
|
+
},
|
|
164
|
+
get: async (slug, o = {}) => check(await maybe(`/events/${encodeURIComponent(slug)}`, { ...o, resource: "events.get" }), "event"),
|
|
165
|
+
/**
|
|
166
|
+
* Register for an event with on-site registration (sites with Tickets). Free tickets come back confirmed; paid
|
|
167
|
+
* ones come back pending with a `checkoutUrl` to send the buyer to. Validation problems (including sold out or
|
|
168
|
+
* closed) resolve to { ok: false, fieldErrors }; network and auth failures throw.
|
|
169
|
+
*/
|
|
170
|
+
register: async (slug, input, opts = {}) => {
|
|
171
|
+
try {
|
|
172
|
+
const data = await request(`/events/${encodeURIComponent(slug)}/registrations`, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
body: input,
|
|
175
|
+
headers: opts.ip ? { "X-Forwarded-For": opts.ip } : void 0,
|
|
176
|
+
signal: opts.signal,
|
|
177
|
+
resource: "events.register"
|
|
178
|
+
});
|
|
179
|
+
return { ok: true, ...data };
|
|
180
|
+
} catch (err) {
|
|
181
|
+
if (isHearthError(err) && (err.code === "bad_request" || err.code === "rate_limited" || err.code === "feature_disabled" || err.code === "not_found")) {
|
|
182
|
+
return { ok: false, message: err.message, fieldErrors: err.details ?? {} };
|
|
183
|
+
}
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
categories: {
|
|
189
|
+
/** The site's category groups, each with its categories. */
|
|
190
|
+
list: (o = {}) => request("/categories", { ...o, resource: "categories.list" })
|
|
191
|
+
},
|
|
192
|
+
collections: {
|
|
193
|
+
/** The site's collections: page groups (a blog) and event types (Talks). */
|
|
194
|
+
list: (o = {}) => request("/collections", { ...o, resource: "collections.list" }),
|
|
195
|
+
/**
|
|
196
|
+
* One collection by address ("blog", or nested "resources/case-studies") with its entries, or null.
|
|
197
|
+
* Page collections take limit/offset/tag; event collections take when/limit.
|
|
198
|
+
*/
|
|
199
|
+
get: (slug, opts = {}, o = {}) => {
|
|
200
|
+
const q = new URLSearchParams();
|
|
201
|
+
for (const [k, v] of Object.entries(opts)) if (v !== void 0 && v !== "") q.set(k, String(v));
|
|
202
|
+
const path = normalizeSlug(slug).split("/").map(encodeURIComponent).join("/");
|
|
203
|
+
return maybe(`/collections/${path}${q.size ? `?${q}` : ""}`, { ...o, resource: "collections.get" });
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
sitemap: (o = {}) => request("/sitemap", { ...o, resource: "sitemap" }),
|
|
207
|
+
fundraisers: {
|
|
208
|
+
/** Fundraisers with progress (sites with Giving). `category` is "group/category". */
|
|
209
|
+
list: (opts = {}, o = {}) => {
|
|
210
|
+
const q = new URLSearchParams();
|
|
211
|
+
for (const [k, v] of Object.entries(opts)) if (v !== void 0 && v !== "") q.set(k, String(v));
|
|
212
|
+
return request(`/fundraisers${q.size ? `?${q}` : ""}`, { ...o, resource: "fundraisers.list" });
|
|
213
|
+
},
|
|
214
|
+
get: async (slug, o = {}) => check(await maybe(`/fundraisers/${encodeURIComponent(slug)}`, { ...o, resource: "fundraisers.get" }), "page")
|
|
215
|
+
},
|
|
216
|
+
donations: {
|
|
217
|
+
/**
|
|
218
|
+
* Start a donation; send the donor to `checkoutUrl`. Validation problems resolve to { ok: false, fieldErrors };
|
|
219
|
+
* network and auth failures throw. Pass the visitor's IP so Hearth can rate-limit per visitor.
|
|
220
|
+
*/
|
|
221
|
+
checkout: async (input, opts = {}) => {
|
|
222
|
+
try {
|
|
223
|
+
const data = await request("/donations/checkout", {
|
|
224
|
+
method: "POST",
|
|
225
|
+
body: input,
|
|
226
|
+
headers: opts.ip ? { "X-Forwarded-For": opts.ip } : void 0,
|
|
227
|
+
signal: opts.signal,
|
|
228
|
+
resource: "donations.checkout"
|
|
229
|
+
});
|
|
230
|
+
return { ok: true, ...data };
|
|
231
|
+
} catch (err) {
|
|
232
|
+
if (isHearthError(err) && (err.code === "bad_request" || err.code === "rate_limited" || err.code === "feature_disabled" || err.code === "not_found")) {
|
|
233
|
+
return { ok: false, message: err.message, fieldErrors: err.details ?? {} };
|
|
234
|
+
}
|
|
235
|
+
throw err;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
preview: {
|
|
240
|
+
/** Check a preview token from a Hearth preview link. Throws (unauthorized) if it's expired or for another site. */
|
|
241
|
+
verify: (token, o = {}) => request("/preview", { ...o, preview: token, resource: "preview.verify" })
|
|
242
|
+
},
|
|
243
|
+
customBlocks: {
|
|
244
|
+
/** The site's own block types (Custom blocks in Hearth): each one's key, name and fields, for rendering `custom` blocks. */
|
|
245
|
+
list: (o = {}) => request("/custom-blocks", { ...o, resource: "customBlocks.list" })
|
|
246
|
+
},
|
|
247
|
+
forms: {
|
|
248
|
+
/** One of the site's forms, to render anywhere (a footer, a modal); null if there's no form with that key. */
|
|
249
|
+
get: (formKey, o = {}) => maybe(`/forms/${encodeURIComponent(formKey)}`, { ...o, resource: "forms.get" }),
|
|
250
|
+
/**
|
|
251
|
+
* Store a form submission. Validation problems resolve to { ok: false, fieldErrors };
|
|
252
|
+
* network and auth failures throw. Pass the visitor's IP so Hearth can rate-limit per visitor.
|
|
253
|
+
*/
|
|
254
|
+
submit: async (formKey, values, opts = {}) => {
|
|
255
|
+
try {
|
|
256
|
+
const data = await request(`/forms/${encodeURIComponent(formKey)}`, {
|
|
257
|
+
method: "POST",
|
|
258
|
+
body: toBody(values),
|
|
259
|
+
headers: opts.ip ? { "X-Forwarded-For": opts.ip } : void 0,
|
|
260
|
+
signal: opts.signal,
|
|
261
|
+
resource: "forms.submit"
|
|
262
|
+
});
|
|
263
|
+
return { ok: true, message: data.message };
|
|
264
|
+
} catch (err) {
|
|
265
|
+
if (isHearthError(err) && (err.code === "bad_request" || err.code === "rate_limited")) {
|
|
266
|
+
return { ok: false, message: err.message, fieldErrors: err.details ?? {} };
|
|
267
|
+
}
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
function backoff(attempt) {
|
|
275
|
+
return 300 * 2 ** (attempt - 1) + Math.floor(Math.random() * 150);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export {
|
|
279
|
+
HearthError,
|
|
280
|
+
isHearthError,
|
|
281
|
+
DEFAULT_BASE_URL,
|
|
282
|
+
normalizeSlug,
|
|
283
|
+
createCore
|
|
284
|
+
};
|