@decentnetwork/beagle-service 0.0.0-stage → 0.2.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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +720 -2
  3. package/dist/agent.d.ts +18 -0
  4. package/dist/agent.js +448 -0
  5. package/dist/auth.d.ts +28 -0
  6. package/dist/auth.js +340 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +163 -0
  9. package/dist/commonjs.cjs +49 -0
  10. package/dist/commonjs.d.cts +11 -0
  11. package/dist/index.d.ts +23 -0
  12. package/dist/index.js +191 -0
  13. package/dist/outbox.d.ts +63 -0
  14. package/dist/outbox.js +314 -0
  15. package/dist/routes.d.ts +29 -0
  16. package/dist/routes.js +454 -0
  17. package/dist/stores.d.ts +21 -0
  18. package/dist/stores.js +92 -0
  19. package/dist/types.d.ts +641 -0
  20. package/dist/types.js +1 -0
  21. package/dist/users.d.ts +16 -0
  22. package/dist/users.js +429 -0
  23. package/dist/util.d.ts +48 -0
  24. package/dist/util.js +208 -0
  25. package/examples/minimal-site/index.html +91 -0
  26. package/examples/minimal-site/server.mjs +64 -0
  27. package/examples/next-pages-cjs/README.md +30 -0
  28. package/examples/next-pages-cjs/instrumentation.js +16 -0
  29. package/examples/next-pages-cjs/lib/beagle.js +23 -0
  30. package/examples/next-pages-cjs/lib/peer-for-browser.mjs +2 -0
  31. package/examples/next-pages-cjs/next.config.js +11 -0
  32. package/examples/next-pages-cjs/package.json +19 -0
  33. package/examples/next-pages-cjs/pages/api/auth/[...nextauth].js +17 -0
  34. package/examples/next-pages-cjs/pages/api/auth/launch.js +3 -0
  35. package/examples/next-pages-cjs/pages/api/auth/logout.js +3 -0
  36. package/examples/next-pages-cjs/pages/api/auth/me.js +3 -0
  37. package/examples/next-pages-cjs/pages/api/auth/nonce.js +3 -0
  38. package/examples/next-pages-cjs/pages/api/auth/register.js +3 -0
  39. package/examples/next-pages-cjs/pages/api/auth/sign-in.js +3 -0
  40. package/examples/next-pages-cjs/pages/api/notes.js +16 -0
  41. package/examples/next-pages-cjs/pages/index.js +52 -0
  42. package/examples/sign-in-from-script.mjs +37 -0
  43. package/package.json +67 -4
