@decentnetwork/lan 0.1.299 → 0.1.301

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.
@@ -226,6 +226,21 @@ export declare class PeerManager extends EventEmitter {
226
226
  * through PacketSession.
227
227
  */
228
228
  sendText(pubkey: string, text: string): Promise<void>;
229
+ /** Send a text to a friend who is NOT connected: the SDK posts it to the
230
+ * express store-and-forward relay, and the friend's client collects it on
231
+ * its next pull — the same path the browser client and the phones already
232
+ * use towards us. Resolves "offline" when it was posted, "acked" when a
233
+ * session turned out to be live after all; throws when there is no express
234
+ * node to post to.
235
+ *
236
+ * Two shapes, because the SDK has two send paths. A friend known to speak
237
+ * the delivery envelope (a JS peer, protoVersion >= 2) is sent through
238
+ * sendTextUntilAck with a stable deliveryId and a 250 ms window: one post,
239
+ * no retry loop (offline, the ACK cannot come, and the 15 s loop would post
240
+ * the same message to the relay three times), and the receiver de-dupes on
241
+ * the id if it ever sees it twice. A friend whose client is unknown or
242
+ * native gets the plain packet — phones do not read the envelope. */
243
+ sendTextOffline(pubkey: string, text: string, deliveryId: string): Promise<"offline" | "acked">;
229
244
  /**
230
245
  * Open an outbound packet session (initiator).
231
246
  * Sends HANDSHAKE_REQ and waits for HANDSHAKE_ACK.
@@ -493,6 +493,42 @@ export class PeerManager extends EventEmitter {
493
493
  }
494
494
  await this.peer.sendText(pubkey, text);
495
495
  }
496
+ /** Send a text to a friend who is NOT connected: the SDK posts it to the
497
+ * express store-and-forward relay, and the friend's client collects it on
498
+ * its next pull — the same path the browser client and the phones already
499
+ * use towards us. Resolves "offline" when it was posted, "acked" when a
500
+ * session turned out to be live after all; throws when there is no express
501
+ * node to post to.
502
+ *
503
+ * Two shapes, because the SDK has two send paths. A friend known to speak
504
+ * the delivery envelope (a JS peer, protoVersion >= 2) is sent through
505
+ * sendTextUntilAck with a stable deliveryId and a 250 ms window: one post,
506
+ * no retry loop (offline, the ACK cannot come, and the 15 s loop would post
507
+ * the same message to the relay three times), and the receiver de-dupes on
508
+ * the id if it ever sees it twice. A friend whose client is unknown or
509
+ * native gets the plain packet — phones do not read the envelope. */
510
+ async sendTextOffline(pubkey, text, deliveryId) {
511
+ if (!this.peer) {
512
+ throw new Error("Peer not created. Call create() first.");
513
+ }
514
+ const friend = this.peer.friends().find((f) => f.pubkey === pubkey || f.userid === pubkey);
515
+ if ((friend?.protoVersion ?? 0) >= 2) {
516
+ try {
517
+ await this.peer.sendTextUntilAck(pubkey, text, { deliveryId, timeoutMs: 250, retryIntervalMs: 250 });
518
+ return "acked";
519
+ }
520
+ catch (e) {
521
+ // The one outcome where nothing was thrown by the send itself: the
522
+ // packet went out (to express, since there is no live session) and
523
+ // no ACK arrived in the window — which offline it cannot.
524
+ if (/ACK timed out/i.test(e.message))
525
+ return "offline";
526
+ throw e;
527
+ }
528
+ }
529
+ const r = await this.peer.sendText(pubkey, text);
530
+ return r?.delivery === "offline" ? "offline" : "acked";
531
+ }
496
532
  /**
497
533
  * Open an outbound packet session (initiator).
498
534
  * Sends HANDSHAKE_REQ and waits for HANDSHAKE_ACK.
@@ -66,6 +66,9 @@ export declare class MessageStore {
66
66
  * Used to flip a "queued" message to delivered once it's actually sent.
67
67
  * No-op if the id isn't found or is a file entry. Returns true if patched. */
68
68
  setStatus(peer: string, id: string, status?: "sending" | "queued" | "sent" | "failed"): boolean;
69
+ /** Record how a text message travelled: "online" over a live session,
70
+ * "offline" posted to the express relay for the friend to collect. */
71
+ setVia(peer: string, id: string, via: "online" | "offline"): boolean;
69
72
  /** Outgoing messages still awaiting delivery (peer was offline), oldest
70
73
  * first — both queued text and queued file chips. The daemon drains this
71
74
  * on a friend's reconnect. */
@@ -74,6 +74,17 @@ export class MessageStore {
74
74
  this.scheduleSave();
75
75
  return true;
76
76
  }
77
+ /** Record how a text message travelled: "online" over a live session,
78
+ * "offline" posted to the express relay for the friend to collect. */
79
+ setVia(peer, id, via) {
80
+ const arr = this.byPeer.get(peer);
81
+ const msg = arr?.find((m) => m.id === id);
82
+ if (!msg || msg.file)
83
+ return false;
84
+ msg.via = via;
85
+ this.scheduleSave();
86
+ return true;
87
+ }
77
88
  /** Outgoing messages still awaiting delivery (peer was offline), oldest
78
89
  * first — both queued text and queued file chips. The daemon drains this
79
90
  * on a friend's reconnect. */
@@ -467,11 +467,42 @@ export class DaemonServer {
467
467
  // sender in ~/.agentnet/messages.json). "queued" now means exactly
468
468
  // one thing: nobody is sending this, the flush must.
469
469
  const online = !!this.peerManager?.isFriendOnline(userid);
470
- const msg = this.messageStore?.append(userid, "out", text, Date.now(), online ? "sending" : "queued");
470
+ const msg = this.messageStore?.append(userid, "out", text, Date.now(), "sending");
471
471
  this.friendMeta?.ensure(userid);
472
472
  this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
473
- if (!msg || !online) {
474
- this.logger.info(`Queued text for ${userid.slice(0, 8)} (delivers on reconnect)`);
473
+ if (!msg)
474
+ return;
475
+ // An OFFLINE friend used to get nothing at all: the message was
476
+ // stored "queued" and waited for a live session, which for a friend
477
+ // who is rarely connected — a browser tab, a phone in a pocket — can
478
+ // be never. Meanwhile their client, on the same SDK, posted ITS
479
+ // messages to the express store-and-forward relay and they arrived
480
+ // here as offline mail. The asymmetry was observed on 2026-09-12: a
481
+ // new web user's friend request and first message came through, the
482
+ // reply sat queued, and — because the SDK takes the first offline
483
+ // message from a "requested" friend as the proof of acceptance —
484
+ // their tab never learned it had been accepted either.
485
+ //
486
+ // So an offline friend now gets the message posted to express, the
487
+ // same way the SDK already does for friend requests. It is marked
488
+ // sent · offline, not delivered: nothing confirms it until they
489
+ // collect it. A live friend keeps the path below unchanged.
490
+ if (!online) {
491
+ void (async () => {
492
+ try {
493
+ const how = await this.peerManager.sendTextOffline(userid, text, `dl-${msg.id}`);
494
+ this.messageStore?.setStatus(userid, msg.id, undefined);
495
+ this.messageStore?.setVia(userid, msg.id, how === "offline" ? "offline" : "online");
496
+ if (how === "acked")
497
+ this.friendMeta?.markConfirmed(userid);
498
+ this.logger.info(`Text to ${userid.slice(0, 8)} ${how === "offline" ? "posted to offline mail (friend not connected)" : "delivered"}`);
499
+ }
500
+ catch (e) {
501
+ this.logger.warn(`Offline text to ${userid.slice(0, 8)} failed, queued for reconnect: ${e.message}`);
502
+ this.messageStore?.setStatus(userid, msg.id, "queued");
503
+ }
504
+ this.ipcEvents.emit("event", { type: "chat", userid, dir: "out" });
505
+ })();
475
506
  return;
476
507
  }
477
508
  // Deliver in the background; on failure hand it to the reconnect flush.
@@ -129,6 +129,11 @@ export class DoraIntegration {
129
129
  // crashed the daemon on startup.
130
130
  try {
131
131
  let record;
132
+ // True when the preferred IP was refused and config says we keep it
133
+ // anyway (dora.keepConfiguredIp). We then run unregistered on the
134
+ // configured address, still read the roster, and let the bootstrap
135
+ // retry keep asking for the preferred IP until its owner accepts it.
136
+ let keptConfiguredIp = false;
132
137
  try {
133
138
  record = await this.tryRegister(myUserid, myAddress, this.opts.preferredIp);
134
139
  }
@@ -154,41 +159,63 @@ export class DoraIntegration {
154
159
  const retryWithoutPreferredIp = /held by|in use|already taken|already in use|out of range/i.test(msg);
155
160
  if (!retryWithoutPreferredIp)
156
161
  throw err;
157
- this.logger.warn(`Preferred IP ${this.opts.preferredIp} not available (${msg}) — requesting any free IP`);
158
- record = await this.tryRegister(myUserid, myAddress);
162
+ if (this.opts.config.keepConfiguredIp && this.opts.preferredIp) {
163
+ this.logger.warn(`Preferred IP ${this.opts.preferredIp} not accepted (${msg}) — keeping it (dora.keepConfiguredIp); ` +
164
+ `running unregistered on it and retrying the registration in the background`);
165
+ keptConfiguredIp = true;
166
+ }
167
+ else {
168
+ this.logger.warn(`Preferred IP ${this.opts.preferredIp} not available (${msg}) — requesting any free IP`);
169
+ record = await this.tryRegister(myUserid, myAddress);
170
+ }
159
171
  }
160
- this.allocatedIp = record.virtualIp;
161
- this.registered = true;
162
- this.logger.info(`Registered with dora: ${record.name} -> ${record.virtualIp}`);
172
+ if (record) {
173
+ this.allocatedIp = record.virtualIp;
174
+ this.registered = true;
175
+ this.logger.info(`Registered with dora: ${record.name} -> ${record.virtualIp}`);
176
+ }
177
+ else {
178
+ this.allocatedIp = this.opts.preferredIp;
179
+ }
180
+ const selfIp = record?.virtualIp ?? this.opts.preferredIp ?? "";
181
+ const selfName = record?.name ?? this.opts.nodeName;
163
182
  // Add self to the in-memory IPAM so the local DNS server can
164
183
  // answer `<my-name>.<domain>` queries from this very host.
165
184
  // mergeRosterIntoIpam skips own entry (sensible — peers don't
166
185
  // need to "auto-friend themselves"), but the DNS resolver
167
186
  // looks at the same IPAM, so without this entry `dig snoopy.
168
187
  // decent` from snoopy itself NXDOMAINs.
169
- this.opts.ipam.assignPeer({
170
- name: record.name,
171
- carrierId: myUserid,
172
- virtualIp: record.virtualIp,
173
- services: [],
174
- });
175
- // Pull the full roster. We don't fail the whole bootstrap if
176
- // list() throws — we still have our own address allocated.
177
- try {
178
- const roster = await this.client.list();
179
- this.mergeRosterIntoIpam(roster, myUserid);
188
+ if (selfIp) {
189
+ this.opts.ipam.assignPeer({
190
+ name: selfName,
191
+ carrierId: myUserid,
192
+ virtualIp: selfIp,
193
+ services: [],
194
+ });
180
195
  }
181
- catch (err) {
182
- this.logger.warn(`Initial roster fetch failed: ${err}`);
196
+ // Pull the full roster and start the periodic refresh — once. The
197
+ // bootstrap retry re-enters this method every 30s while we are
198
+ // unregistered (keepConfiguredIp), and must not stack refresh timers.
199
+ if (!this.refreshTimer) {
200
+ // We don't fail the whole bootstrap if list() throws — we still
201
+ // have our own address.
202
+ try {
203
+ const roster = await this.client.list();
204
+ this.mergeRosterIntoIpam(roster, myUserid);
205
+ }
206
+ catch (err) {
207
+ this.logger.warn(`Initial roster fetch failed: ${err}`);
208
+ }
209
+ const interval = this.opts.config.refreshIntervalMs ?? 60_000;
210
+ this.refreshTimer = setInterval(() => {
211
+ this.refreshRoster(myUserid).catch((err) => {
212
+ this.logger.debug(`Roster refresh: ${err}`);
213
+ });
214
+ }, interval);
183
215
  }
184
- // Start periodic refresh.
185
- const interval = this.opts.config.refreshIntervalMs ?? 60_000;
186
- this.refreshTimer = setInterval(() => {
187
- this.refreshRoster(myUserid).catch((err) => {
188
- this.logger.debug(`Roster refresh: ${err}`);
189
- });
190
- }, interval);
191
- return this.allocatedIp;
216
+ if (keptConfiguredIp)
217
+ this.scheduleBootstrapRetry();
218
+ return this.allocatedIp ?? "";
192
219
  }
193
220
  catch (err) {
194
221
  if (err instanceof AllRegistriesUnavailableError) {
package/dist/types.d.ts CHANGED
@@ -204,6 +204,24 @@ export interface DoraConfig {
204
204
  * friended on every refresh; everything else is left alone.
205
205
  */
206
206
  autoFriend?: "none" | "all" | string[];
207
+ /**
208
+ * Never let a registry move this node off `network.ip`.
209
+ *
210
+ * By default, when the preferred IP is rejected (collision, or "out of
211
+ * range" because the registry that owns its band did not answer in time),
212
+ * the daemon registers for ANY free IP and rebuilds the TUN on it. That is
213
+ * right for a throwaway node and catastrophic for one whose address is
214
+ * pinned elsewhere — an exit node listed by IP in every app, a server in
215
+ * someone's /etc/hosts. 2026-09-07: a routine restart moved the tokyo exit
216
+ * from 10.86.68.90 to 10.86.64.16 and gojipower from 10.86.166.16 to
217
+ * 10.86.192.10 this way.
218
+ *
219
+ * With `keepConfiguredIp: true` the daemon keeps `network.ip`, still
220
+ * pulls the roster (so `.decent` names resolve), and keeps retrying the
221
+ * preferred registration in the background until the owning registry
222
+ * accepts it. Set it on every node whose IP other things depend on.
223
+ */
224
+ keepConfiguredIp?: boolean;
207
225
  }
208
226
  export interface DecentAgentNetConfig {
209
227
  node: NodeConfig;
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.299";
1
+ window.__DK_UI_VERSION="0.1.301";
2
2
  const ICON_PATHS = {
3
3
  // ---- tab bar (the four must feel like one set) ----
4
4
  users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.299",
3
+ "version": "0.1.301",
4
4
  "description": "Private virtual LAN for self-hosted services and AI agents, built on Elastos Carrier. NAT-traversal, name service, ACL, all over a peer-to-peer mesh — no public IP required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",