@agnt-sdk/simon-server 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +12 -0
  3. package/README.md +317 -2
  4. package/dist/account.d.ts +59 -0
  5. package/dist/account.js +244 -0
  6. package/dist/auth.d.ts +42 -0
  7. package/dist/auth.js +96 -0
  8. package/dist/config.d.ts +219 -0
  9. package/dist/config.js +300 -0
  10. package/dist/context.d.ts +57 -0
  11. package/dist/context.js +2 -0
  12. package/dist/continuity.d.ts +35 -0
  13. package/dist/continuity.js +74 -0
  14. package/dist/deliver.d.ts +22 -0
  15. package/dist/deliver.js +208 -0
  16. package/dist/errors.d.ts +11 -0
  17. package/dist/errors.js +11 -0
  18. package/dist/extensions.d.ts +216 -0
  19. package/dist/extensions.js +75 -0
  20. package/dist/fanout.d.ts +32 -0
  21. package/dist/fanout.js +79 -0
  22. package/dist/http.d.ts +69 -0
  23. package/dist/http.js +112 -0
  24. package/dist/index.d.ts +15 -0
  25. package/dist/index.js +12 -0
  26. package/dist/main.d.ts +2 -0
  27. package/dist/main.js +41 -0
  28. package/dist/net/dns.d.ts +31 -0
  29. package/dist/net/dns.js +100 -0
  30. package/dist/net/fetch.d.ts +48 -0
  31. package/dist/net/fetch.js +173 -0
  32. package/dist/net/ip.d.ts +38 -0
  33. package/dist/net/ip.js +159 -0
  34. package/dist/net/psl.d.ts +22 -0
  35. package/dist/net/psl.js +91 -0
  36. package/dist/net/public-suffixes.d.ts +10 -0
  37. package/dist/net/public-suffixes.js +6885 -0
  38. package/dist/notice.d.ts +5 -0
  39. package/dist/notice.js +61 -0
  40. package/dist/outbox.d.ts +14 -0
  41. package/dist/outbox.js +356 -0
  42. package/dist/parts.d.ts +29 -0
  43. package/dist/parts.js +443 -0
  44. package/dist/public.d.ts +43 -0
  45. package/dist/public.js +134 -0
  46. package/dist/published.d.ts +3 -0
  47. package/dist/published.js +140 -0
  48. package/dist/rate.d.ts +28 -0
  49. package/dist/rate.js +77 -0
  50. package/dist/readers.d.ts +31 -0
  51. package/dist/readers.js +48 -0
  52. package/dist/register.d.ts +4 -0
  53. package/dist/register.js +401 -0
  54. package/dist/resolver.d.ts +140 -0
  55. package/dist/resolver.js +386 -0
  56. package/dist/send.d.ts +30 -0
  57. package/dist/send.js +216 -0
  58. package/dist/server.d.ts +52 -0
  59. package/dist/server.js +234 -0
  60. package/dist/signal.d.ts +5 -0
  61. package/dist/signal.js +137 -0
  62. package/dist/store/contract.d.ts +4 -0
  63. package/dist/store/contract.js +1177 -0
  64. package/dist/store/sqlite-migration.d.ts +82 -0
  65. package/dist/store/sqlite-migration.js +1019 -0
  66. package/dist/store/sqlite.d.ts +108 -0
  67. package/dist/store/sqlite.js +992 -0
  68. package/dist/store/store.d.ts +400 -0
  69. package/dist/store/store.js +23 -0
  70. package/dist/sync.d.ts +19 -0
  71. package/dist/sync.js +199 -0
  72. package/dist/testing.d.ts +38 -0
  73. package/dist/testing.js +87 -0
  74. package/dist/webhooks.d.ts +10 -0
  75. package/dist/webhooks.js +162 -0
  76. package/dist/worker.d.ts +15 -0
  77. package/dist/worker.js +33 -0
  78. package/package.json +57 -4
