pi-lxmf 0.1.2 β 0.1.3
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/CHANGELOG.md +30 -0
- package/package.json +1 -1
- package/src/bridge.js +10 -3
- package/src/lxmf.js +265 -36
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.3] - 2026-09-27
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- The successful `/cd` reply is now a visually distinct banner (divider
|
|
15
|
+
line, π/π/β¨ emoji) so project change boundaries are easy to spot when
|
|
16
|
+
scrolling back through the message history.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Startup notification lost to the announce race: reticulum-js keeps the
|
|
21
|
+
destinationβidentity mapping in memory, so right after a daemon restart
|
|
22
|
+
the owner's `lxmf.delivery` hash is unknown and the router fails the
|
|
23
|
+
"π’ ready" send instantly (its path request only happens once the
|
|
24
|
+
identity is known). `sendWithRetry` now recognises that failure, sends a
|
|
25
|
+
path request (which solicits an announce from the peer or a node holding
|
|
26
|
+
its path) and waits up to 30s for the announce before retrying β instead
|
|
27
|
+
of burning two hopeless immediate retries and parking the text in the
|
|
28
|
+
next reply's delivery-failure note.
|
|
29
|
+
- Configured propagation node was never used for outbound: reticulum-js's
|
|
30
|
+
`lxmf.send` never consults the outbound propagation node on its own
|
|
31
|
+
(unlike Python's `LXMRouter`), so despite `setOutboundPropagationNode`
|
|
32
|
+
being called, replies to an off-mesh owner were simply lost. The retry
|
|
33
|
+
chain now escalates to `submitToPropagationNode` (store-and-forward,
|
|
34
|
+
delivered on the owner's next sync) after direct and opportunistic
|
|
35
|
+
delivery both fail β including waiting for the node's own announce on a
|
|
36
|
+
fresh start. The chain lives in the exported `createRetrySender`
|
|
37
|
+
(unit-tested against a fake router) instead of a closure inside
|
|
38
|
+
`startLxmf`.
|
|
39
|
+
|
|
10
40
|
## [0.1.2] - 2026-09-27
|
|
11
41
|
|
|
12
42
|
### Added
|
package/package.json
CHANGED
package/src/bridge.js
CHANGED
|
@@ -612,9 +612,16 @@ export class Bridge {
|
|
|
612
612
|
/* switch reply still goes out; the next prompt re-observes */
|
|
613
613
|
}
|
|
614
614
|
const rel = relative(this.config.workdir, absPath) || ".";
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
615
|
+
// A visually loud banner: project changes are the main boundaries
|
|
616
|
+
// in the message history, so they must be easy to spot while
|
|
617
|
+
// scrolling back.
|
|
618
|
+
const lines = ["π βββββββββββββββββββ", `π Switched to ${rel}`];
|
|
619
|
+
lines.push(
|
|
620
|
+
pointer
|
|
621
|
+
? `π Resuming session ${basename(pointer.sessionFile)}`
|
|
622
|
+
: "β¨ Fresh session",
|
|
623
|
+
);
|
|
624
|
+
return lines.join("\n");
|
|
618
625
|
}
|
|
619
626
|
|
|
620
627
|
/**
|
package/src/lxmf.js
CHANGED
|
@@ -22,6 +22,244 @@ import {
|
|
|
22
22
|
import { createBz2 } from "./bz2.js";
|
|
23
23
|
import { chunkText } from "./text.js";
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The LXMRouter's failure when the destination's identity has not been
|
|
27
|
+
* learned yet (no announce heard): `send` declines instantly β no link can
|
|
28
|
+
* be established and opportunistic encryption is impossible without the
|
|
29
|
+
* recipient's public key.
|
|
30
|
+
*/
|
|
31
|
+
const UNKNOWN_IDENTITY_MESSAGE =
|
|
32
|
+
/^Cannot deliver: identity for [0-9a-f]+ is unknown$/;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* How long {@link waitForPeerIdentity} waits for a solicited announce
|
|
36
|
+
* before giving up (and `sendWithRetry` falling back to its plain retries).
|
|
37
|
+
* Generous on purpose: the peer may be several slow mesh hops away, and the
|
|
38
|
+
* common trigger (the startup notification racing the owner's first
|
|
39
|
+
* announce after a daemon restart) is worth waiting for β the alternative
|
|
40
|
+
* parks the message in the bridge's `failedNote` until the *next* reply.
|
|
41
|
+
*/
|
|
42
|
+
const PEER_DISCOVERY_WAIT_MS = 30_000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether `e` is the router's unknown-destination failure β the caller
|
|
46
|
+
* should solicit the peer (path request + announce) instead of retrying
|
|
47
|
+
* blind, since the retry cannot succeed until the announce lands.
|
|
48
|
+
*
|
|
49
|
+
* @param {unknown} e
|
|
50
|
+
* @returns {e is Error}
|
|
51
|
+
*/
|
|
52
|
+
export function isUnknownIdentityError(e) {
|
|
53
|
+
return e instanceof Error && UNKNOWN_IDENTITY_MESSAGE.test(e.message);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Waits until `transport` can recall the identity for `destinationHash`,
|
|
58
|
+
* soliciting it first: a path request makes the destination itself (or any
|
|
59
|
+
* transport node holding its path) announce, and the ingested announce
|
|
60
|
+
* populates the destinationβidentity mapping. Resolves early once an
|
|
61
|
+
* announce for the exact destination arrives, `false` on timeout.
|
|
62
|
+
*
|
|
63
|
+
* Closes the restart gap the router leaves open: `_establishDirectLink`
|
|
64
|
+
* only requests-and-awaits a path once the identity is *known*, so an
|
|
65
|
+
* unknown identity fails the whole `send` without any mesh solicitation.
|
|
66
|
+
* reticulum-js keeps `knownDestinations` in memory, so every daemon restart
|
|
67
|
+
* re-enters that state until the owner's next announce.
|
|
68
|
+
*
|
|
69
|
+
* @param {any} transport - `rns.transport` (EventTarget with
|
|
70
|
+
* `recallIdentity`, `requestPath`; tolerates missing methods for test
|
|
71
|
+
* doubles).
|
|
72
|
+
* @param {Uint8Array} destinationHash
|
|
73
|
+
* @param {number} timeoutMs
|
|
74
|
+
* @returns {Promise<boolean>} `true` when the identity is recallable on return.
|
|
75
|
+
*/
|
|
76
|
+
export async function waitForPeerIdentity(
|
|
77
|
+
transport,
|
|
78
|
+
destinationHash,
|
|
79
|
+
timeoutMs,
|
|
80
|
+
) {
|
|
81
|
+
const destHex = toHex(destinationHash);
|
|
82
|
+
const recall = () =>
|
|
83
|
+
Promise.resolve()
|
|
84
|
+
.then(() => transport?.recallIdentity(destinationHash))
|
|
85
|
+
.catch(() => null);
|
|
86
|
+
if (await recall()) return true;
|
|
87
|
+
try {
|
|
88
|
+
await transport?.requestPath?.(destinationHash);
|
|
89
|
+
} catch {
|
|
90
|
+
/* best effort β a late announce still has the timeout window */
|
|
91
|
+
}
|
|
92
|
+
if (await recall()) return true;
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
let settled = false;
|
|
95
|
+
/** @type {NodeJS.Timeout|null} */
|
|
96
|
+
let timer = null;
|
|
97
|
+
const finish = (/** @type {boolean} */ ok) => {
|
|
98
|
+
if (settled) return;
|
|
99
|
+
settled = true;
|
|
100
|
+
if (timer) clearTimeout(timer);
|
|
101
|
+
transport.removeEventListener("announce", onAnnounce);
|
|
102
|
+
resolve(ok);
|
|
103
|
+
};
|
|
104
|
+
// The transport dispatches "announce" only after `rememberIdentity`
|
|
105
|
+
// completed, so a matching event implies a recallable identity; the
|
|
106
|
+
// re-check is belt-and-braces against half-fakes in tests.
|
|
107
|
+
const onAnnounce = (/** @type {any} */ ev) => {
|
|
108
|
+
const announced = ev?.detail?.destinationHash;
|
|
109
|
+
if (!announced || toHex(announced) !== destHex) return;
|
|
110
|
+
void recall().then((identity) => finish(Boolean(identity)));
|
|
111
|
+
};
|
|
112
|
+
timer = setTimeout(() => finish(false), timeoutMs);
|
|
113
|
+
transport.addEventListener("announce", onAnnounce);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Builds the outbound retry chain behind `sendText`/`sendReaction`:
|
|
119
|
+
*
|
|
120
|
+
* 1. `lxmf.send` over the given link (DIRECT; the router falls back to an
|
|
121
|
+
* opportunistic packet internally when no link can be established),
|
|
122
|
+
* 2. on the router's unknown-identity failure: solicit the destination
|
|
123
|
+
* (path request β announce) and wait for its announce β the restart
|
|
124
|
+
* race, since reticulum-js keeps the destinationβidentity map in
|
|
125
|
+
* memory and an immediate retry cannot succeed,
|
|
126
|
+
* 3. retry over the same link, then once more without it (the arrival
|
|
127
|
+
* link is usually gone by reply time on battery-conscious clients),
|
|
128
|
+
* 4. store-and-forward via the configured propagation node β the owner is
|
|
129
|
+
* likely off-mesh entirely; their next sync picks the message up.
|
|
130
|
+
*
|
|
131
|
+
* The same `LXMessage` object flows through every attempt so all wire
|
|
132
|
+
* copies share one message id and a deduplicating client renders the
|
|
133
|
+
* reply once. Factored out of `startLxmf` with injected dependencies so
|
|
134
|
+
* the chain is testable against a fake router.
|
|
135
|
+
*
|
|
136
|
+
* @param {object} deps
|
|
137
|
+
* @param {LXMRouter} deps.lxmf - Initialised router.
|
|
138
|
+
* @param {Identity} deps.identity - The node's LXMF identity (signs sends).
|
|
139
|
+
* @param {string|null} [deps.propagationNodeHex] - Configured propagation
|
|
140
|
+
* node's `lxmf.propagation` hash; enables the store-and-forward fallback
|
|
141
|
+
* (reticulum-js's `send` never consults the outbound node on its own).
|
|
142
|
+
* @param {(msg: string) => void} [deps.log] - Diagnostic sink.
|
|
143
|
+
* @param {number} [deps.peerWaitMs] - Per-peer announce wait (overridable in tests).
|
|
144
|
+
* @returns {{sendWithRetry: (message: LXMessage, link?: any) => Promise<void>}}
|
|
145
|
+
*/
|
|
146
|
+
export function createRetrySender({
|
|
147
|
+
lxmf,
|
|
148
|
+
identity,
|
|
149
|
+
propagationNodeHex = null,
|
|
150
|
+
log = () => {},
|
|
151
|
+
peerWaitMs = PEER_DISCOVERY_WAIT_MS,
|
|
152
|
+
}) {
|
|
153
|
+
const propagationNodeHash = propagationNodeHex
|
|
154
|
+
? fromHex(propagationNodeHex)
|
|
155
|
+
: null;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Last-resort store-and-forward through the configured propagation
|
|
159
|
+
* node, reached from `sendWithRetry` after direct and opportunistic
|
|
160
|
+
* delivery both failed β typically the owner being off-mesh entirely
|
|
161
|
+
* (the mobile case). The propagated form is encrypted to the *recipient's*
|
|
162
|
+
* public key (`dest_hash β E(srcβsigβpayload)`), so it needs their
|
|
163
|
+
* identity (by then known β the earlier sends failed on reachability,
|
|
164
|
+
* not identity) but **no live path**: the node holds the message until
|
|
165
|
+
* the owner's next sync. A node whose announce hasn't been heard yet
|
|
166
|
+
* (fresh start) is solicited and waited for like unknown recipients are.
|
|
167
|
+
*
|
|
168
|
+
* @param {LXMessage} message
|
|
169
|
+
* @param {Uint8Array} nodeHash - The configured node's `lxmf.propagation`
|
|
170
|
+
* hash (callers guarantee it is set).
|
|
171
|
+
*/
|
|
172
|
+
async function submitViaPropagationNode(message, nodeHash) {
|
|
173
|
+
const describe = (/** @type {unknown} */ e) =>
|
|
174
|
+
e instanceof Error ? e.message : String(e);
|
|
175
|
+
const nodeHex = toHex(nodeHash);
|
|
176
|
+
try {
|
|
177
|
+
try {
|
|
178
|
+
await lxmf.submitToPropagationNode(message, identity);
|
|
179
|
+
} catch (e) {
|
|
180
|
+
if (!/Propagation node identity unknown/.test(describe(e))) throw e;
|
|
181
|
+
log(
|
|
182
|
+
`pi-lxmf: propagation node ${nodeHex} unknown β requesting path, ` +
|
|
183
|
+
`waiting up to ${Math.round(peerWaitMs / 1000)}s for its announce`,
|
|
184
|
+
);
|
|
185
|
+
const learned = await waitForPeerIdentity(
|
|
186
|
+
lxmf.rns.transport,
|
|
187
|
+
nodeHash,
|
|
188
|
+
peerWaitMs,
|
|
189
|
+
);
|
|
190
|
+
if (!learned) throw e;
|
|
191
|
+
await lxmf.submitToPropagationNode(message, identity);
|
|
192
|
+
}
|
|
193
|
+
log(
|
|
194
|
+
"pi-lxmf: owner unreachable directly β submitted via propagation " +
|
|
195
|
+
"node (delivered on their next sync)",
|
|
196
|
+
);
|
|
197
|
+
} catch (e) {
|
|
198
|
+
log(`pi-lxmf: propagation submit failed (${describe(e)})`);
|
|
199
|
+
throw e;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* @param {LXMessage} message
|
|
205
|
+
* @param {any} [link]
|
|
206
|
+
*/
|
|
207
|
+
async function sendWithRetry(message, link) {
|
|
208
|
+
try {
|
|
209
|
+
await lxmf.send(message, identity, link);
|
|
210
|
+
} catch (e) {
|
|
211
|
+
log(
|
|
212
|
+
`pi-lxmf: LXMF send failed (${e instanceof Error ? e.message : e}), retrying once`,
|
|
213
|
+
);
|
|
214
|
+
// The destination's identity is unknown (typically: the startup
|
|
215
|
+
// notification racing the owner's first announce after a restart β
|
|
216
|
+
// `knownDestinations` is in-memory in reticulum-js, so every restart
|
|
217
|
+
// forgets it). An immediate retry cannot succeed; solicit the peer
|
|
218
|
+
// and give its announce time to land first.
|
|
219
|
+
if (isUnknownIdentityError(e)) {
|
|
220
|
+
const destHex = toHex(message.destinationHash);
|
|
221
|
+
log(
|
|
222
|
+
`pi-lxmf: identity for ${destHex} unknown β requesting path, waiting up to ${Math.round(peerWaitMs / 1000)}s for its announce`,
|
|
223
|
+
);
|
|
224
|
+
const learned = await waitForPeerIdentity(
|
|
225
|
+
lxmf.rns.transport,
|
|
226
|
+
message.destinationHash,
|
|
227
|
+
peerWaitMs,
|
|
228
|
+
);
|
|
229
|
+
log(
|
|
230
|
+
learned
|
|
231
|
+
? `pi-lxmf: learned ${destHex} β retrying delivery`
|
|
232
|
+
: `pi-lxmf: no announce from ${destHex} in ${Math.round(peerWaitMs / 1000)}s β retrying anyway`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
await lxmf.send(message, identity, link);
|
|
237
|
+
} catch (e2) {
|
|
238
|
+
// The arrival link is likely gone (the peer closed it after its
|
|
239
|
+
// message was acknowledged). Retry without it: `LXMRouter.send`
|
|
240
|
+
// then establishes a fresh DIRECT link, falling back to an
|
|
241
|
+
// opportunistic packet. Same message object β same message id, so
|
|
242
|
+
// a deduplicating client renders the reply once.
|
|
243
|
+
log(
|
|
244
|
+
`pi-lxmf: link retry failed (${e2 instanceof Error ? e2.message : e2}), retrying without link`,
|
|
245
|
+
);
|
|
246
|
+
try {
|
|
247
|
+
await lxmf.send(message, identity, null);
|
|
248
|
+
} catch (e3) {
|
|
249
|
+
// Direct and opportunistic both failed: the owner is likely
|
|
250
|
+
// off-mesh. Store-and-forward via the configured propagation
|
|
251
|
+
// node instead of losing the reply (their next sync picks it
|
|
252
|
+
// up); without a configured node the failure stands.
|
|
253
|
+
if (!propagationNodeHash) throw e3;
|
|
254
|
+
await submitViaPropagationNode(message, propagationNodeHash);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { sendWithRetry };
|
|
261
|
+
}
|
|
262
|
+
|
|
25
263
|
/**
|
|
26
264
|
* Attaches diagnostic logging to the inbound LXMF choke points that the
|
|
27
265
|
* bridge itself can't see: packets that decrypt but never dispatch.
|
|
@@ -216,10 +454,17 @@ export async function startLxmf(config, options = {}) {
|
|
|
216
454
|
log(`pi-lxmf: announcing as "${config.name}"`);
|
|
217
455
|
|
|
218
456
|
// Optional propagation-node integration: outbound submits go through the
|
|
219
|
-
// node when a direct link
|
|
220
|
-
// pulls messages that arrived while
|
|
221
|
-
|
|
222
|
-
|
|
457
|
+
// node when neither a direct link nor opportunistic delivery can be
|
|
458
|
+
// established, and a periodic sync pulls messages that arrived while
|
|
459
|
+
// this daemon was down. (reticulum-js's `send` never consults the
|
|
460
|
+
// outbound node on its own β `submitToPropagationNode` is an explicit
|
|
461
|
+
// call β so the store-and-forward fallback in `sendWithRetry` below is
|
|
462
|
+
// what makes the config effective.)
|
|
463
|
+
const propagationNodeHash = config.propagationNode
|
|
464
|
+
? fromHex(config.propagationNode)
|
|
465
|
+
: null;
|
|
466
|
+
if (propagationNodeHash) {
|
|
467
|
+
lxmf.setOutboundPropagationNode(propagationNodeHash);
|
|
223
468
|
log(`pi-lxmf: outbound propagation node ${config.propagationNode}`);
|
|
224
469
|
}
|
|
225
470
|
/** @type {NodeJS.Timeout|null} */
|
|
@@ -243,15 +488,26 @@ export async function startLxmf(config, options = {}) {
|
|
|
243
488
|
log(`pi-lxmf: propagation sync every ${config.syncIntervalSec}s`);
|
|
244
489
|
}
|
|
245
490
|
|
|
491
|
+
// The outbound retry chain shared by sendText/sendReaction (see
|
|
492
|
+
// createRetrySender for the escalation order).
|
|
493
|
+
const { sendWithRetry } = createRetrySender({
|
|
494
|
+
lxmf,
|
|
495
|
+
identity,
|
|
496
|
+
propagationNodeHex: config.propagationNode ?? null,
|
|
497
|
+
log,
|
|
498
|
+
});
|
|
499
|
+
|
|
246
500
|
/**
|
|
247
501
|
* Sends `text` to `destinationHex` (a 32-hex lxmf.delivery source hash),
|
|
248
502
|
* chunked to `chunkChars`, titled on the first chunk. A failed send is
|
|
249
503
|
* retried once over the same path, then once more opportunistically
|
|
250
|
-
* (without the link)
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
* reply
|
|
504
|
+
* (without the link), and finally submitted to the configured
|
|
505
|
+
* propagation node for store-and-forward β see {@link createRetrySender}
|
|
506
|
+
* for the full escalation order. Battery-conscious mobile clients tear
|
|
507
|
+
* their link down right after their message is acknowledged, so the
|
|
508
|
+
* arrival link can be gone by reply time; the same `LXMessage` object is
|
|
509
|
+
* re-sent so all wire copies share one message id and a deduplicating
|
|
510
|
+
* client shows the reply once (learned in signalk-reticulum's deliverer).
|
|
255
511
|
*
|
|
256
512
|
* @param {string} destinationHex
|
|
257
513
|
* @param {string} text
|
|
@@ -312,33 +568,6 @@ export async function startLxmf(config, options = {}) {
|
|
|
312
568
|
await sendWithRetry(message, sendOptions.link);
|
|
313
569
|
}
|
|
314
570
|
|
|
315
|
-
/**
|
|
316
|
-
* @param {LXMessage} message
|
|
317
|
-
* @param {any} [link]
|
|
318
|
-
*/
|
|
319
|
-
async function sendWithRetry(message, link) {
|
|
320
|
-
try {
|
|
321
|
-
await lxmf.send(message, identity, link);
|
|
322
|
-
} catch (e) {
|
|
323
|
-
log(
|
|
324
|
-
`pi-lxmf: LXMF send failed (${e instanceof Error ? e.message : e}), retrying once`,
|
|
325
|
-
);
|
|
326
|
-
try {
|
|
327
|
-
await lxmf.send(message, identity, link);
|
|
328
|
-
} catch (e2) {
|
|
329
|
-
// The arrival link is likely gone (the peer closed it after its
|
|
330
|
-
// message was acknowledged). Retry without it: `LXMRouter.send`
|
|
331
|
-
// then establishes a fresh DIRECT link, falling back to an
|
|
332
|
-
// opportunistic packet. Same message object β same message id, so
|
|
333
|
-
// a deduplicating client renders the reply once.
|
|
334
|
-
log(
|
|
335
|
-
`pi-lxmf: link retry failed (${e2 instanceof Error ? e2.message : e2}), retrying without link`,
|
|
336
|
-
);
|
|
337
|
-
await lxmf.send(message, identity, null);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
571
|
/**
|
|
343
572
|
* Verifies the signature of an inbound `message` against the sender's
|
|
344
573
|
* recalled identity. The router verifies signatures on the direct-delivery
|