package/dist/index.js ADDED
@@ -0,0 +1,191 @@
1
+ // @decentnetwork/beagle-service — the web backend for a site on Beagle identity.
2
+ //
3
+ // Three parts; a site may use the first alone:
4
+ //
5
+ // auth Sign in with Beagle on the server: nonce, exact origin, the two
6
+ // assertions, a launch replay store that survives a restart, sessions.
7
+ // users one record per userid: visitor, registered or blocked; the site's
8
+ // own registration fields; the visitor limit; the blacklist.
9
+ // agent the site's own Carrier identity: who may become a friend, requests
10
+ // held on disk, a persistent outbox, messages with the sender's record.
11
+ //
12
+ // Nothing here makes one site special. Every key is an equal identity; a site
13
+ // that uses this package is one Carrier service provider among any number.
14
+ import { join } from "node:path";
15
+ import { checkOrigins, createAuth, loadSecret } from "./auth.js";
16
+ import { msgOf } from "./util.js";
17
+ import { createRoutes } from "./routes.js";
18
+ import { jsonFileStateStore, jsonFileUserStore } from "./stores.js";
19
+ import { createUsers } from "./users.js";
20
+ export async function createBeagleService(config) {
21
+ if (!config || typeof config !== "object")
22
+ throw new TypeError("beagle-service: createBeagleService needs a config object");
23
+ const dataDir = config.dataDir ?? "./data";
24
+ const now = config.now ?? Date.now;
25
+ const log = config.log ?? ((m) => console.warn(m));
26
+ // A malformed origin is found here, when the service is created, before a
27
+ // file is made or a request is answered.
28
+ checkOrigins(config.origins);
29
+ const store = config.store ?? jsonFileUserStore(join(dataDir, "users.json"));
30
+ const state = config.state ?? jsonFileStateStore(join(dataDir, "state.json"), now);
31
+ const secret = loadSecret(config.sessionSecret, join(dataDir, "session-secret"));
32
+ const users = createUsers({
33
+ store,
34
+ register: config.register,
35
+ visitor: config.visitor,
36
+ importProfile: config.importProfile,
37
+ now,
38
+ log,
39
+ fetch: config.fetch,
40
+ });
41
+ const auth = createAuth({ origins: config.origins, state, users, secret, serverId: config.serverId, session: config.session, now, log });
42
+ let agent;
43
+ const routes = createRoutes({
44
+ auth,
45
+ users,
46
+ session: config.session,
47
+ publicUser: config.publicUser,
48
+ onSignIn: config.onSignIn,
49
+ rateLimit: config.rateLimit,
50
+ agent: () => agent,
51
+ log,
52
+ now,
53
+ });
54
+ if (config.agent) {
55
+ // Only when asked for: a site that needs sign-in alone starts no peer.
56
+ const { createAgent } = await import("./agent.js");
57
+ agent = await createAgent({ config: config.agent, users, dir: join(dataDir, "agent"), now, log, fetch: config.fetch });
58
+ }
59
+ return {
60
+ users,
61
+ auth,
62
+ agent,
63
+ handle: routes.handle,
64
+ handlers: routes.handlers,
65
+ nodeHandler: routes.nodeHandler,
66
+ expressHandler: routes.expressHandler,
67
+ nextApiHandler: routes.nextApiHandler,
68
+ fetchHandler: routes.fetchHandler,
69
+ route: routes.route,
70
+ close: async () => {
71
+ await agent?.stop();
72
+ },
73
+ };
74
+ }
75
+ // ── one service per process ─────────────────────────────────────────────────
76
+ const SHARED = Symbol.for("@decentnetwork/beagle-service/shared");
77
+ const UNAVAILABLE = {
78
+ status: 500,
79
+ headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
80
+ body: { ok: false, error: "server-error", message: "Sign-in is not available on this server." },
81
+ };
82
+ /** The service for this process: created on first use, and the same one for
83
+ * every caller with the same `key`, however many times the calling module is
84
+ * evaluated (Next.js bundles pages and API routes apart) and whichever of
85
+ * `import` and `require` loaded this package.
86
+ *
87
+ * Returns at once and never throws. A configuration that is wrong shows in
88
+ * `ready()`, which rejects; the routes then answer 500 and `authenticate`
89
+ * answers `ok: false`. A failed start is not kept: it is tried again, at most
90
+ * every `retryMs`.
91
+ *
92
+ * `config` may be a function, so that reading the environment happens at
93
+ * first use and not when the module is loaded. The first call for a key
94
+ * decides the configuration. */
95
+ export function sharedBeagleService(config, opts = {}) {
96
+ const registry = (globalThis[SHARED] ??= new Map());
97
+ const key = opts.key ?? "default";
98
+ const held = registry.get(key);
99
+ if (held)
100
+ return held;
101
+ const retryMs = opts.retryMs ?? 5000;
102
+ let starting = null;
103
+ let failure = null;
104
+ let complainedAt = 0;
105
+ function ready() {
106
+ if (starting)
107
+ return starting;
108
+ if (failure && Date.now() - failure.at < retryMs)
109
+ return Promise.reject(failure.error);
110
+ const attempt = Promise.resolve().then(() => createBeagleService(typeof config === "function" ? config() : config));
111
+ starting = attempt;
112
+ attempt.then(() => (failure = null), (error) => {
113
+ // Not kept: the next call after `retryMs` starts again.
114
+ if (starting === attempt)
115
+ starting = null;
116
+ failure = { error, at: Date.now() };
117
+ if (Date.now() - complainedAt > 60_000) {
118
+ complainedAt = Date.now();
119
+ console.error(`beagle-service: the service could not start: ${msgOf(error)}`);
120
+ }
121
+ });
122
+ return attempt;
123
+ }
124
+ const down = (res) => {
125
+ if (res.headersSent)
126
+ return;
127
+ res.statusCode = UNAVAILABLE.status;
128
+ for (const [k, v] of Object.entries(UNAVAILABLE.headers))
129
+ res.setHeader(k, v);
130
+ res.end(JSON.stringify(UNAVAILABLE.body));
131
+ };
132
+ const node = (pick) => async (req, res, next) => {
133
+ let svc;
134
+ try {
135
+ svc = await ready();
136
+ }
137
+ catch {
138
+ return down(res);
139
+ }
140
+ return pick(svc)(req, res, next);
141
+ };
142
+ const shared = {
143
+ ready,
144
+ async authenticate(req, o) {
145
+ try {
146
+ return await (await ready()).auth.authenticate(req, o);
147
+ }
148
+ catch {
149
+ return { ok: false, status: 500, error: "server-error" };
150
+ }
151
+ },
152
+ async handle(req) {
153
+ try {
154
+ return await (await ready()).handle(req);
155
+ }
156
+ catch {
157
+ return UNAVAILABLE;
158
+ }
159
+ },
160
+ route: (name) => node((svc) => svc.route(name)),
161
+ nodeHandler: (o) => node((svc) => svc.nodeHandler(o)),
162
+ expressHandler: (o) => node((svc) => svc.expressHandler(o)),
163
+ nextApiHandler: (o) => node((svc) => svc.nextApiHandler(o)),
164
+ fetchHandler: (o) => async (request) => {
165
+ try {
166
+ return await (await ready()).fetchHandler(o)(request);
167
+ }
168
+ catch {
169
+ return new Response(JSON.stringify(UNAVAILABLE.body), { status: UNAVAILABLE.status, headers: UNAVAILABLE.headers });
170
+ }
171
+ },
172
+ async close() {
173
+ if (registry.get(key) === shared)
174
+ registry.delete(key);
175
+ const svc = await starting?.catch(() => null);
176
+ starting = null;
177
+ await svc?.close();
178
+ },
179
+ };
180
+ registry.set(key, shared);
181
+ return shared;
182
+ }
183
+ export { createAuth, normalizeOrigins, NONCE_TTL_MS, LAUNCH_MAX_AGE_MS } from "./auth.js";
184
+ export { createUsers, DEFAULT_PROFILE_GATEWAY } from "./users.js";
185
+ export { createAgent, loadServers, DEFAULT_SERVERS_URL } from "./agent.js";
186
+ export { Outbox, splitText } from "./outbox.js";
187
+ export { jsonFileUserStore, memoryUserStore, jsonFileStateStore, memoryStateStore, applyUserChange } from "./stores.js";
188
+ export { defaultPublicUser } from "./routes.js";
189
+ // `export *` rather than `export type *`: types.ts holds types only, and this
190
+ // form is read by TypeScript versions before 5.0 too.
191
+ export * from "./types.js";
@@ -0,0 +1,63 @@
1
+ import type { DeadLetter, DeadLetterReason, DeliveryEvent, OutboxItem } from "./types.js";
2
+ import { type Logger } from "./util.js";
3
+ /** Cut `text` into parts that each fit one message (about 700 bytes of
4
+ * UTF-8), at a line break or a space where one is near. The parts joined
5
+ * together are the text, unchanged. */
6
+ export declare function splitText(text: string): string[];
7
+ export interface OutboxTransport {
8
+ /** "friend": the friendship is settled. "pending": a request is out, or one
9
+ * of theirs is held. "none": there is no way a message could reach them. */
10
+ relation(userid: string): "friend" | "pending" | "none";
11
+ /** A live session that carried something from the friend a moment ago. */
12
+ proven(userid: string): boolean;
13
+ /** Live send with a short confirmation window; throws when nothing took it. */
14
+ sendLive(userid: string, text: string, deliveryId: string): Promise<"acked" | "accepted" | "offline">;
15
+ /** Post to an Express node whatever the session looks like; throws when it cannot. */
16
+ sendExpress(userid: string, text: string, deliveryId: string): Promise<void>;
17
+ }
18
+ export interface OutboxOptions {
19
+ file: string;
20
+ deadFile: string;
21
+ transport: OutboxTransport;
22
+ now?: () => number;
23
+ log?: Logger;
24
+ baseDelayMs?: number;
25
+ maxDelayMs?: number;
26
+ maxAgeMs?: number;
27
+ onDelivered?: (e: DeliveryEvent) => void;
28
+ onDead?: (d: DeadLetter) => void;
29
+ }
30
+ /**
31
+ * Messages on their way out, kept in one JSON file.
32
+ *
33
+ * - A message is written to disk before `enqueue` returns and removed only
34
+ * after a road took it, so a restart loses nothing.
35
+ * - Its `deliveryId` is fixed when it is queued. A retry, a second road or a
36
+ * send repeated after a crash carries the same id, and a friend that reads
37
+ * the delivery envelope shows it once.
38
+ * - One queue per recipient: a recipient's messages go in order, and an
39
+ * unreachable recipient never holds up another.
40
+ * - The road (the rule the agentnet daemon uses): a session that has just
41
+ * proven itself gets the message live, with a short wait for the
42
+ * confirmation; otherwise, or when no confirmation comes, it is posted to
43
+ * an Express node at once. Only when neither road takes it does it wait,
44
+ * with a delay that doubles up to `maxDelayMs`.
45
+ * - What can never arrive is moved to the dead-letter file with the reason.
46
+ */
47
+ export declare class Outbox {
48
+ #private;
49
+ constructor(o: OutboxOptions);
50
+ list(): OutboxItem[];
51
+ deadLetters(limit?: number): DeadLetter[];
52
+ /** Put a text on disk for `to`. Synchronous: when it returns, the message survives a crash. */
53
+ enqueue(to: string, text: string): OutboxItem[];
54
+ /** Record a message that cannot be sent at all, without queueing it. */
55
+ refuse(to: string, text: string, why: DeadLetterReason): OutboxItem[];
56
+ /** Dead-letter everything queued for `to`. */
57
+ dropFor(to: string, why: DeadLetterReason): number;
58
+ /** Something changed for `to` (accepted, connected): try now, not after the delay. */
59
+ kick(to: string): void;
60
+ stop(): void;
61
+ /** Try the head of every recipient's queue that is due. */
62
+ pump(): Promise<void>;
63
+ }
package/dist/outbox.js ADDED
@@ -0,0 +1,314 @@
1
+ import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import { atomicWriteJson, msgOf, readJsonFile } from "./util.js";
5
+ // ── splitting ───────────────────────────────────────────────────────────────
6
+ //
7
+ // A Carrier friend message that stays one packet carries at most 1024 bytes of
8
+ // text. A friend that reads the delivery envelope gets
9
+ // "\x1eDNPACK1:" + base64url(JSON {t:"msg", id, text}), so the JSON may be at
10
+ // most floor((1024 - 9) * 3 / 4) = 761 bytes; an envelope-less friend gets the
11
+ // text itself. Each part is cut to fit the tighter of the two, so it travels
12
+ // as one packet on either road and to either kind of friend.
13
+ const ENVELOPE_JSON_MAX = 761;
14
+ const ID_RESERVE = "x".repeat(48);
15
+ const fits = (text) => Buffer.byteLength(JSON.stringify({ t: "msg", id: ID_RESERVE, text })) <= ENVELOPE_JSON_MAX;
16
+ /** Cut `text` into parts that each fit one message (about 700 bytes of
17
+ * UTF-8), at a line break or a space where one is near. The parts joined
18
+ * together are the text, unchanged. */
19
+ export function splitText(text) {
20
+ if (fits(text))
21
+ return [text];
22
+ const cps = Array.from(text); // code points: never cut inside a surrogate pair
23
+ const parts = [];
24
+ let start = 0;
25
+ while (start < cps.length) {
26
+ let lo = 1;
27
+ let hi = Math.min(cps.length - start, ENVELOPE_JSON_MAX);
28
+ while (lo < hi) {
29
+ const mid = (lo + hi + 1) >> 1;
30
+ if (fits(cps.slice(start, start + mid).join("")))
31
+ lo = mid;
32
+ else
33
+ hi = mid - 1;
34
+ }
35
+ let n = lo;
36
+ if (start + n < cps.length) {
37
+ // Prefer to end the part after a line break, else after a space, when
38
+ // one lies in its last third.
39
+ const floor = Math.floor(n * (2 / 3));
40
+ let cut = -1;
41
+ for (let i = n - 1; i >= floor; i--) {
42
+ if (cps[start + i] === "\n") {
43
+ cut = i + 1;
44
+ break;
45
+ }
46
+ }
47
+ if (cut < 0) {
48
+ for (let i = n - 1; i >= floor; i--) {
49
+ if (cps[start + i] === " ") {
50
+ cut = i + 1;
51
+ break;
52
+ }
53
+ }
54
+ }
55
+ if (cut > 0)
56
+ n = cut;
57
+ }
58
+ parts.push(cps.slice(start, start + n).join(""));
59
+ start += n;
60
+ }
61
+ return parts;
62
+ }
63
+ /**
64
+ * Messages on their way out, kept in one JSON file.
65
+ *
66
+ * - A message is written to disk before `enqueue` returns and removed only
67
+ * after a road took it, so a restart loses nothing.
68
+ * - Its `deliveryId` is fixed when it is queued. A retry, a second road or a
69
+ * send repeated after a crash carries the same id, and a friend that reads
70
+ * the delivery envelope shows it once.
71
+ * - One queue per recipient: a recipient's messages go in order, and an
72
+ * unreachable recipient never holds up another.
73
+ * - The road (the rule the agentnet daemon uses): a session that has just
74
+ * proven itself gets the message live, with a short wait for the
75
+ * confirmation; otherwise, or when no confirmation comes, it is posted to
76
+ * an Express node at once. Only when neither road takes it does it wait,
77
+ * with a delay that doubles up to `maxDelayMs`.
78
+ * - What can never arrive is moved to the dead-letter file with the reason.
79
+ */
80
+ export class Outbox {
81
+ #o;
82
+ #items;
83
+ #busy = new Set();
84
+ /** The message being sent to each recipient right now. */
85
+ #inFlight = new Map();
86
+ /** Recipients whose queue was dropped while a send was in flight, and why. */
87
+ #dropAfter = new Map();
88
+ #stopped = false;
89
+ #now;
90
+ #log;
91
+ #base;
92
+ #max;
93
+ #maxAge;
94
+ constructor(o) {
95
+ this.#o = o;
96
+ this.#now = o.now ?? Date.now;
97
+ this.#log = o.log ?? (() => { });
98
+ this.#base = o.baseDelayMs ?? 5_000;
99
+ this.#max = o.maxDelayMs ?? 10 * 60_000;
100
+ this.#maxAge = o.maxAgeMs ?? 72 * 3_600_000;
101
+ const loaded = readJsonFile(o.file, []);
102
+ this.#items = Array.isArray(loaded) ? loaded.filter((i) => i && typeof i.to === "string" && typeof i.text === "string" && typeof i.deliveryId === "string") : [];
103
+ // Whatever delay was running when the process ended is over.
104
+ for (const i of this.#items)
105
+ i.nextAt = 0;
106
+ }
107
+ list() {
108
+ return this.#items.map((i) => ({ ...i }));
109
+ }
110
+ deadLetters(limit = 500) {
111
+ let raw = "";
112
+ try {
113
+ raw = readFileSync(this.#o.deadFile, "utf8");
114
+ }
115
+ catch {
116
+ return [];
117
+ }
118
+ const out = [];
119
+ for (const line of raw.split("\n")) {
120
+ if (!line)
121
+ continue;
122
+ try {
123
+ out.push(JSON.parse(line));
124
+ }
125
+ catch {
126
+ /* a torn last line */
127
+ }
128
+ }
129
+ return out.slice(-limit);
130
+ }
131
+ #make(to, text) {
132
+ const id = `${this.#now().toString(36)}${randomBytes(9).toString("base64url")}`;
133
+ const texts = splitText(text);
134
+ const createdAt = new Date(this.#now()).toISOString();
135
+ return texts.map((t, part) => ({
136
+ id,
137
+ part,
138
+ parts: texts.length,
139
+ to,
140
+ text: t,
141
+ deliveryId: `bs-${id}-${part}`,
142
+ createdAt,
143
+ attempts: 0,
144
+ nextAt: 0,
145
+ }));
146
+ }
147
+ /** Put a text on disk for `to`. Synchronous: when it returns, the message survives a crash. */
148
+ enqueue(to, text) {
149
+ const items = this.#make(to, text);
150
+ this.#items.push(...items);
151
+ this.#persist();
152
+ return items.map((i) => ({ ...i }));
153
+ }
154
+ /** Record a message that cannot be sent at all, without queueing it. */
155
+ refuse(to, text, why) {
156
+ const items = this.#make(to, text);
157
+ for (const i of items)
158
+ this.#deadLetter(i, why);
159
+ return items;
160
+ }
161
+ /** Dead-letter everything queued for `to`. */
162
+ dropFor(to, why) {
163
+ // A message already on the wire is not called dead: it may well arrive.
164
+ // Its attempt finishes; only if it fails is it dead-lettered too.
165
+ const flying = this.#inFlight.get(to);
166
+ if (flying)
167
+ this.#dropAfter.set(to, why);
168
+ const mine = this.#items.filter((i) => i.to === to && i !== flying);
169
+ if (!mine.length)
170
+ return 0;
171
+ this.#items = this.#items.filter((i) => i.to !== to || i === flying);
172
+ this.#persist();
173
+ for (const i of mine)
174
+ this.#deadLetter(i, why);
175
+ return mine.length;
176
+ }
177
+ /** Something changed for `to` (accepted, connected): try now, not after the delay. */
178
+ kick(to) {
179
+ for (const i of this.#items)
180
+ if (i.to === to)
181
+ i.nextAt = 0;
182
+ void this.pump();
183
+ }
184
+ stop() {
185
+ this.#stopped = true;
186
+ }
187
+ /** Try the head of every recipient's queue that is due. */
188
+ async pump() {
189
+ if (this.#stopped)
190
+ return;
191
+ const t = this.#now();
192
+ const due = new Set();
193
+ const seen = new Set();
194
+ for (const i of this.#items) {
195
+ if (seen.has(i.to))
196
+ continue;
197
+ seen.add(i.to);
198
+ if (!this.#busy.has(i.to) && i.nextAt <= t)
199
+ due.add(i.to);
200
+ }
201
+ await Promise.all([...due].map((to) => this.#drain(to)));
202
+ }
203
+ async #drain(to) {
204
+ this.#busy.add(to);
205
+ try {
206
+ for (;;) {
207
+ if (this.#stopped)
208
+ return;
209
+ const head = this.#items.find((i) => i.to === to);
210
+ if (!head || head.nextAt > this.#now())
211
+ return;
212
+ if (!(await this.#deliver(head)))
213
+ return;
214
+ }
215
+ }
216
+ catch (e) {
217
+ this.#log(`beagle-service: outbox for ${to.slice(0, 8)} failed: ${msgOf(e)}`);
218
+ }
219
+ finally {
220
+ this.#busy.delete(to);
221
+ }
222
+ }
223
+ /** true when the item left the queue (delivered or dead-lettered). */
224
+ async #deliver(item) {
225
+ const t = this.#o.transport;
226
+ const relation = t.relation(item.to);
227
+ if (relation === "none")
228
+ return this.#settleDead(item, "not-a-friend");
229
+ if (this.#now() - Date.parse(item.createdAt) > this.#maxAge) {
230
+ return this.#settleDead(item, relation === "pending" ? "not-a-friend" : "expired", item.lastError);
231
+ }
232
+ if (relation === "pending")
233
+ return this.#wait(item, "the friend request is not accepted yet");
234
+ let result;
235
+ this.#inFlight.set(item.to, item);
236
+ try {
237
+ result = await this.#road(item);
238
+ }
239
+ catch (e) {
240
+ const err = msgOf(e);
241
+ const dropped = this.#dropAfter.get(item.to);
242
+ if (dropped)
243
+ return this.#settleDead(item, dropped, err);
244
+ // The far side dropped us between the check and the send.
245
+ if (/not a friend/i.test(err) && t.relation(item.to) === "none")
246
+ return this.#settleDead(item, "not-a-friend", err);
247
+ return this.#wait(item, err);
248
+ }
249
+ finally {
250
+ this.#inFlight.delete(item.to);
251
+ this.#dropAfter.delete(item.to);
252
+ }
253
+ this.#remove(item);
254
+ this.#o.onDelivered?.({ id: item.id, deliveryId: item.deliveryId, to: item.to, ...result });
255
+ return true;
256
+ }
257
+ async #road(item) {
258
+ const t = this.#o.transport;
259
+ const { to, text, deliveryId } = item;
260
+ const live = async () => {
261
+ const how = await t.sendLive(to, text, deliveryId);
262
+ return { via: how === "offline" ? "offline" : "online", confirmed: how === "acked" };
263
+ };
264
+ const express = async () => {
265
+ await t.sendExpress(to, text, deliveryId);
266
+ return { via: "offline", confirmed: false };
267
+ };
268
+ const [first, second, a, b] = t.proven(to) ? [live, express, "live", "express"] : [express, live, "express", "live"];
269
+ try {
270
+ return await first();
271
+ }
272
+ catch (e1) {
273
+ try {
274
+ return await second();
275
+ }
276
+ catch (e2) {
277
+ throw new Error(`${a}: ${msgOf(e1)}; ${b}: ${msgOf(e2)}`);
278
+ }
279
+ }
280
+ }
281
+ #wait(item, error) {
282
+ item.attempts += 1;
283
+ item.lastError = error;
284
+ item.nextAt = this.#now() + Math.min(this.#max, this.#base * 2 ** Math.min(item.attempts - 1, 20));
285
+ this.#persist();
286
+ return false;
287
+ }
288
+ #settleDead(item, why, error) {
289
+ this.#remove(item);
290
+ this.#deadLetter(item, why, error);
291
+ return true;
292
+ }
293
+ #remove(item) {
294
+ const i = this.#items.indexOf(item);
295
+ if (i >= 0)
296
+ this.#items.splice(i, 1);
297
+ this.#persist();
298
+ }
299
+ #deadLetter(item, why, error) {
300
+ const letter = { at: new Date(this.#now()).toISOString(), why, ...(error ? { error } : {}), item: { ...item } };
301
+ try {
302
+ mkdirSync(dirname(this.#o.deadFile), { recursive: true });
303
+ appendFileSync(this.#o.deadFile, JSON.stringify(letter) + "\n", { mode: 0o600 });
304
+ }
305
+ catch (e) {
306
+ this.#log(`beagle-service: could not write the dead-letter file: ${msgOf(e)}`);
307
+ }
308
+ this.#log(`beagle-service: dead-letter (${why}) for ${item.to.slice(0, 8)}${error ? `: ${error}` : ""}`);
309
+ this.#o.onDead?.(letter);
310
+ }
311
+ #persist() {
312
+ atomicWriteJson(this.#o.file, this.#items);
313
+ }
314
+ }
@@ -0,0 +1,29 @@
1
+ import type { AdapterOptions, Agent, Auth, Handlers, NodeHandler, RateLimitConfig, RouteHandler, RouteName, ServiceRequest, SessionConfig, SignInHook, UserRecord, Users } from "./types.js";
2
+ import { type Logger } from "./util.js";
3
+ /** The library's own fields only: a site's extra fields (a plan, notes) do
4
+ * not reach the page unless the site says so with `publicUser`. */
5
+ export declare function defaultPublicUser(user: UserRecord): Record<string, unknown>;
6
+ export interface RoutesOptions {
7
+ auth: Auth;
8
+ users: Users;
9
+ session?: SessionConfig;
10
+ publicUser?: (user: UserRecord) => Record<string, unknown>;
11
+ onSignIn?: SignInHook;
12
+ rateLimit?: RateLimitConfig | false;
13
+ agent: () => Agent | undefined;
14
+ log: Logger;
15
+ now?: () => number;
16
+ }
17
+ /** Counts sign-in and launch attempts in fixed windows, per client and for
18
+ * all clients together. Returns the seconds to wait when a request is over
19
+ * the limit, else 0. In memory: each server process counts for itself. */
20
+ export declare function createRateLimit(config: RateLimitConfig | false | undefined, now: () => number): (req: ServiceRequest) => number;
21
+ export declare function createRoutes(opts: RoutesOptions): {
22
+ handle: RouteHandler;
23
+ handlers: Handlers;
24
+ nodeHandler: (o?: AdapterOptions) => NodeHandler;
25
+ expressHandler: (o?: AdapterOptions) => NodeHandler;
26
+ nextApiHandler: (o?: AdapterOptions) => NodeHandler;
27
+ fetchHandler: (o?: AdapterOptions) => (request: Request) => Promise<Response>;
28
+ route: (name: RouteName) => NodeHandler;
29
+ };