@@ -0,0 +1,5 @@
1
+ import { type Context } from './context.ts';
2
+ /** The local part this server writes notices as, on every domain it serves; no agent may register it. */
3
+ export declare const POSTMASTER = "postmaster";
4
+ /** Put a notice in the inbox. True confirms residence; false means the host identity is unavailable. Store failures throw. */
5
+ export declare function notify(ctx: Context, address: string, text: string): Promise<boolean>;
package/dist/notice.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Notices: a message from this server itself to an agent here (CLIENT.md register, recovery). It is an
3
+ * ordinary signed message, from `postmaster@<the agent's domain>`, a solo agent this server keeps for
4
+ * itself with a key held in its store, so a reader verifies it as it verifies any other. It goes straight
5
+ * into the inbox: no policy stands between a server and its own agent.
6
+ */
7
+ import crypto from 'node:crypto';
8
+ import { b64url, domainOf, fromB64url, keyToken, messageId, privateKeyFromSeed, signMessage, timestamp, } from '@agnt-sdk/simon-protocol';
9
+ import { iso, later } from "./context.js";
10
+ import { takeIntoInbox } from "./fanout.js";
11
+ /** The local part this server writes notices as, on every domain it serves; no agent may register it. */
12
+ export const POSTMASTER = 'postmaster';
13
+ const DAY_MS = 86_400_000;
14
+ /** Put a notice in the inbox. True confirms residence; false means the host identity is unavailable. Store failures throw. */
15
+ export async function notify(ctx, address, text) {
16
+ const { store, now: clock } = ctx;
17
+ const now = clock();
18
+ const from = `${POSTMASTER}@${domainOf(address)}`;
19
+ const seed = await store.valueOnce('postmaster-key', from, b64url(crypto.randomBytes(32)), null, iso(now));
20
+ const key = privateKeyFromSeed('ed25519', fromB64url(seed));
21
+ const token = keyToken(key);
22
+ await store.createAgent({
23
+ address: from,
24
+ kind: 'solo',
25
+ principal: null,
26
+ keys: [{ key: token, added: iso(now), revoked: null }],
27
+ mailbox: false,
28
+ createdAt: iso(now),
29
+ retiredAt: null,
30
+ });
31
+ const author = await store.agentByAddress(from);
32
+ if (!author ||
33
+ author.kind !== 'solo' ||
34
+ author.principal !== null ||
35
+ author.retiredAt ||
36
+ (author.authorizedKeys && !author.authorizedKeys.includes(token)) ||
37
+ !author.keys.some(k => k.key === token && k.revoked === null)) {
38
+ ctx.log('warn', 'no notice: the postmaster identity is unavailable', { address, from });
39
+ return false;
40
+ }
41
+ const message = signMessage({
42
+ v: 1,
43
+ from,
44
+ to: [address],
45
+ inReplyTo: null,
46
+ parts: [{ type: 'text/plain', data: text }],
47
+ headers: {},
48
+ sentAt: timestamp(now),
49
+ }, key);
50
+ const record = await store.putMessage({
51
+ id: messageId(message),
52
+ message,
53
+ carrier: ctx.config.origin,
54
+ claims: [],
55
+ signedClaims: null,
56
+ receivedAt: iso(now),
57
+ expiresAt: later(now, ctx.config.retentionDays * DAY_MS),
58
+ });
59
+ const added = await takeIntoInbox(ctx, address, record);
60
+ return added || (await store.inInbox(address, record.id));
61
+ }
@@ -0,0 +1,14 @@
1
+ import { type Context } from './context.ts';
2
+ export declare const PER_DESTINATION = 4;
3
+ export declare const CONCURRENCY = 32;
4
+ /** How long the answers a domain's move can cause are retried: the longest a resolver caches (spec section 3). */
5
+ export declare const MOVE_WINDOW_MS = 3600000;
6
+ export declare const SERVING_SINCE = "serving-since";
7
+ export declare class Outbox {
8
+ #private;
9
+ constructor(ctx: Context);
10
+ /** Claim due rows as far as capacity allows, and start attempting them. Returns how many messages it started. */
11
+ tick(): Promise<number>;
12
+ /** Wait until every attempt underway has ended. */
13
+ idle(): Promise<void>;
14
+ }
package/dist/outbox.js ADDED
@@ -0,0 +1,356 @@
1
+ /**
2
+ * The outbox: delivering stored messages to recipient servers (spec section 6), modelled on Postfix's
3
+ * queue manager.
4
+ *
5
+ * - **Claims.** Due rows are claimed from the store atomically, each leased to this attempt's token, so
6
+ * two workers never take the same row, and every outcome is written only under that lease: an attempt
7
+ * that outlived its lease writes nothing, and a delivered row is never written again.
8
+ * - **Bounded concurrency:** at most CONCURRENCY rows in flight, so at most that many POSTs, and at most
9
+ * PER_DESTINATION POSTs to one server at once. Rows for a busy server wait a moment without spending
10
+ * an attempt.
11
+ * - **Dead and throttled destinations.** A server that refuses connections, cannot be connected to, or
12
+ * holds one past the deadline (a tarpit) is skipped for DEAD_MS, and so is its address, so a thousand
13
+ * names for one tarpit cost one timeout. A `429` throttles its server until its Retry-After.
14
+ * - **Timing.** No attempt before `sentAt`, none at or after the deadline (the earlier of five days after
15
+ * acceptance and 143 hours after `sentAt`), when the recipients fail as `expired`. Retries back off
16
+ * exponentially from a minute to four hours, honour Retry-After up to an hour, and are capped to fall
17
+ * before the deadline. `429` and `5xx` answers, and failures that may pass, are retried; other answers
18
+ * are final (spec section 6.3), except two a domain's move can cause while caches still name the old
19
+ * server: a `400 invalid` on `recipients` is retried for an hour from the first such answer, the
20
+ * recipients' domain resolved again without the cache before each attempt; a `403 bad_signature` is
21
+ * retried while this server began serving the author's domain less than an hour ago ("serving since",
22
+ * kept in the store by `send`).
23
+ * - **One envelope per server,** naming that server and the recipients it serves, with only their hints:
24
+ * a message's recipient domains are resolved together, and those naming the same server share one. A
25
+ * server document must be readable before anything is posted.
26
+ * - Recipients on a domain naming this server become arrivals here, offered through the admit chain.
27
+ *
28
+ * Caps, dead and throttled destinations are this process's; several workers on one store each keep their own.
29
+ */
30
+ import crypto from 'node:crypto';
31
+ import { canonicalBytes, domainOf, envelopeFor, parseProblem, signEnvelope, } from '@agnt-sdk/simon-protocol';
32
+ import { iso, later } from "./context.js";
33
+ import { arrivalsFor } from "./fanout.js";
34
+ import { Unreachable } from "./net/fetch.js";
35
+ export const PER_DESTINATION = 4;
36
+ export const CONCURRENCY = 32;
37
+ const LEASE_MS = 2 * 60_000;
38
+ const MIN_BACKOFF_MS = 60_000;
39
+ const MAX_BACKOFF_MS = 4 * 3600_000;
40
+ const MAX_RETRY_AFTER_S = 3600;
41
+ const DEAD_MS = 5 * 60_000;
42
+ const CAPACITY_WAIT_MS = 5_000;
43
+ const POST_TIMEOUT_MS = 30_000;
44
+ const ANSWER_BYTES = 65_536;
45
+ /** The last retry is scheduled this long before the deadline, at the latest. */
46
+ const LAST_CHANCE_MS = 1_000;
47
+ /** Outcomes of resolution that mean a domain has no server to deliver to. */
48
+ const NO_SERVER = ['invalid_domain', 'invalid_record', 'conflict', 'not_found'];
49
+ /** How long the answers a domain's move can cause are retried: the longest a resolver caches (spec section 3). */
50
+ export const MOVE_WINDOW_MS = 3600_000;
51
+ /** Store scopes: when a row was first answered 400 on its recipients; when this server began serving a domain. */
52
+ const RECIPIENTS_REFUSED = 'recipients-refused';
53
+ /** Per-row absolute migration cutoffs, retained through the normal delivery deadline. */
54
+ const MOVE_CUTOFF = 'delivery-move-cutoff';
55
+ export const SERVING_SINCE = 'serving-since';
56
+ export class Outbox {
57
+ #ctx;
58
+ #running = new Set();
59
+ /** POSTs in flight, per server. */
60
+ #posting = new Map();
61
+ /** Servers skipped until a time: dead, or throttled by a 429. */
62
+ #blocked = new Map();
63
+ /** Addresses that refused or held a connection, skipped until a time. */
64
+ #deadAddresses = new Map();
65
+ /** Rows claimed and not yet settled. */
66
+ #active = 0;
67
+ constructor(ctx) {
68
+ this.#ctx = ctx;
69
+ }
70
+ /** Claim due rows as far as capacity allows, and start attempting them. Returns how many messages it started. */
71
+ async tick() {
72
+ const room = CONCURRENCY - this.#active;
73
+ if (room <= 0)
74
+ return 0;
75
+ const now = this.#ctx.now();
76
+ const busy = [...new Set([...this.#posting.keys(), ...this.#blocked.keys()])].filter(s => !this.#open(s, now));
77
+ const token = crypto.randomUUID();
78
+ const rows = await this.#ctx.store.claimDeliveries(iso(now), room, busy, { token, until: later(now, LEASE_MS) });
79
+ this.#active += rows.length;
80
+ const byMessage = Map.groupBy(rows, r => r.id);
81
+ for (const [id, group] of byMessage) {
82
+ const attempt = this.#attempt(id, group, token)
83
+ .catch(e => this.#ctx.log('error', 'delivery attempt failed', { id, error: String(e) }))
84
+ .finally(() => {
85
+ this.#active -= group.length;
86
+ this.#running.delete(attempt);
87
+ });
88
+ this.#running.add(attempt);
89
+ }
90
+ return byMessage.size;
91
+ }
92
+ /** Wait until every attempt underway has ended. */
93
+ async idle() {
94
+ while (this.#running.size)
95
+ await Promise.all(this.#running);
96
+ }
97
+ /** Whether a server may take another POST now: not blocked, and under its cap. */
98
+ #open(server, now) {
99
+ return !blockedUntil(this.#blocked, server, now) && (this.#posting.get(server) ?? 0) < PER_DESTINATION;
100
+ }
101
+ async #attempt(id, claimed, token) {
102
+ const ctx = this.#ctx;
103
+ const now = ctx.now();
104
+ const record = await ctx.store.message(id);
105
+ const rows = record ? claimed.filter(r => now < new Date(r.deadline)) : [];
106
+ const expired = claimed.filter(r => !rows.includes(r));
107
+ if (expired.length)
108
+ await this.#fail(expired, 'expired', token);
109
+ if (!record)
110
+ return;
111
+ const byServer = new Map();
112
+ for (const row of rows) {
113
+ const route = await this.#route(row, record.message.from);
114
+ const r = { ...row, server: 'server' in route ? route.server : row.server };
115
+ if ('retry' in route)
116
+ await this.#retry([r], route.retry, token);
117
+ else if ('fail' in route)
118
+ await this.#fail([r], route.fail, token);
119
+ else
120
+ byServer.set(route.server, [...(byServer.get(route.server) ?? []), r]);
121
+ }
122
+ await Promise.all([...byServer].map(([server, group]) => this.#deliverTo(server, record, group, token)));
123
+ }
124
+ /**
125
+ * Where a row's domain delivers to now, or why not; resolved afresh while its recipients were refused. The
126
+ * lookups are its author's, in the author's share of the server's own pool, so no agent's slow recipient
127
+ * domains hold up another's.
128
+ */
129
+ async #route(row, author) {
130
+ try {
131
+ const { store, now } = this.#ctx;
132
+ const at = now();
133
+ const move = await store.value(MOVE_CUTOFF, rowKey(row), iso(at));
134
+ if (move && at.getTime() >= Date.parse(move.until))
135
+ return { fail: move.reason };
136
+ const first = await store.value(RECIPIENTS_REFUSED, rowKey(row), iso(at));
137
+ if (first && at.getTime() >= Date.parse(first) + MOVE_WINDOW_MS)
138
+ return { fail: 'invalid' };
139
+ const fresh = move?.fresh || first !== null;
140
+ const peer = await this.#ctx.resolver.peer(row.domain, { agent: author }, { fresh });
141
+ if (peer.outcome !== 'server') {
142
+ return NO_SERVER.includes(peer.outcome) ? { fail: peer.outcome } : { retry: peer.outcome };
143
+ }
144
+ if (!peer.document)
145
+ return { retry: 'no_server_document' };
146
+ return { server: peer.server };
147
+ }
148
+ catch {
149
+ return { retry: 'temporary' };
150
+ }
151
+ }
152
+ async #deliverTo(server, record, rows, token) {
153
+ const ctx = this.#ctx;
154
+ const now = ctx.now();
155
+ const recipients = rows.flatMap(r => r.recipients);
156
+ const hints = Object.assign({}, ...rows.map(r => r.hints));
157
+ if (server === ctx.config.origin) {
158
+ await ctx.store.putMessage(record, { arrivals: arrivalsFor(record.id, recipients, hints, now) });
159
+ ctx.wake();
160
+ return this.#delivered(rows, server, token);
161
+ }
162
+ if (!this.#open(server, now)) {
163
+ const until = Math.max(blockedUntil(this.#blocked, server, now), now.getTime() + CAPACITY_WAIT_MS);
164
+ for (const r of rows)
165
+ await ctx.store.settleDelivery({ ...r, nextAttemptAt: iso(new Date(until)) }, token);
166
+ return;
167
+ }
168
+ this.#posting.set(server, (this.#posting.get(server) ?? 0) + 1);
169
+ try {
170
+ const outcome = await this.#post(server, record, recipients, hints);
171
+ if ('delivered' in outcome)
172
+ await this.#delivered(rows, server, token);
173
+ else if ('fail' in outcome) {
174
+ // Rows sharing one destination can have different migration histories.
175
+ for (const row of rows) {
176
+ if (await this.#moved(record, row, outcome))
177
+ await this.#retry([row], outcome.fail, token);
178
+ else
179
+ await this.#fail([row], outcome.fail, token);
180
+ }
181
+ }
182
+ else
183
+ await this.#retry(rows, outcome.retry, token, outcome.afterS);
184
+ }
185
+ finally {
186
+ const n = this.#posting.get(server) - 1;
187
+ if (n)
188
+ this.#posting.set(server, n);
189
+ else
190
+ this.#posting.delete(server);
191
+ }
192
+ }
193
+ async #post(server, record, recipients, hints) {
194
+ const ctx = this.#ctx;
195
+ // The claims as they were signed for the message at send, without this server's judgement of them.
196
+ const claims = record.signedClaims?.claims;
197
+ const envelope = signEnvelope(envelopeFor(record.message, { server, recipients }, { ...(claims ? { claims } : {}), hints }), ctx.config.serverKey);
198
+ try {
199
+ const res = await ctx.http.request({
200
+ method: 'POST',
201
+ url: `${server}/simon/deliver`,
202
+ headers: { 'content-type': 'application/json' },
203
+ body: canonicalBytes(envelope),
204
+ maxBytes: ANSWER_BYTES,
205
+ timeoutMs: POST_TIMEOUT_MS,
206
+ avoid: address => blockedUntil(this.#deadAddresses, address, ctx.now()) > 0,
207
+ });
208
+ if (res.status >= 200 && res.status < 300)
209
+ return { delivered: true };
210
+ const problem = parseProblem(res.status, res.body);
211
+ const code = problem?.code ?? `http_${res.status}`;
212
+ if (res.status !== 429 && res.status < 500)
213
+ return { fail: code, field: problem?.details?.field, status: res.status };
214
+ const afterS = retryAfter(res.headers['retry-after'], ctx.now());
215
+ if (res.status === 429)
216
+ this.#blocked.set(server, ctx.now().getTime() + (afterS ?? 60) * 1000);
217
+ return { retry: code, afterS };
218
+ }
219
+ catch (e) {
220
+ const kind = e instanceof Unreachable ? e.kind : 'failed';
221
+ if (kind === 'refused' || kind === 'connect' || kind === 'timeout') {
222
+ const until = ctx.now().getTime() + DEAD_MS;
223
+ this.#blocked.set(server, until);
224
+ if (e instanceof Unreachable && e.address)
225
+ this.#deadAddresses.set(e.address, until);
226
+ }
227
+ return { retry: kind };
228
+ }
229
+ }
230
+ /**
231
+ * Whether a refusal is one a domain's move can cause, still within its hour (spec section 6.3): a `400
232
+ * invalid` on `recipients`, an hour from the first such answer for the row, which is then resolved afresh
233
+ * before each attempt; a `403 bad_signature` while this server began serving the author's domain less than
234
+ * an hour ago.
235
+ */
236
+ async #moved(record, row, outcome) {
237
+ const { store, now } = this.#ctx;
238
+ const at = now();
239
+ if (!((outcome.status === 403 && outcome.fail === 'bad_signature') ||
240
+ (outcome.status === 400 && outcome.fail === 'invalid' && outcome.field === 'recipients')))
241
+ return false;
242
+ const existing = await store.value(MOVE_CUTOFF, rowKey(row), iso(at));
243
+ if (existing) {
244
+ if (outcome.status === 400 && outcome.field === 'recipients') {
245
+ // A later routing mismatch requires fresh resolution without extending the existing cutoff.
246
+ await store.valueOnce(RECIPIENTS_REFUSED, rowKey(row), iso(at), row.deadline, iso(at));
247
+ }
248
+ return at.getTime() < Date.parse(existing.until);
249
+ }
250
+ let cutoff = null;
251
+ if (outcome.fail === 'invalid' && outcome.field === 'recipients') {
252
+ const first = await store.valueOnce(RECIPIENTS_REFUSED, rowKey(row), iso(at), row.deadline, iso(at));
253
+ cutoff = { until: later(new Date(first), MOVE_WINDOW_MS), reason: 'invalid', fresh: true };
254
+ }
255
+ else if (outcome.fail === 'bad_signature') {
256
+ const noted = await store.value(SERVING_SINCE, domainOf(record.message.from), iso(at));
257
+ if (typeof noted === 'object' && noted !== null) {
258
+ const since = Date.parse(noted.since);
259
+ if (since <= at.getTime() && at.getTime() < since + MOVE_WINDOW_MS) {
260
+ cutoff = { until: later(new Date(since), MOVE_WINDOW_MS), reason: 'bad_signature', fresh: false };
261
+ }
262
+ }
263
+ }
264
+ if (!cutoff)
265
+ return false;
266
+ cutoff.until = iso(new Date(Math.min(Date.parse(cutoff.until), Date.parse(row.deadline))));
267
+ const fixed = await store.valueOnce(MOVE_CUTOFF, rowKey(row), cutoff, row.deadline, iso(at));
268
+ return at.getTime() < Date.parse(fixed.until);
269
+ }
270
+ async #delivered(rows, server, token) {
271
+ const settled = [];
272
+ for (const r of rows) {
273
+ const row = { ...r, server, status: 'delivered', reason: null, attempts: r.attempts + 1 };
274
+ if (await this.#ctx.store.settleDelivery(row, token))
275
+ settled.push(r);
276
+ }
277
+ if (settled.length) {
278
+ await this.#ctx.hooks.emit('delivered', {
279
+ id: rows[0].id,
280
+ server,
281
+ recipients: settled.flatMap(r => r.recipients),
282
+ });
283
+ }
284
+ }
285
+ async #fail(rows, reason, token) {
286
+ const { store, hooks } = this.#ctx;
287
+ const failed = [];
288
+ for (const r of rows) {
289
+ const attempts = r.attempts + 1;
290
+ if (reason === 'not_found' && (await this.#bridged(r))) {
291
+ await store.settleDelivery({ ...r, status: 'delivered', reason: null, attempts }, token);
292
+ }
293
+ else if (await store.settleDelivery({ ...r, status: 'failed', reason, attempts }, token)) {
294
+ failed.push(r);
295
+ }
296
+ }
297
+ if (!failed.length)
298
+ return;
299
+ await hooks.emit('deliveryFailed', {
300
+ id: failed[0].id,
301
+ server: failed[0].server,
302
+ recipients: failed.flatMap(r => r.recipients),
303
+ reason,
304
+ });
305
+ }
306
+ /** Whether an extension took recipients whose domain names no SIMON server (a bridge to email, say). */
307
+ async #bridged(row) {
308
+ const record = await this.#ctx.store.message(row.id);
309
+ return (!!record &&
310
+ this.#ctx.hooks.emit('unroutable', {
311
+ id: row.id,
312
+ domain: row.domain,
313
+ recipients: row.recipients,
314
+ message: record.message,
315
+ }));
316
+ }
317
+ /** Try again after backoff or Retry-After, before the deadline; past the last chance, the rows expire. */
318
+ async #retry(rows, reason, token, afterS) {
319
+ const now = this.#ctx.now().getTime();
320
+ const expired = [];
321
+ for (const r of rows) {
322
+ const backoff = Math.min(MIN_BACKOFF_MS * 2 ** r.attempts, MAX_BACKOFF_MS);
323
+ const delay = afterS === undefined ? backoff : Math.max(afterS * 1000, MIN_BACKOFF_MS);
324
+ const move = await this.#ctx.store.value(MOVE_CUTOFF, rowKey(r), iso(new Date(now)));
325
+ const deadline = Math.min(Date.parse(r.deadline), move ? Date.parse(move.until) : Infinity);
326
+ const next = Math.min(now + delay, deadline - LAST_CHANCE_MS);
327
+ if (next <= now && move && Date.parse(move.until) <= Date.parse(r.deadline))
328
+ await this.#fail([r], move.reason, token);
329
+ else if (next <= now)
330
+ expired.push(r);
331
+ else {
332
+ const row = { ...r, reason, attempts: r.attempts + 1, nextAttemptAt: iso(new Date(next)) };
333
+ await this.#ctx.store.settleDelivery(row, token);
334
+ }
335
+ }
336
+ if (expired.length)
337
+ await this.#fail(expired, 'expired', token);
338
+ }
339
+ }
340
+ /** A delivery row's key in the store: its message and recipient domain. */
341
+ const rowKey = (row) => `${row.id} ${row.domain}`;
342
+ /** When a key stops being blocked, or 0 when it is not (an entry past its time is forgotten). */
343
+ function blockedUntil(map, key, now) {
344
+ const until = map.get(key) ?? 0;
345
+ if (until > now.getTime())
346
+ return until;
347
+ map.delete(key);
348
+ return 0;
349
+ }
350
+ /** Retry-After in seconds, from seconds or an HTTP date, at most an hour; undefined when absent or unreadable. */
351
+ function retryAfter(value, now) {
352
+ if (!value)
353
+ return undefined;
354
+ const s = /^\d+$/.test(value) ? Number(value) : (Date.parse(value) - now.getTime()) / 1000;
355
+ return Number.isFinite(s) ? Math.min(MAX_RETRY_AFTER_S, Math.max(0, Math.ceil(s))) : undefined;
356
+ }
@@ -0,0 +1,29 @@
1
+ import { type Context } from './context.ts';
2
+ import type { Route } from './http.ts';
3
+ import type { StoredMessage } from './store/store.ts';
4
+ export declare const PREFETCH_JITTER_MS: number;
5
+ export declare const MAX_FETCHES = 4;
6
+ export declare const MAX_FETCHES_PER_CARRIER = 2;
7
+ export declare const MAX_FETCH_BYTES: number;
8
+ /**
9
+ * Fetches waiting for one carrier, at most. A part past it is given up at once, as a fetch that failed is,
10
+ * so no carrier's messages grow the queue without bound; its rows go with the messages that name them.
11
+ */
12
+ export declare const MAX_PENDING_FETCHES_PER_CARRIER = 4096;
13
+ /** Queue the fetch of an accepted message's content-addressed parts from its carrier. */
14
+ export declare function queuePrefetch(ctx: Context, record: StoredMessage): Promise<void>;
15
+ /**
16
+ * Runs due part fetches within the caps, in turn across carriers. A carrier's hourly budget and its share of
17
+ * the fetches underway are kept in the store, so every process running fetches on it shares them, and a
18
+ * restart starts over on neither; a fetch is claimed in the store before it runs, so no two processes run it.
19
+ * MAX_FETCHES and MAX_FETCH_BYTES bound each process's own work.
20
+ */
21
+ export declare class Fetcher {
22
+ #private;
23
+ constructor(ctx: Context);
24
+ /** Start due fetches, as far as the caps allow. Returns how many it started. */
25
+ tick(): Promise<number>;
26
+ /** Wait until every fetch underway has ended. */
27
+ idle(): Promise<void>;
28
+ }
29
+ export declare function partRoutes(ctx: Context): Route[];