1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196 | /**
* The p2p offline mailbox: deposit an encrypted payload for a room and let
* a peer that shows up later replay it. This is the catch-up path for peers
* that are never online at the same time — for js-libp2p/Helia/OrbitDB
* apps, where encrypted log entries and head CIDs go, keyed by the database
* address.
*
* Unlike the rest of this client these calls do not go to api.ird.ro: they
* go to the app's own node at `https://<hostname>/mailbox/v1`,
* authenticated with a short-lived bearer token. Mint one with
* `credentials` (which wraps `p2p.mintMailboxCredentials`) and pass the
* returned value as the first argument to `deposit` and `replay`; it
* carries both the `endpoint` and the `token`. The app needs
* `mailbox_enabled` — see `p2p.updateApp`.
*
* The node stores exactly the bytes you send and holds no keys: encrypt
* before depositing. Payloads are capped by the app's
* `mailbox_max_message_kb` (default 64), retained for `mailbox_ttl_hours`
* (default 24) and bounded by a per-app `mailbox_max_mb` ring buffer — so
* replayed history can be truncated under you; see `isHistoryGap`.
*/
import type { Client } from './client.js';
import { configError, transportError } from './errors.js';
import { encode, requestUrl } from './http.js';
import * as p2p from './p2p.js';
import type { Body, Res } from './types.js';
export type MailboxCredentials = Res<'/p2p/apps/{id}/mailbox-credentials', 'post'>['credentials'];
export type MailboxOptions = Readonly<{ fetch?: typeof fetch; timeoutMs?: number }>;
export type MailboxPayload = Uint8Array | string | Blob | ReadableStream<Uint8Array>;
export type MailboxMessage = Readonly<{
/** Opaque ordered token; a string that must stay one (see `isHistoryGap`). */
seq: string;
id: string;
ts: number;
/** Standard base64; decode with `payloadBytes`. */
payload: string;
}>;
export type ReplayPage = Readonly<{
messages: readonly MailboxMessage[];
has_more: boolean;
oldest_seq?: string | null;
latest_seq?: string | null;
}>;
export type DepositResult = Readonly<{ seq: string; id: string; expires_at: number; deduped: boolean }>;
export type ReplayOptions = MailboxOptions &
Readonly<{
/** Replay forward from this seq, exclusive (the usual cursor). */
after?: string;
/** Scroll back from this seq, exclusive. */
before?: string;
/** Page size, default 100, capped at 500. */
limit?: number;
}>;
export type ReplayAllOptions = MailboxOptions & Readonly<{ after?: string; limit?: number }>;
export type ReplayAllResult = Readonly<{
messages: readonly MailboxMessage[];
/** The seq to resume from next time (the last one seen, or the `after` you passed). */
cursor: string | undefined;
/** Whether history was truncated before your cursor (checked on the first page). */
gap: boolean;
}>;
/**
* Mint a mailbox token for APP-ID and return the credentials directly —
* `{ token, ttl, expires_at, endpoint, room? }` — ready to pass to
* `deposit` and `replay`. BODY may scope the token to one `room` and set
* `ttl_sec` (default 3600, capped at 86400). Mint short and reuse one
* across calls: every mint is a charged request.
*/
export const credentials = async (
client: Client,
appId: string,
body?: Body<'/p2p/apps/{id}/mailbox-credentials', 'post'>,
): Promise<MailboxCredentials> => (await p2p.mintMailboxCredentials(client, appId, body)).credentials;
/**
* Percent-encode ROOM as a single path segment. Room ids are opaque customer
* strings and routinely contain slashes (an OrbitDB address is
* "/orbitdb/zdpu…"), so the whole id is encoded — the node unescapes it back
* into one room.
*/
const encodeRoom = (room: string): string => {
if (!room?.trim()) throw configError('Mailbox room must be a non-empty string', { room });
return encode(room);
};
const messagesUrl = (creds: Pick<MailboxCredentials, 'endpoint'>, room: string): string => {
if (!creds.endpoint?.trim()) {
throw configError('Mailbox credentials must carry an endpoint (mint them with `credentials`)', {
endpoint: creds.endpoint,
});
}
return `${creds.endpoint.replace(/\/+$/, '')}/rooms/${encodeRoom(room)}/messages`;
};
/** A string is its own UTF-8 bytes — payloads are data here, never file paths. */
const toBody = (payload: MailboxPayload): BodyInit => {
if (typeof payload === 'string') return new TextEncoder().encode(payload) as BodyInit;
if (payload instanceof Uint8Array) return payload as BodyInit;
if (typeof Blob !== 'undefined' && payload instanceof Blob) return payload;
if (typeof ReadableStream !== 'undefined' && payload instanceof ReadableStream) return payload;
throw transportError('Unsupported mailbox payload; expected bytes, a string, a Blob, or a ReadableStream', {
payload: typeof payload,
});
};
/**
* POST /mailbox/v1/rooms/{room}/messages — Deposit PAYLOAD into ROOM for
* peers that arrive later. Identical payloads deduplicate per room within
* the retention window (`deduped: true`), so retries are safe and free.
* Throws an api error whose `code` is the node's, notably
* "message_too_large" and "node_storage_full".
*/
export const deposit = (
creds: MailboxCredentials,
room: string,
payload: MailboxPayload,
options: MailboxOptions = {},
): Promise<DepositResult> =>
requestUrl<DepositResult>('POST', messagesUrl(creds, room), {
token: creds.token,
headers: { 'Content-Type': 'application/octet-stream' },
body: toBody(payload),
fetch: options.fetch,
timeoutMs: options.timeoutMs,
});
/**
* GET /mailbox/v1/rooms/{room}/messages — Replay ROOM, oldest first. Every
* `seq` is a string and must stay one: it packs a millisecond clock into
* its high bits, far past what a JSON number survives. There is no
* server-side ack: the cursor is yours to keep; check it against
* `oldest_seq` with `isHistoryGap` on every replay.
*/
export const replay = (creds: MailboxCredentials, room: string, options: ReplayOptions = {}): Promise<ReplayPage> =>
requestUrl<ReplayPage>('GET', messagesUrl(creds, room), {
token: creds.token,
query: { after: options.after, before: options.before, limit: options.limit },
fetch: options.fetch,
timeoutMs: options.timeoutMs,
});
/** Decode one replayed message's base64 payload into bytes. */
export const payloadBytes = (message: Pick<MailboxMessage, 'payload'>): Uint8Array =>
Uint8Array.from(atob(message.payload), (c) => c.charCodeAt(0));
/** Decode one replayed message's payload as UTF-8 text (only meaningful for text deposits). */
export const payloadString = (message: Pick<MailboxMessage, 'payload'>): string =>
new TextDecoder().decode(payloadBytes(message));
/**
* True when CURSOR — the seq you last saw — is older than PAGE's
* `oldest_seq`, meaning messages were evicted (TTL or the app's ring
* buffer) between your visits. Report that as truncated history, not as a
* quiet room. An absent/blank cursor (a first visit) is never a gap.
*/
export const isHistoryGap = (cursor: string | null | undefined, page: Pick<ReplayPage, 'oldest_seq'>): boolean => {
if (!cursor?.trim()) return false;
const oldest = page.oldest_seq;
if (oldest === undefined || oldest === null) return false;
return BigInt(cursor) < BigInt(oldest);
};
/**
* Replay ROOM forward from `after` until `has_more` is exhausted,
* collecting every message. Every page is a charged request, so pass the
* largest `limit` you can use.
*/
export const replayAll = async (
creds: MailboxCredentials,
room: string,
options: ReplayAllOptions = {},
): Promise<ReplayAllResult> => {
const { after, limit, fetch, timeoutMs } = options;
const messages: MailboxMessage[] = [];
let cursor = after;
let gap: boolean | undefined;
for (;;) {
const page = await replay(creds, room, { after: cursor, limit, fetch, timeoutMs });
gap ??= isHistoryGap(after, page);
messages.push(...page.messages);
cursor = page.messages.at(-1)?.seq ?? cursor;
if (!(page.has_more && page.messages.length > 0)) {
return { messages, cursor: messages.at(-1)?.seq ?? after, gap: gap ?? false };
}
}
};
|