@unicitylabs/sphere-sdk 0.2.2 → 0.2.5
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/README.md +73 -79
- package/dist/core/index.cjs +1220 -275
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +422 -221
- package/dist/core/index.d.ts +422 -221
- package/dist/core/index.js +1219 -275
- package/dist/core/index.js.map +1 -1
- package/dist/impl/browser/index.cjs +2077 -14
- package/dist/impl/browser/index.cjs.map +1 -1
- package/dist/impl/browser/index.js +2077 -14
- package/dist/impl/browser/index.js.map +1 -1
- package/dist/impl/browser/ipfs.cjs +1877 -513
- package/dist/impl/browser/ipfs.cjs.map +1 -1
- package/dist/impl/browser/ipfs.js +1877 -513
- package/dist/impl/browser/ipfs.js.map +1 -1
- package/dist/impl/nodejs/index.cjs +2222 -172
- package/dist/impl/nodejs/index.cjs.map +1 -1
- package/dist/impl/nodejs/index.d.cts +84 -3
- package/dist/impl/nodejs/index.d.ts +84 -3
- package/dist/impl/nodejs/index.js +2222 -172
- package/dist/impl/nodejs/index.js.map +1 -1
- package/dist/index.cjs +1231 -265
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +440 -67
- package/dist/index.d.ts +440 -67
- package/dist/index.js +1231 -265
- package/dist/index.js.map +1 -1
- package/package.json +25 -5
|
@@ -30,65 +30,73 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// impl/browser/ipfs.ts
|
|
31
31
|
var ipfs_exports = {};
|
|
32
32
|
__export(ipfs_exports, {
|
|
33
|
+
BrowserIpfsStatePersistence: () => BrowserIpfsStatePersistence,
|
|
33
34
|
IpfsStorageProvider: () => IpfsStorageProvider,
|
|
35
|
+
createBrowserIpfsStorageProvider: () => createBrowserIpfsStorageProvider,
|
|
34
36
|
createIpfsStorageProvider: () => createIpfsStorageProvider
|
|
35
37
|
});
|
|
36
38
|
module.exports = __toCommonJS(ipfs_exports);
|
|
37
39
|
|
|
38
|
-
//
|
|
39
|
-
var
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
/** Wallet existence flag */
|
|
55
|
-
WALLET_EXISTS: "wallet_exists",
|
|
56
|
-
/** Current active address index */
|
|
57
|
-
CURRENT_ADDRESS_INDEX: "current_address_index",
|
|
58
|
-
/** Nametag cache per address (separate from tracked addresses registry) */
|
|
59
|
-
ADDRESS_NAMETAGS: "address_nametags",
|
|
60
|
-
/** Active addresses registry (JSON: TrackedAddressesStorage) */
|
|
61
|
-
TRACKED_ADDRESSES: "tracked_addresses"
|
|
62
|
-
};
|
|
63
|
-
var STORAGE_KEYS_ADDRESS = {
|
|
64
|
-
/** Pending transfers for this address */
|
|
65
|
-
PENDING_TRANSFERS: "pending_transfers",
|
|
66
|
-
/** Transfer outbox for this address */
|
|
67
|
-
OUTBOX: "outbox",
|
|
68
|
-
/** Conversations for this address */
|
|
69
|
-
CONVERSATIONS: "conversations",
|
|
70
|
-
/** Messages for this address */
|
|
71
|
-
MESSAGES: "messages",
|
|
72
|
-
/** Transaction history for this address */
|
|
73
|
-
TRANSACTION_HISTORY: "transaction_history"
|
|
40
|
+
// impl/shared/ipfs/ipfs-error-types.ts
|
|
41
|
+
var IpfsError = class extends Error {
|
|
42
|
+
category;
|
|
43
|
+
gateway;
|
|
44
|
+
cause;
|
|
45
|
+
constructor(message, category, gateway, cause) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = "IpfsError";
|
|
48
|
+
this.category = category;
|
|
49
|
+
this.gateway = gateway;
|
|
50
|
+
this.cause = cause;
|
|
51
|
+
}
|
|
52
|
+
/** Whether this error should trigger the circuit breaker */
|
|
53
|
+
get shouldTriggerCircuitBreaker() {
|
|
54
|
+
return this.category !== "NOT_FOUND" && this.category !== "SEQUENCE_DOWNGRADE";
|
|
55
|
+
}
|
|
74
56
|
};
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
57
|
+
function classifyFetchError(error) {
|
|
58
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
59
|
+
return "TIMEOUT";
|
|
60
|
+
}
|
|
61
|
+
if (error instanceof TypeError) {
|
|
62
|
+
return "NETWORK_ERROR";
|
|
63
|
+
}
|
|
64
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
65
|
+
return "TIMEOUT";
|
|
66
|
+
}
|
|
67
|
+
return "NETWORK_ERROR";
|
|
68
|
+
}
|
|
69
|
+
function classifyHttpStatus(status, responseBody) {
|
|
70
|
+
if (status === 404) {
|
|
71
|
+
return "NOT_FOUND";
|
|
72
|
+
}
|
|
73
|
+
if (status === 500 && responseBody) {
|
|
74
|
+
if (/routing:\s*not\s*found/i.test(responseBody)) {
|
|
75
|
+
return "NOT_FOUND";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (status >= 500) {
|
|
79
|
+
return "GATEWAY_ERROR";
|
|
80
|
+
}
|
|
81
|
+
if (status >= 400) {
|
|
82
|
+
return "GATEWAY_ERROR";
|
|
83
|
+
}
|
|
84
|
+
return "GATEWAY_ERROR";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// impl/shared/ipfs/ipfs-state-persistence.ts
|
|
88
|
+
var InMemoryIpfsStatePersistence = class {
|
|
89
|
+
states = /* @__PURE__ */ new Map();
|
|
90
|
+
async load(ipnsName) {
|
|
91
|
+
return this.states.get(ipnsName) ?? null;
|
|
92
|
+
}
|
|
93
|
+
async save(ipnsName, state) {
|
|
94
|
+
this.states.set(ipnsName, { ...state });
|
|
95
|
+
}
|
|
96
|
+
async clear(ipnsName) {
|
|
97
|
+
this.states.delete(ipnsName);
|
|
98
|
+
}
|
|
78
99
|
};
|
|
79
|
-
var DEFAULT_IPFS_GATEWAYS = [
|
|
80
|
-
"https://ipfs.unicity.network",
|
|
81
|
-
"https://dweb.link",
|
|
82
|
-
"https://ipfs.io"
|
|
83
|
-
];
|
|
84
|
-
var DEFAULT_IPFS_BOOTSTRAP_PEERS = [
|
|
85
|
-
"/dns4/unicity-ipfs2.dyndns.org/tcp/4001/p2p/12D3KooWLNi5NDPPHbrfJakAQqwBqymYTTwMQXQKEWuCrJNDdmfh",
|
|
86
|
-
"/dns4/unicity-ipfs3.dyndns.org/tcp/4001/p2p/12D3KooWQ4aujVE4ShLjdusNZBdffq3TbzrwT2DuWZY9H1Gxhwn6",
|
|
87
|
-
"/dns4/unicity-ipfs4.dyndns.org/tcp/4001/p2p/12D3KooWJ1ByPfUzUrpYvgxKU8NZrR8i6PU1tUgMEbQX9Hh2DEn1",
|
|
88
|
-
"/dns4/unicity-ipfs5.dyndns.org/tcp/4001/p2p/12D3KooWB1MdZZGHN5B8TvWXntbycfe7Cjcz7n6eZ9eykZadvmDv"
|
|
89
|
-
];
|
|
90
|
-
var DEFAULT_BASE_PATH = "m/44'/0'/0'";
|
|
91
|
-
var DEFAULT_DERIVATION_PATH = `${DEFAULT_BASE_PATH}/0/0`;
|
|
92
100
|
|
|
93
101
|
// node_modules/@noble/hashes/utils.js
|
|
94
102
|
function isBytes(a) {
|
|
@@ -526,581 +534,1937 @@ var sha256 = /* @__PURE__ */ createHasher(
|
|
|
526
534
|
/* @__PURE__ */ oidNist(1)
|
|
527
535
|
);
|
|
528
536
|
|
|
529
|
-
//
|
|
530
|
-
var
|
|
531
|
-
var
|
|
532
|
-
var
|
|
537
|
+
// core/crypto.ts
|
|
538
|
+
var bip39 = __toESM(require("bip39"), 1);
|
|
539
|
+
var import_crypto_js = __toESM(require("crypto-js"), 1);
|
|
540
|
+
var import_elliptic = __toESM(require("elliptic"), 1);
|
|
541
|
+
var ec = new import_elliptic.default.ec("secp256k1");
|
|
542
|
+
var CURVE_ORDER = BigInt(
|
|
543
|
+
"0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141"
|
|
544
|
+
);
|
|
545
|
+
function hexToBytes(hex) {
|
|
546
|
+
const matches = hex.match(/../g);
|
|
547
|
+
if (!matches) {
|
|
548
|
+
return new Uint8Array(0);
|
|
549
|
+
}
|
|
550
|
+
return Uint8Array.from(matches.map((x) => parseInt(x, 16)));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// impl/shared/ipfs/ipns-key-derivation.ts
|
|
554
|
+
var IPNS_HKDF_INFO = "ipfs-storage-ed25519-v1";
|
|
533
555
|
var libp2pCryptoModule = null;
|
|
534
556
|
var libp2pPeerIdModule = null;
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
[
|
|
539
|
-
heliaModule,
|
|
540
|
-
heliaJsonModule,
|
|
541
|
-
libp2pBootstrapModule,
|
|
542
|
-
libp2pCryptoModule,
|
|
543
|
-
libp2pPeerIdModule,
|
|
544
|
-
multiformatsCidModule
|
|
545
|
-
] = await Promise.all([
|
|
546
|
-
import("helia"),
|
|
547
|
-
import("@helia/json"),
|
|
548
|
-
import("@libp2p/bootstrap"),
|
|
557
|
+
async function loadLibp2pModules() {
|
|
558
|
+
if (!libp2pCryptoModule) {
|
|
559
|
+
[libp2pCryptoModule, libp2pPeerIdModule] = await Promise.all([
|
|
549
560
|
import("@libp2p/crypto/keys"),
|
|
550
|
-
import("@libp2p/peer-id")
|
|
551
|
-
import("multiformats/cid")
|
|
561
|
+
import("@libp2p/peer-id")
|
|
552
562
|
]);
|
|
553
563
|
}
|
|
554
564
|
return {
|
|
555
|
-
createHelia: heliaModule.createHelia,
|
|
556
|
-
json: heliaJsonModule.json,
|
|
557
|
-
bootstrap: libp2pBootstrapModule.bootstrap,
|
|
558
565
|
generateKeyPairFromSeed: libp2pCryptoModule.generateKeyPairFromSeed,
|
|
559
|
-
peerIdFromPrivateKey: libp2pPeerIdModule.peerIdFromPrivateKey
|
|
560
|
-
CID: multiformatsCidModule.CID
|
|
566
|
+
peerIdFromPrivateKey: libp2pPeerIdModule.peerIdFromPrivateKey
|
|
561
567
|
};
|
|
562
568
|
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
bootstrapPeers: config?.bootstrapPeers ?? [...DEFAULT_IPFS_BOOTSTRAP_PEERS],
|
|
590
|
-
enableIpns: config?.enableIpns ?? true,
|
|
591
|
-
ipnsTimeout: config?.ipnsTimeout ?? 3e4,
|
|
592
|
-
fetchTimeout: config?.fetchTimeout ?? 15e3,
|
|
593
|
-
debug: config?.debug ?? false
|
|
569
|
+
function deriveEd25519KeyMaterial(privateKeyHex, info = IPNS_HKDF_INFO) {
|
|
570
|
+
const walletSecret = hexToBytes(privateKeyHex);
|
|
571
|
+
const infoBytes = new TextEncoder().encode(info);
|
|
572
|
+
return hkdf(sha256, walletSecret, void 0, infoBytes, 32);
|
|
573
|
+
}
|
|
574
|
+
async function deriveIpnsIdentity(privateKeyHex) {
|
|
575
|
+
const { generateKeyPairFromSeed, peerIdFromPrivateKey } = await loadLibp2pModules();
|
|
576
|
+
const derivedKey = deriveEd25519KeyMaterial(privateKeyHex);
|
|
577
|
+
const keyPair = await generateKeyPairFromSeed("Ed25519", derivedKey);
|
|
578
|
+
const peerId = peerIdFromPrivateKey(keyPair);
|
|
579
|
+
return {
|
|
580
|
+
keyPair,
|
|
581
|
+
ipnsName: peerId.toString()
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// impl/shared/ipfs/ipns-record-manager.ts
|
|
586
|
+
var DEFAULT_LIFETIME_MS = 99 * 365 * 24 * 60 * 60 * 1e3;
|
|
587
|
+
var ipnsModule = null;
|
|
588
|
+
async function loadIpnsModule() {
|
|
589
|
+
if (!ipnsModule) {
|
|
590
|
+
const mod = await import("ipns");
|
|
591
|
+
ipnsModule = {
|
|
592
|
+
createIPNSRecord: mod.createIPNSRecord,
|
|
593
|
+
marshalIPNSRecord: mod.marshalIPNSRecord,
|
|
594
|
+
unmarshalIPNSRecord: mod.unmarshalIPNSRecord
|
|
594
595
|
};
|
|
595
596
|
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
597
|
+
return ipnsModule;
|
|
598
|
+
}
|
|
599
|
+
async function createSignedRecord(keyPair, cid, sequenceNumber, lifetimeMs = DEFAULT_LIFETIME_MS) {
|
|
600
|
+
const { createIPNSRecord, marshalIPNSRecord } = await loadIpnsModule();
|
|
601
|
+
const record = await createIPNSRecord(
|
|
602
|
+
keyPair,
|
|
603
|
+
`/ipfs/${cid}`,
|
|
604
|
+
sequenceNumber,
|
|
605
|
+
lifetimeMs
|
|
606
|
+
);
|
|
607
|
+
return marshalIPNSRecord(record);
|
|
608
|
+
}
|
|
609
|
+
async function parseRoutingApiResponse(responseText) {
|
|
610
|
+
const { unmarshalIPNSRecord } = await loadIpnsModule();
|
|
611
|
+
const lines = responseText.trim().split("\n");
|
|
612
|
+
for (const line of lines) {
|
|
613
|
+
if (!line.trim()) continue;
|
|
602
614
|
try {
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
615
|
+
const obj = JSON.parse(line);
|
|
616
|
+
if (obj.Extra) {
|
|
617
|
+
const recordData = base64ToUint8Array(obj.Extra);
|
|
618
|
+
const record = unmarshalIPNSRecord(recordData);
|
|
619
|
+
const valueBytes = typeof record.value === "string" ? new TextEncoder().encode(record.value) : record.value;
|
|
620
|
+
const valueStr = new TextDecoder().decode(valueBytes);
|
|
621
|
+
const cidMatch = valueStr.match(/\/ipfs\/([a-zA-Z0-9]+)/);
|
|
622
|
+
if (cidMatch) {
|
|
623
|
+
return {
|
|
624
|
+
cid: cidMatch[1],
|
|
625
|
+
sequence: record.sequence,
|
|
626
|
+
recordData
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
} catch {
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
function base64ToUint8Array(base64) {
|
|
637
|
+
const binary = atob(base64);
|
|
638
|
+
const bytes = new Uint8Array(binary.length);
|
|
639
|
+
for (let i = 0; i < binary.length; i++) {
|
|
640
|
+
bytes[i] = binary.charCodeAt(i);
|
|
641
|
+
}
|
|
642
|
+
return bytes;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// impl/shared/ipfs/ipfs-cache.ts
|
|
646
|
+
var DEFAULT_IPNS_TTL_MS = 6e4;
|
|
647
|
+
var DEFAULT_FAILURE_COOLDOWN_MS = 6e4;
|
|
648
|
+
var DEFAULT_FAILURE_THRESHOLD = 3;
|
|
649
|
+
var DEFAULT_KNOWN_FRESH_WINDOW_MS = 3e4;
|
|
650
|
+
var IpfsCache = class {
|
|
651
|
+
ipnsRecords = /* @__PURE__ */ new Map();
|
|
652
|
+
content = /* @__PURE__ */ new Map();
|
|
653
|
+
gatewayFailures = /* @__PURE__ */ new Map();
|
|
654
|
+
knownFreshTimestamps = /* @__PURE__ */ new Map();
|
|
655
|
+
ipnsTtlMs;
|
|
656
|
+
failureCooldownMs;
|
|
657
|
+
failureThreshold;
|
|
658
|
+
knownFreshWindowMs;
|
|
659
|
+
constructor(config) {
|
|
660
|
+
this.ipnsTtlMs = config?.ipnsTtlMs ?? DEFAULT_IPNS_TTL_MS;
|
|
661
|
+
this.failureCooldownMs = config?.failureCooldownMs ?? DEFAULT_FAILURE_COOLDOWN_MS;
|
|
662
|
+
this.failureThreshold = config?.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD;
|
|
663
|
+
this.knownFreshWindowMs = config?.knownFreshWindowMs ?? DEFAULT_KNOWN_FRESH_WINDOW_MS;
|
|
664
|
+
}
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
// IPNS Record Cache (60s TTL)
|
|
667
|
+
// ---------------------------------------------------------------------------
|
|
668
|
+
getIpnsRecord(ipnsName) {
|
|
669
|
+
const entry = this.ipnsRecords.get(ipnsName);
|
|
670
|
+
if (!entry) return null;
|
|
671
|
+
if (Date.now() - entry.timestamp > this.ipnsTtlMs) {
|
|
672
|
+
this.ipnsRecords.delete(ipnsName);
|
|
673
|
+
return null;
|
|
610
674
|
}
|
|
675
|
+
return entry.data;
|
|
611
676
|
}
|
|
612
677
|
/**
|
|
613
|
-
*
|
|
678
|
+
* Get cached IPNS record ignoring TTL (for known-fresh optimization).
|
|
614
679
|
*/
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
this.log("Initializing Helia with bootstrap peers...");
|
|
619
|
-
const { createHelia, json, bootstrap } = await loadHeliaModules();
|
|
620
|
-
this.helia = await createHelia({
|
|
621
|
-
libp2p: {
|
|
622
|
-
peerDiscovery: [
|
|
623
|
-
bootstrap({ list: this.config.bootstrapPeers })
|
|
624
|
-
],
|
|
625
|
-
connectionManager: {
|
|
626
|
-
maxConnections: 10
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
});
|
|
630
|
-
this.heliaJson = json(this.helia);
|
|
631
|
-
const peerId = this.helia.libp2p.peerId.toString();
|
|
632
|
-
this.log("Helia initialized, browser peer ID:", peerId.slice(0, 20) + "...");
|
|
633
|
-
setTimeout(() => {
|
|
634
|
-
const connections = this.helia?.libp2p.getConnections() || [];
|
|
635
|
-
this.log(`Active Helia connections: ${connections.length}`);
|
|
636
|
-
}, 3e3);
|
|
637
|
-
} catch (error) {
|
|
638
|
-
this.log("Helia initialization failed (will use HTTP only):", error);
|
|
639
|
-
}
|
|
680
|
+
getIpnsRecordIgnoreTtl(ipnsName) {
|
|
681
|
+
const entry = this.ipnsRecords.get(ipnsName);
|
|
682
|
+
return entry?.data ?? null;
|
|
640
683
|
}
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
this.log("Error stopping Helia:", error);
|
|
647
|
-
}
|
|
648
|
-
this.helia = null;
|
|
649
|
-
this.heliaJson = null;
|
|
650
|
-
}
|
|
651
|
-
this.status = "disconnected";
|
|
652
|
-
this.localCache = null;
|
|
653
|
-
this.ipnsKeyPair = null;
|
|
654
|
-
this.log("Disconnected from IPFS");
|
|
684
|
+
setIpnsRecord(ipnsName, result) {
|
|
685
|
+
this.ipnsRecords.set(ipnsName, {
|
|
686
|
+
data: result,
|
|
687
|
+
timestamp: Date.now()
|
|
688
|
+
});
|
|
655
689
|
}
|
|
656
|
-
|
|
657
|
-
|
|
690
|
+
invalidateIpns(ipnsName) {
|
|
691
|
+
this.ipnsRecords.delete(ipnsName);
|
|
658
692
|
}
|
|
659
|
-
|
|
660
|
-
|
|
693
|
+
// ---------------------------------------------------------------------------
|
|
694
|
+
// Content Cache (infinite TTL - content is immutable by CID)
|
|
695
|
+
// ---------------------------------------------------------------------------
|
|
696
|
+
getContent(cid) {
|
|
697
|
+
const entry = this.content.get(cid);
|
|
698
|
+
return entry?.data ?? null;
|
|
661
699
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
try {
|
|
668
|
-
const { generateKeyPairFromSeed, peerIdFromPrivateKey } = await loadHeliaModules();
|
|
669
|
-
const walletSecret = this.hexToBytes(identity.privateKey);
|
|
670
|
-
const derivedKey = hkdf(sha256, walletSecret, void 0, HKDF_INFO, 32);
|
|
671
|
-
this.ipnsKeyPair = await generateKeyPairFromSeed("Ed25519", derivedKey);
|
|
672
|
-
const peerId = peerIdFromPrivateKey(this.ipnsKeyPair);
|
|
673
|
-
this.ipnsName = peerId.toString();
|
|
674
|
-
this.log("Identity set, IPNS name:", this.ipnsName);
|
|
675
|
-
} catch {
|
|
676
|
-
this.ipnsName = identity.ipnsName ?? this.deriveIpnsNameSimple(identity.privateKey);
|
|
677
|
-
this.log("Identity set with fallback IPNS name:", this.ipnsName);
|
|
678
|
-
}
|
|
700
|
+
setContent(cid, data) {
|
|
701
|
+
this.content.set(cid, {
|
|
702
|
+
data,
|
|
703
|
+
timestamp: Date.now()
|
|
704
|
+
});
|
|
679
705
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
706
|
+
// ---------------------------------------------------------------------------
|
|
707
|
+
// Gateway Failure Tracking (Circuit Breaker)
|
|
708
|
+
// ---------------------------------------------------------------------------
|
|
709
|
+
/**
|
|
710
|
+
* Record a gateway failure. After threshold consecutive failures,
|
|
711
|
+
* the gateway enters cooldown and is considered unhealthy.
|
|
712
|
+
*/
|
|
713
|
+
recordGatewayFailure(gateway) {
|
|
714
|
+
const existing = this.gatewayFailures.get(gateway);
|
|
715
|
+
this.gatewayFailures.set(gateway, {
|
|
716
|
+
count: (existing?.count ?? 0) + 1,
|
|
717
|
+
lastFailure: Date.now()
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
/** Reset failure count for a gateway (on successful request) */
|
|
721
|
+
recordGatewaySuccess(gateway) {
|
|
722
|
+
this.gatewayFailures.delete(gateway);
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Check if a gateway is currently in circuit breaker cooldown.
|
|
726
|
+
* A gateway is considered unhealthy if it has had >= threshold
|
|
727
|
+
* consecutive failures and the cooldown period hasn't elapsed.
|
|
728
|
+
*/
|
|
729
|
+
isGatewayInCooldown(gateway) {
|
|
730
|
+
const failure = this.gatewayFailures.get(gateway);
|
|
731
|
+
if (!failure) return false;
|
|
732
|
+
if (failure.count < this.failureThreshold) return false;
|
|
733
|
+
const elapsed = Date.now() - failure.lastFailure;
|
|
734
|
+
if (elapsed >= this.failureCooldownMs) {
|
|
735
|
+
this.gatewayFailures.delete(gateway);
|
|
736
|
+
return false;
|
|
683
737
|
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
738
|
+
return true;
|
|
739
|
+
}
|
|
740
|
+
// ---------------------------------------------------------------------------
|
|
741
|
+
// Known-Fresh Flag (FAST mode optimization)
|
|
742
|
+
// ---------------------------------------------------------------------------
|
|
743
|
+
/**
|
|
744
|
+
* Mark IPNS cache as "known-fresh" (after local publish or push notification).
|
|
745
|
+
* Within the fresh window, we can skip network resolution.
|
|
746
|
+
*/
|
|
747
|
+
markIpnsFresh(ipnsName) {
|
|
748
|
+
this.knownFreshTimestamps.set(ipnsName, Date.now());
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Check if the cache is known-fresh (within the fresh window).
|
|
752
|
+
*/
|
|
753
|
+
isIpnsKnownFresh(ipnsName) {
|
|
754
|
+
const timestamp = this.knownFreshTimestamps.get(ipnsName);
|
|
755
|
+
if (!timestamp) return false;
|
|
756
|
+
if (Date.now() - timestamp > this.knownFreshWindowMs) {
|
|
757
|
+
this.knownFreshTimestamps.delete(ipnsName);
|
|
688
758
|
return false;
|
|
689
759
|
}
|
|
760
|
+
return true;
|
|
690
761
|
}
|
|
691
|
-
|
|
692
|
-
|
|
762
|
+
// ---------------------------------------------------------------------------
|
|
763
|
+
// Cache Management
|
|
764
|
+
// ---------------------------------------------------------------------------
|
|
765
|
+
clear() {
|
|
766
|
+
this.ipnsRecords.clear();
|
|
767
|
+
this.content.clear();
|
|
768
|
+
this.gatewayFailures.clear();
|
|
769
|
+
this.knownFreshTimestamps.clear();
|
|
693
770
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
// impl/shared/ipfs/ipfs-http-client.ts
|
|
774
|
+
var DEFAULT_CONNECTIVITY_TIMEOUT_MS = 5e3;
|
|
775
|
+
var DEFAULT_FETCH_TIMEOUT_MS = 15e3;
|
|
776
|
+
var DEFAULT_RESOLVE_TIMEOUT_MS = 1e4;
|
|
777
|
+
var DEFAULT_PUBLISH_TIMEOUT_MS = 3e4;
|
|
778
|
+
var DEFAULT_GATEWAY_PATH_TIMEOUT_MS = 3e3;
|
|
779
|
+
var DEFAULT_ROUTING_API_TIMEOUT_MS = 2e3;
|
|
780
|
+
var IpfsHttpClient = class {
|
|
781
|
+
gateways;
|
|
782
|
+
fetchTimeoutMs;
|
|
783
|
+
resolveTimeoutMs;
|
|
784
|
+
publishTimeoutMs;
|
|
785
|
+
connectivityTimeoutMs;
|
|
786
|
+
debug;
|
|
787
|
+
cache;
|
|
788
|
+
constructor(config, cache) {
|
|
789
|
+
this.gateways = config.gateways;
|
|
790
|
+
this.fetchTimeoutMs = config.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
791
|
+
this.resolveTimeoutMs = config.resolveTimeoutMs ?? DEFAULT_RESOLVE_TIMEOUT_MS;
|
|
792
|
+
this.publishTimeoutMs = config.publishTimeoutMs ?? DEFAULT_PUBLISH_TIMEOUT_MS;
|
|
793
|
+
this.connectivityTimeoutMs = config.connectivityTimeoutMs ?? DEFAULT_CONNECTIVITY_TIMEOUT_MS;
|
|
794
|
+
this.debug = config.debug ?? false;
|
|
795
|
+
this.cache = cache;
|
|
796
|
+
}
|
|
797
|
+
// ---------------------------------------------------------------------------
|
|
798
|
+
// Public Accessors
|
|
799
|
+
// ---------------------------------------------------------------------------
|
|
800
|
+
/**
|
|
801
|
+
* Get configured gateway URLs.
|
|
802
|
+
*/
|
|
803
|
+
getGateways() {
|
|
804
|
+
return [...this.gateways];
|
|
805
|
+
}
|
|
806
|
+
// ---------------------------------------------------------------------------
|
|
807
|
+
// Gateway Health
|
|
808
|
+
// ---------------------------------------------------------------------------
|
|
809
|
+
/**
|
|
810
|
+
* Test connectivity to a single gateway.
|
|
811
|
+
*/
|
|
812
|
+
async testConnectivity(gateway) {
|
|
813
|
+
const start = Date.now();
|
|
697
814
|
try {
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
}
|
|
705
|
-
};
|
|
706
|
-
const cid = await this.publishToGateways(dataToSave);
|
|
707
|
-
if (this.config.enableIpns && this.ipnsName) {
|
|
708
|
-
await this.publishIpns(cid);
|
|
815
|
+
const response = await this.fetchWithTimeout(
|
|
816
|
+
`${gateway}/api/v0/version`,
|
|
817
|
+
this.connectivityTimeoutMs,
|
|
818
|
+
{ method: "POST" }
|
|
819
|
+
);
|
|
820
|
+
if (!response.ok) {
|
|
821
|
+
return { gateway, healthy: false, error: `HTTP ${response.status}` };
|
|
709
822
|
}
|
|
710
|
-
this.localCache = dataToSave;
|
|
711
|
-
this.cacheTimestamp = Date.now();
|
|
712
|
-
this.lastCid = cid;
|
|
713
|
-
this.emitEvent({ type: "storage:saved", timestamp: Date.now(), data: { cid } });
|
|
714
823
|
return {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
824
|
+
gateway,
|
|
825
|
+
healthy: true,
|
|
826
|
+
responseTimeMs: Date.now() - start
|
|
718
827
|
};
|
|
719
828
|
} catch (error) {
|
|
720
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
721
|
-
this.emitEvent({ type: "storage:error", timestamp: Date.now(), error: errorMsg });
|
|
722
829
|
return {
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
830
|
+
gateway,
|
|
831
|
+
healthy: false,
|
|
832
|
+
error: error instanceof Error ? error.message : String(error)
|
|
726
833
|
};
|
|
727
834
|
}
|
|
728
835
|
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
836
|
+
/**
|
|
837
|
+
* Find healthy gateways from the configured list.
|
|
838
|
+
*/
|
|
839
|
+
async findHealthyGateways() {
|
|
840
|
+
const results = await Promise.allSettled(
|
|
841
|
+
this.gateways.map((gw) => this.testConnectivity(gw))
|
|
842
|
+
);
|
|
843
|
+
return results.filter((r) => r.status === "fulfilled" && r.value.healthy).map((r) => r.value.gateway);
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Get gateways that are not in circuit breaker cooldown.
|
|
847
|
+
*/
|
|
848
|
+
getAvailableGateways() {
|
|
849
|
+
return this.gateways.filter((gw) => !this.cache.isGatewayInCooldown(gw));
|
|
850
|
+
}
|
|
851
|
+
// ---------------------------------------------------------------------------
|
|
852
|
+
// Content Upload
|
|
853
|
+
// ---------------------------------------------------------------------------
|
|
854
|
+
/**
|
|
855
|
+
* Upload JSON content to IPFS.
|
|
856
|
+
* Tries all gateways in parallel, returns first success.
|
|
857
|
+
*/
|
|
858
|
+
async upload(data, gateways) {
|
|
859
|
+
const targets = gateways ?? this.getAvailableGateways();
|
|
860
|
+
if (targets.length === 0) {
|
|
861
|
+
throw new IpfsError("No gateways available for upload", "NETWORK_ERROR");
|
|
862
|
+
}
|
|
863
|
+
const jsonBytes = new TextEncoder().encode(JSON.stringify(data));
|
|
864
|
+
const promises = targets.map(async (gateway) => {
|
|
865
|
+
try {
|
|
866
|
+
const formData = new FormData();
|
|
867
|
+
formData.append("file", new Blob([jsonBytes], { type: "application/json" }), "data.json");
|
|
868
|
+
const response = await this.fetchWithTimeout(
|
|
869
|
+
`${gateway}/api/v0/add?pin=true&cid-version=1`,
|
|
870
|
+
this.publishTimeoutMs,
|
|
871
|
+
{ method: "POST", body: formData }
|
|
872
|
+
);
|
|
873
|
+
if (!response.ok) {
|
|
874
|
+
throw new IpfsError(
|
|
875
|
+
`Upload failed: HTTP ${response.status}`,
|
|
876
|
+
classifyHttpStatus(response.status),
|
|
877
|
+
gateway
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
const result = await response.json();
|
|
881
|
+
this.cache.recordGatewaySuccess(gateway);
|
|
882
|
+
this.log(`Uploaded to ${gateway}: CID=${result.Hash}`);
|
|
883
|
+
return { cid: result.Hash, gateway };
|
|
884
|
+
} catch (error) {
|
|
885
|
+
if (error instanceof IpfsError && error.shouldTriggerCircuitBreaker) {
|
|
886
|
+
this.cache.recordGatewayFailure(gateway);
|
|
887
|
+
}
|
|
888
|
+
throw error;
|
|
742
889
|
}
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
890
|
+
});
|
|
891
|
+
try {
|
|
892
|
+
const result = await Promise.any(promises);
|
|
893
|
+
return { cid: result.cid };
|
|
894
|
+
} catch (error) {
|
|
895
|
+
if (error instanceof AggregateError) {
|
|
896
|
+
throw new IpfsError(
|
|
897
|
+
`Upload failed on all gateways: ${error.errors.map((e) => e.message).join("; ")}`,
|
|
898
|
+
"NETWORK_ERROR"
|
|
899
|
+
);
|
|
746
900
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
901
|
+
throw error;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
// ---------------------------------------------------------------------------
|
|
905
|
+
// Content Fetch
|
|
906
|
+
// ---------------------------------------------------------------------------
|
|
907
|
+
/**
|
|
908
|
+
* Fetch content by CID from IPFS gateways.
|
|
909
|
+
* Checks content cache first. Races all gateways for fastest response.
|
|
910
|
+
*/
|
|
911
|
+
async fetchContent(cid, gateways) {
|
|
912
|
+
const cached = this.cache.getContent(cid);
|
|
913
|
+
if (cached) {
|
|
914
|
+
this.log(`Content cache hit for CID=${cid}`);
|
|
915
|
+
return cached;
|
|
916
|
+
}
|
|
917
|
+
const targets = gateways ?? this.getAvailableGateways();
|
|
918
|
+
if (targets.length === 0) {
|
|
919
|
+
throw new IpfsError("No gateways available for fetch", "NETWORK_ERROR");
|
|
920
|
+
}
|
|
921
|
+
const promises = targets.map(async (gateway) => {
|
|
922
|
+
try {
|
|
923
|
+
const response = await this.fetchWithTimeout(
|
|
924
|
+
`${gateway}/ipfs/${cid}`,
|
|
925
|
+
this.fetchTimeoutMs,
|
|
926
|
+
{ headers: { Accept: "application/octet-stream" } }
|
|
927
|
+
);
|
|
928
|
+
if (!response.ok) {
|
|
929
|
+
const body = await response.text().catch(() => "");
|
|
930
|
+
throw new IpfsError(
|
|
931
|
+
`Fetch failed: HTTP ${response.status}`,
|
|
932
|
+
classifyHttpStatus(response.status, body),
|
|
933
|
+
gateway
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
const data = await response.json();
|
|
937
|
+
this.cache.recordGatewaySuccess(gateway);
|
|
938
|
+
this.cache.setContent(cid, data);
|
|
939
|
+
this.log(`Fetched from ${gateway}: CID=${cid}`);
|
|
940
|
+
return data;
|
|
941
|
+
} catch (error) {
|
|
942
|
+
if (error instanceof IpfsError && error.shouldTriggerCircuitBreaker) {
|
|
943
|
+
this.cache.recordGatewayFailure(gateway);
|
|
944
|
+
}
|
|
945
|
+
throw error;
|
|
754
946
|
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
this.lastCid = cid;
|
|
759
|
-
this.emitEvent({ type: "storage:loaded", timestamp: Date.now() });
|
|
760
|
-
return {
|
|
761
|
-
success: true,
|
|
762
|
-
data,
|
|
763
|
-
source: "remote",
|
|
764
|
-
timestamp: Date.now()
|
|
765
|
-
};
|
|
947
|
+
});
|
|
948
|
+
try {
|
|
949
|
+
return await Promise.any(promises);
|
|
766
950
|
} catch (error) {
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
data: this.localCache,
|
|
773
|
-
source: "cache",
|
|
774
|
-
timestamp: Date.now()
|
|
775
|
-
};
|
|
951
|
+
if (error instanceof AggregateError) {
|
|
952
|
+
throw new IpfsError(
|
|
953
|
+
`Fetch failed on all gateways for CID=${cid}`,
|
|
954
|
+
"NETWORK_ERROR"
|
|
955
|
+
);
|
|
776
956
|
}
|
|
777
|
-
|
|
778
|
-
success: false,
|
|
779
|
-
error: errorMsg,
|
|
780
|
-
source: "remote",
|
|
781
|
-
timestamp: Date.now()
|
|
782
|
-
};
|
|
957
|
+
throw error;
|
|
783
958
|
}
|
|
784
959
|
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
960
|
+
// ---------------------------------------------------------------------------
|
|
961
|
+
// IPNS Resolution
|
|
962
|
+
// ---------------------------------------------------------------------------
|
|
963
|
+
/**
|
|
964
|
+
* Resolve IPNS via Routing API (returns record with sequence number).
|
|
965
|
+
* POST /api/v0/routing/get?arg=/ipns/{name}
|
|
966
|
+
*/
|
|
967
|
+
async resolveIpnsViaRoutingApi(gateway, ipnsName, timeoutMs = DEFAULT_ROUTING_API_TIMEOUT_MS) {
|
|
788
968
|
try {
|
|
789
|
-
const
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
conflicts: 0
|
|
800
|
-
};
|
|
969
|
+
const response = await this.fetchWithTimeout(
|
|
970
|
+
`${gateway}/api/v0/routing/get?arg=/ipns/${ipnsName}`,
|
|
971
|
+
timeoutMs,
|
|
972
|
+
{ method: "POST" }
|
|
973
|
+
);
|
|
974
|
+
if (!response.ok) {
|
|
975
|
+
const body = await response.text().catch(() => "");
|
|
976
|
+
const category = classifyHttpStatus(response.status, body);
|
|
977
|
+
if (category === "NOT_FOUND") return null;
|
|
978
|
+
throw new IpfsError(`Routing API: HTTP ${response.status}`, category, gateway);
|
|
801
979
|
}
|
|
802
|
-
const
|
|
803
|
-
await
|
|
804
|
-
|
|
980
|
+
const text = await response.text();
|
|
981
|
+
const parsed = await parseRoutingApiResponse(text);
|
|
982
|
+
if (!parsed) return null;
|
|
983
|
+
this.cache.recordGatewaySuccess(gateway);
|
|
805
984
|
return {
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
conflicts: mergeResult.conflicts
|
|
985
|
+
cid: parsed.cid,
|
|
986
|
+
sequence: parsed.sequence,
|
|
987
|
+
gateway,
|
|
988
|
+
recordData: parsed.recordData
|
|
811
989
|
};
|
|
812
990
|
} catch (error) {
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
conflicts: 0,
|
|
820
|
-
error: errorMsg
|
|
821
|
-
};
|
|
991
|
+
if (error instanceof IpfsError) throw error;
|
|
992
|
+
const category = classifyFetchError(error);
|
|
993
|
+
if (category !== "NOT_FOUND") {
|
|
994
|
+
this.cache.recordGatewayFailure(gateway);
|
|
995
|
+
}
|
|
996
|
+
return null;
|
|
822
997
|
}
|
|
823
998
|
}
|
|
824
|
-
|
|
825
|
-
|
|
999
|
+
/**
|
|
1000
|
+
* Resolve IPNS via gateway path (simpler, no sequence number).
|
|
1001
|
+
* GET /ipns/{name}?format=dag-json
|
|
1002
|
+
*/
|
|
1003
|
+
async resolveIpnsViaGatewayPath(gateway, ipnsName, timeoutMs = DEFAULT_GATEWAY_PATH_TIMEOUT_MS) {
|
|
826
1004
|
try {
|
|
827
|
-
const
|
|
828
|
-
|
|
1005
|
+
const response = await this.fetchWithTimeout(
|
|
1006
|
+
`${gateway}/ipns/${ipnsName}`,
|
|
1007
|
+
timeoutMs,
|
|
1008
|
+
{ headers: { Accept: "application/json" } }
|
|
1009
|
+
);
|
|
1010
|
+
if (!response.ok) return null;
|
|
1011
|
+
const content = await response.json();
|
|
1012
|
+
const cidHeader = response.headers.get("X-Ipfs-Path");
|
|
1013
|
+
if (cidHeader) {
|
|
1014
|
+
const match = cidHeader.match(/\/ipfs\/([a-zA-Z0-9]+)/);
|
|
1015
|
+
if (match) {
|
|
1016
|
+
this.cache.recordGatewaySuccess(gateway);
|
|
1017
|
+
return { cid: match[1], content };
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return { cid: "", content };
|
|
829
1021
|
} catch {
|
|
830
|
-
return
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
async clear() {
|
|
834
|
-
const emptyData = {
|
|
835
|
-
_meta: {
|
|
836
|
-
version: 0,
|
|
837
|
-
address: this.identity?.l1Address ?? "",
|
|
838
|
-
formatVersion: "2.0",
|
|
839
|
-
updatedAt: Date.now()
|
|
840
|
-
},
|
|
841
|
-
_tombstones: []
|
|
842
|
-
};
|
|
843
|
-
const result = await this.save(emptyData);
|
|
844
|
-
return result.success;
|
|
845
|
-
}
|
|
846
|
-
onEvent(callback) {
|
|
847
|
-
this.eventCallbacks.add(callback);
|
|
848
|
-
return () => this.eventCallbacks.delete(callback);
|
|
849
|
-
}
|
|
850
|
-
// ===========================================================================
|
|
851
|
-
// Private: IPFS Operations
|
|
852
|
-
// ===========================================================================
|
|
853
|
-
async testGatewayConnectivity() {
|
|
854
|
-
const gateway = this.config.gateways[0];
|
|
855
|
-
const controller = new AbortController();
|
|
856
|
-
const timeout = setTimeout(() => controller.abort(), 5e3);
|
|
857
|
-
try {
|
|
858
|
-
const response = await fetch(`${gateway}/api/v0/version`, {
|
|
859
|
-
method: "POST",
|
|
860
|
-
signal: controller.signal
|
|
861
|
-
});
|
|
862
|
-
if (!response.ok) throw new Error("Gateway not responding");
|
|
863
|
-
} finally {
|
|
864
|
-
clearTimeout(timeout);
|
|
1022
|
+
return null;
|
|
865
1023
|
}
|
|
866
1024
|
}
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
1025
|
+
/**
|
|
1026
|
+
* Progressive IPNS resolution across all gateways.
|
|
1027
|
+
* Queries all gateways in parallel, returns highest sequence number.
|
|
1028
|
+
*/
|
|
1029
|
+
async resolveIpns(ipnsName, gateways) {
|
|
1030
|
+
const targets = gateways ?? this.getAvailableGateways();
|
|
1031
|
+
if (targets.length === 0) {
|
|
1032
|
+
return { best: null, allResults: [], respondedCount: 0, totalGateways: 0 };
|
|
873
1033
|
}
|
|
874
|
-
|
|
875
|
-
|
|
1034
|
+
const results = [];
|
|
1035
|
+
let respondedCount = 0;
|
|
1036
|
+
const promises = targets.map(async (gateway) => {
|
|
1037
|
+
const result = await this.resolveIpnsViaRoutingApi(
|
|
1038
|
+
gateway,
|
|
1039
|
+
ipnsName,
|
|
1040
|
+
this.resolveTimeoutMs
|
|
1041
|
+
);
|
|
1042
|
+
if (result) results.push(result);
|
|
1043
|
+
respondedCount++;
|
|
1044
|
+
return result;
|
|
1045
|
+
});
|
|
1046
|
+
await Promise.race([
|
|
1047
|
+
Promise.allSettled(promises),
|
|
1048
|
+
new Promise((resolve) => setTimeout(resolve, this.resolveTimeoutMs + 1e3))
|
|
1049
|
+
]);
|
|
1050
|
+
let best = null;
|
|
1051
|
+
for (const result of results) {
|
|
1052
|
+
if (!best || result.sequence > best.sequence) {
|
|
1053
|
+
best = result;
|
|
1054
|
+
}
|
|
876
1055
|
}
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
this.log("Published to IPFS, CID:", cid);
|
|
880
|
-
return cid;
|
|
881
|
-
} catch {
|
|
882
|
-
throw new Error("All publish attempts failed");
|
|
1056
|
+
if (best) {
|
|
1057
|
+
this.cache.setIpnsRecord(ipnsName, best);
|
|
883
1058
|
}
|
|
1059
|
+
return {
|
|
1060
|
+
best,
|
|
1061
|
+
allResults: results,
|
|
1062
|
+
respondedCount,
|
|
1063
|
+
totalGateways: targets.length
|
|
1064
|
+
};
|
|
884
1065
|
}
|
|
1066
|
+
// ---------------------------------------------------------------------------
|
|
1067
|
+
// IPNS Publishing
|
|
1068
|
+
// ---------------------------------------------------------------------------
|
|
885
1069
|
/**
|
|
886
|
-
* Publish
|
|
1070
|
+
* Publish IPNS record to a single gateway via routing API.
|
|
887
1071
|
*/
|
|
888
|
-
async
|
|
889
|
-
if (!this.heliaJson) {
|
|
890
|
-
throw new Error("Helia not initialized");
|
|
891
|
-
}
|
|
892
|
-
const cid = await this.heliaJson.add(data);
|
|
893
|
-
this.log("Published via Helia, CID:", cid.toString());
|
|
894
|
-
return cid.toString();
|
|
895
|
-
}
|
|
896
|
-
async publishToGateway(gateway, blob) {
|
|
897
|
-
const formData = new FormData();
|
|
898
|
-
formData.append("file", blob);
|
|
899
|
-
const controller = new AbortController();
|
|
900
|
-
const timeout = setTimeout(() => controller.abort(), this.config.fetchTimeout);
|
|
1072
|
+
async publishIpnsViaRoutingApi(gateway, ipnsName, marshalledRecord, timeoutMs = DEFAULT_PUBLISH_TIMEOUT_MS) {
|
|
901
1073
|
try {
|
|
902
|
-
const
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1074
|
+
const formData = new FormData();
|
|
1075
|
+
formData.append(
|
|
1076
|
+
"file",
|
|
1077
|
+
new Blob([new Uint8Array(marshalledRecord)]),
|
|
1078
|
+
"record"
|
|
1079
|
+
);
|
|
1080
|
+
const response = await this.fetchWithTimeout(
|
|
1081
|
+
`${gateway}/api/v0/routing/put?arg=/ipns/${ipnsName}&allow-offline=true`,
|
|
1082
|
+
timeoutMs,
|
|
1083
|
+
{ method: "POST", body: formData }
|
|
1084
|
+
);
|
|
907
1085
|
if (!response.ok) {
|
|
908
|
-
|
|
1086
|
+
const errorText = await response.text().catch(() => "");
|
|
1087
|
+
throw new IpfsError(
|
|
1088
|
+
`IPNS publish: HTTP ${response.status}: ${errorText.slice(0, 100)}`,
|
|
1089
|
+
classifyHttpStatus(response.status, errorText),
|
|
1090
|
+
gateway
|
|
1091
|
+
);
|
|
909
1092
|
}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
);
|
|
921
|
-
await Promise.allSettled(promises);
|
|
922
|
-
this.log("Published IPNS:", this.ipnsName, "->", cid);
|
|
1093
|
+
this.cache.recordGatewaySuccess(gateway);
|
|
1094
|
+
this.log(`IPNS published to ${gateway}: ${ipnsName}`);
|
|
1095
|
+
return true;
|
|
1096
|
+
} catch (error) {
|
|
1097
|
+
if (error instanceof IpfsError && error.shouldTriggerCircuitBreaker) {
|
|
1098
|
+
this.cache.recordGatewayFailure(gateway);
|
|
1099
|
+
}
|
|
1100
|
+
this.log(`IPNS publish to ${gateway} failed: ${error}`);
|
|
1101
|
+
return false;
|
|
1102
|
+
}
|
|
923
1103
|
}
|
|
924
|
-
|
|
1104
|
+
/**
|
|
1105
|
+
* Publish IPNS record to all gateways in parallel.
|
|
1106
|
+
*/
|
|
1107
|
+
async publishIpns(ipnsName, marshalledRecord, gateways) {
|
|
1108
|
+
const targets = gateways ?? this.getAvailableGateways();
|
|
1109
|
+
if (targets.length === 0) {
|
|
1110
|
+
return { success: false, error: "No gateways available" };
|
|
1111
|
+
}
|
|
1112
|
+
const results = await Promise.allSettled(
|
|
1113
|
+
targets.map((gw) => this.publishIpnsViaRoutingApi(gw, ipnsName, marshalledRecord, this.publishTimeoutMs))
|
|
1114
|
+
);
|
|
1115
|
+
const successfulGateways = [];
|
|
1116
|
+
results.forEach((result, index) => {
|
|
1117
|
+
if (result.status === "fulfilled" && result.value) {
|
|
1118
|
+
successfulGateways.push(targets[index]);
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
return {
|
|
1122
|
+
success: successfulGateways.length > 0,
|
|
1123
|
+
ipnsName,
|
|
1124
|
+
successfulGateways,
|
|
1125
|
+
error: successfulGateways.length === 0 ? "All gateways failed" : void 0
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
// ---------------------------------------------------------------------------
|
|
1129
|
+
// IPNS Verification
|
|
1130
|
+
// ---------------------------------------------------------------------------
|
|
1131
|
+
/**
|
|
1132
|
+
* Verify IPNS record persistence after publishing.
|
|
1133
|
+
* Retries resolution to confirm the record was accepted.
|
|
1134
|
+
*/
|
|
1135
|
+
async verifyIpnsRecord(ipnsName, expectedSeq, expectedCid, retries = 3, delayMs = 1e3) {
|
|
1136
|
+
for (let i = 0; i < retries; i++) {
|
|
1137
|
+
if (i > 0) {
|
|
1138
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1139
|
+
}
|
|
1140
|
+
const { best } = await this.resolveIpns(ipnsName);
|
|
1141
|
+
if (best && best.sequence >= expectedSeq && best.cid === expectedCid) {
|
|
1142
|
+
return true;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return false;
|
|
1146
|
+
}
|
|
1147
|
+
// ---------------------------------------------------------------------------
|
|
1148
|
+
// Helpers
|
|
1149
|
+
// ---------------------------------------------------------------------------
|
|
1150
|
+
async fetchWithTimeout(url, timeoutMs, options) {
|
|
925
1151
|
const controller = new AbortController();
|
|
926
|
-
const
|
|
1152
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
927
1153
|
try {
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
1154
|
+
return await fetch(url, {
|
|
1155
|
+
...options,
|
|
1156
|
+
signal: controller.signal
|
|
1157
|
+
});
|
|
1158
|
+
} finally {
|
|
1159
|
+
clearTimeout(timer);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
log(message) {
|
|
1163
|
+
if (this.debug) {
|
|
1164
|
+
console.log(`[IPFS-HTTP] ${message}`);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
};
|
|
1168
|
+
|
|
1169
|
+
// impl/shared/ipfs/txf-merge.ts
|
|
1170
|
+
function mergeTxfData(local, remote) {
|
|
1171
|
+
let added = 0;
|
|
1172
|
+
let removed = 0;
|
|
1173
|
+
let conflicts = 0;
|
|
1174
|
+
const localVersion = local._meta?.version ?? 0;
|
|
1175
|
+
const remoteVersion = remote._meta?.version ?? 0;
|
|
1176
|
+
const baseMeta = localVersion >= remoteVersion ? local._meta : remote._meta;
|
|
1177
|
+
const mergedMeta = {
|
|
1178
|
+
...baseMeta,
|
|
1179
|
+
version: Math.max(localVersion, remoteVersion) + 1,
|
|
1180
|
+
updatedAt: Date.now()
|
|
1181
|
+
};
|
|
1182
|
+
const mergedTombstones = mergeTombstones(
|
|
1183
|
+
local._tombstones ?? [],
|
|
1184
|
+
remote._tombstones ?? []
|
|
1185
|
+
);
|
|
1186
|
+
const tombstoneKeys = new Set(
|
|
1187
|
+
mergedTombstones.map((t) => `${t.tokenId}:${t.stateHash}`)
|
|
1188
|
+
);
|
|
1189
|
+
const localTokenKeys = getTokenKeys(local);
|
|
1190
|
+
const remoteTokenKeys = getTokenKeys(remote);
|
|
1191
|
+
const allTokenKeys = /* @__PURE__ */ new Set([...localTokenKeys, ...remoteTokenKeys]);
|
|
1192
|
+
const mergedTokens = {};
|
|
1193
|
+
for (const key of allTokenKeys) {
|
|
1194
|
+
const tokenId = key.startsWith("_") ? key.slice(1) : key;
|
|
1195
|
+
const localToken = local[key];
|
|
1196
|
+
const remoteToken = remote[key];
|
|
1197
|
+
if (isTokenTombstoned(tokenId, localToken, remoteToken, tombstoneKeys)) {
|
|
1198
|
+
if (localTokenKeys.has(key)) removed++;
|
|
1199
|
+
continue;
|
|
1200
|
+
}
|
|
1201
|
+
if (localToken && !remoteToken) {
|
|
1202
|
+
mergedTokens[key] = localToken;
|
|
1203
|
+
} else if (!localToken && remoteToken) {
|
|
1204
|
+
mergedTokens[key] = remoteToken;
|
|
1205
|
+
added++;
|
|
1206
|
+
} else if (localToken && remoteToken) {
|
|
1207
|
+
mergedTokens[key] = localToken;
|
|
1208
|
+
conflicts++;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
const mergedOutbox = mergeArrayById(
|
|
1212
|
+
local._outbox ?? [],
|
|
1213
|
+
remote._outbox ?? [],
|
|
1214
|
+
"id"
|
|
1215
|
+
);
|
|
1216
|
+
const mergedSent = mergeArrayById(
|
|
1217
|
+
local._sent ?? [],
|
|
1218
|
+
remote._sent ?? [],
|
|
1219
|
+
"tokenId"
|
|
1220
|
+
);
|
|
1221
|
+
const mergedInvalid = mergeArrayById(
|
|
1222
|
+
local._invalid ?? [],
|
|
1223
|
+
remote._invalid ?? [],
|
|
1224
|
+
"tokenId"
|
|
1225
|
+
);
|
|
1226
|
+
const merged = {
|
|
1227
|
+
_meta: mergedMeta,
|
|
1228
|
+
_tombstones: mergedTombstones.length > 0 ? mergedTombstones : void 0,
|
|
1229
|
+
_outbox: mergedOutbox.length > 0 ? mergedOutbox : void 0,
|
|
1230
|
+
_sent: mergedSent.length > 0 ? mergedSent : void 0,
|
|
1231
|
+
_invalid: mergedInvalid.length > 0 ? mergedInvalid : void 0,
|
|
1232
|
+
...mergedTokens
|
|
1233
|
+
};
|
|
1234
|
+
return { merged, added, removed, conflicts };
|
|
1235
|
+
}
|
|
1236
|
+
function mergeTombstones(local, remote) {
|
|
1237
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1238
|
+
for (const tombstone of [...local, ...remote]) {
|
|
1239
|
+
const key = `${tombstone.tokenId}:${tombstone.stateHash}`;
|
|
1240
|
+
const existing = merged.get(key);
|
|
1241
|
+
if (!existing || tombstone.timestamp > existing.timestamp) {
|
|
1242
|
+
merged.set(key, tombstone);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
return Array.from(merged.values());
|
|
1246
|
+
}
|
|
1247
|
+
function getTokenKeys(data) {
|
|
1248
|
+
const reservedKeys = /* @__PURE__ */ new Set([
|
|
1249
|
+
"_meta",
|
|
1250
|
+
"_tombstones",
|
|
1251
|
+
"_outbox",
|
|
1252
|
+
"_sent",
|
|
1253
|
+
"_invalid",
|
|
1254
|
+
"_nametag",
|
|
1255
|
+
"_mintOutbox",
|
|
1256
|
+
"_invalidatedNametags",
|
|
1257
|
+
"_integrity"
|
|
1258
|
+
]);
|
|
1259
|
+
const keys = /* @__PURE__ */ new Set();
|
|
1260
|
+
for (const key of Object.keys(data)) {
|
|
1261
|
+
if (reservedKeys.has(key)) continue;
|
|
1262
|
+
if (key.startsWith("archived-") || key.startsWith("_forked_") || key.startsWith("nametag-")) continue;
|
|
1263
|
+
keys.add(key);
|
|
1264
|
+
}
|
|
1265
|
+
return keys;
|
|
1266
|
+
}
|
|
1267
|
+
function isTokenTombstoned(tokenId, localToken, remoteToken, tombstoneKeys) {
|
|
1268
|
+
for (const key of tombstoneKeys) {
|
|
1269
|
+
if (key.startsWith(`${tokenId}:`)) {
|
|
1270
|
+
return true;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
void localToken;
|
|
1274
|
+
void remoteToken;
|
|
1275
|
+
return false;
|
|
1276
|
+
}
|
|
1277
|
+
function mergeArrayById(local, remote, idField) {
|
|
1278
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1279
|
+
for (const item of local) {
|
|
1280
|
+
const id = item[idField];
|
|
1281
|
+
if (id !== void 0) {
|
|
1282
|
+
seen.set(id, item);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
for (const item of remote) {
|
|
1286
|
+
const id = item[idField];
|
|
1287
|
+
if (id !== void 0 && !seen.has(id)) {
|
|
1288
|
+
seen.set(id, item);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
return Array.from(seen.values());
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// transport/websocket.ts
|
|
1295
|
+
var WebSocketReadyState = {
|
|
1296
|
+
CONNECTING: 0,
|
|
1297
|
+
OPEN: 1,
|
|
1298
|
+
CLOSING: 2,
|
|
1299
|
+
CLOSED: 3
|
|
1300
|
+
};
|
|
1301
|
+
|
|
1302
|
+
// impl/shared/ipfs/ipns-subscription-client.ts
|
|
1303
|
+
var IpnsSubscriptionClient = class {
|
|
1304
|
+
ws = null;
|
|
1305
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
1306
|
+
reconnectTimeout = null;
|
|
1307
|
+
pingInterval = null;
|
|
1308
|
+
fallbackPollInterval = null;
|
|
1309
|
+
wsUrl;
|
|
1310
|
+
createWebSocket;
|
|
1311
|
+
pingIntervalMs;
|
|
1312
|
+
initialReconnectDelayMs;
|
|
1313
|
+
maxReconnectDelayMs;
|
|
1314
|
+
debugEnabled;
|
|
1315
|
+
reconnectAttempts = 0;
|
|
1316
|
+
isConnecting = false;
|
|
1317
|
+
connectionOpenedAt = 0;
|
|
1318
|
+
destroyed = false;
|
|
1319
|
+
/** Minimum stable connection time before resetting backoff (30 seconds) */
|
|
1320
|
+
minStableConnectionMs = 3e4;
|
|
1321
|
+
fallbackPollFn = null;
|
|
1322
|
+
fallbackPollIntervalMs = 0;
|
|
1323
|
+
constructor(config) {
|
|
1324
|
+
this.wsUrl = config.wsUrl;
|
|
1325
|
+
this.createWebSocket = config.createWebSocket;
|
|
1326
|
+
this.pingIntervalMs = config.pingIntervalMs ?? 3e4;
|
|
1327
|
+
this.initialReconnectDelayMs = config.reconnectDelayMs ?? 5e3;
|
|
1328
|
+
this.maxReconnectDelayMs = config.maxReconnectDelayMs ?? 6e4;
|
|
1329
|
+
this.debugEnabled = config.debug ?? false;
|
|
1330
|
+
}
|
|
1331
|
+
// ---------------------------------------------------------------------------
|
|
1332
|
+
// Public API
|
|
1333
|
+
// ---------------------------------------------------------------------------
|
|
1334
|
+
/**
|
|
1335
|
+
* Subscribe to IPNS updates for a specific name.
|
|
1336
|
+
* Automatically connects the WebSocket if not already connected.
|
|
1337
|
+
* If WebSocket is connecting, the name will be subscribed once connected.
|
|
1338
|
+
*/
|
|
1339
|
+
subscribe(ipnsName, callback) {
|
|
1340
|
+
if (!ipnsName || typeof ipnsName !== "string") {
|
|
1341
|
+
this.log("Invalid IPNS name for subscription");
|
|
1342
|
+
return () => {
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
const isNewSubscription = !this.subscriptions.has(ipnsName);
|
|
1346
|
+
if (isNewSubscription) {
|
|
1347
|
+
this.subscriptions.set(ipnsName, /* @__PURE__ */ new Set());
|
|
1348
|
+
}
|
|
1349
|
+
this.subscriptions.get(ipnsName).add(callback);
|
|
1350
|
+
if (isNewSubscription && this.ws?.readyState === WebSocketReadyState.OPEN) {
|
|
1351
|
+
this.sendSubscribe([ipnsName]);
|
|
1352
|
+
}
|
|
1353
|
+
if (!this.ws || this.ws.readyState !== WebSocketReadyState.OPEN) {
|
|
1354
|
+
this.connect();
|
|
1355
|
+
}
|
|
1356
|
+
return () => {
|
|
1357
|
+
const callbacks = this.subscriptions.get(ipnsName);
|
|
1358
|
+
if (callbacks) {
|
|
1359
|
+
callbacks.delete(callback);
|
|
1360
|
+
if (callbacks.size === 0) {
|
|
1361
|
+
this.subscriptions.delete(ipnsName);
|
|
1362
|
+
if (this.ws?.readyState === WebSocketReadyState.OPEN) {
|
|
1363
|
+
this.sendUnsubscribe([ipnsName]);
|
|
1364
|
+
}
|
|
1365
|
+
if (this.subscriptions.size === 0) {
|
|
1366
|
+
this.disconnect();
|
|
1367
|
+
}
|
|
933
1368
|
}
|
|
934
|
-
);
|
|
935
|
-
if (!response.ok) {
|
|
936
|
-
throw new Error(`IPNS publish failed: ${response.status}`);
|
|
937
1369
|
}
|
|
938
|
-
}
|
|
939
|
-
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Register a convenience update callback for all subscriptions.
|
|
1374
|
+
* Returns an unsubscribe function.
|
|
1375
|
+
*/
|
|
1376
|
+
onUpdate(callback) {
|
|
1377
|
+
if (!this.subscriptions.has("*")) {
|
|
1378
|
+
this.subscriptions.set("*", /* @__PURE__ */ new Set());
|
|
1379
|
+
}
|
|
1380
|
+
this.subscriptions.get("*").add(callback);
|
|
1381
|
+
return () => {
|
|
1382
|
+
const callbacks = this.subscriptions.get("*");
|
|
1383
|
+
if (callbacks) {
|
|
1384
|
+
callbacks.delete(callback);
|
|
1385
|
+
if (callbacks.size === 0) {
|
|
1386
|
+
this.subscriptions.delete("*");
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* Set a fallback poll function to use when WebSocket is disconnected.
|
|
1393
|
+
* The poll function will be called at the specified interval while WS is down.
|
|
1394
|
+
*/
|
|
1395
|
+
setFallbackPoll(fn, intervalMs) {
|
|
1396
|
+
this.fallbackPollFn = fn;
|
|
1397
|
+
this.fallbackPollIntervalMs = intervalMs;
|
|
1398
|
+
if (!this.isConnected()) {
|
|
1399
|
+
this.startFallbackPolling();
|
|
940
1400
|
}
|
|
941
1401
|
}
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1402
|
+
/**
|
|
1403
|
+
* Connect to the WebSocket server.
|
|
1404
|
+
*/
|
|
1405
|
+
connect() {
|
|
1406
|
+
if (this.destroyed) return;
|
|
1407
|
+
if (this.ws?.readyState === WebSocketReadyState.OPEN || this.isConnecting) {
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
this.isConnecting = true;
|
|
1411
|
+
try {
|
|
1412
|
+
this.log(`Connecting to ${this.wsUrl}...`);
|
|
1413
|
+
this.ws = this.createWebSocket(this.wsUrl);
|
|
1414
|
+
this.ws.onopen = () => {
|
|
1415
|
+
this.log("WebSocket connected");
|
|
1416
|
+
this.isConnecting = false;
|
|
1417
|
+
this.connectionOpenedAt = Date.now();
|
|
1418
|
+
const names = Array.from(this.subscriptions.keys()).filter((n) => n !== "*");
|
|
1419
|
+
if (names.length > 0) {
|
|
1420
|
+
this.sendSubscribe(names);
|
|
1421
|
+
}
|
|
1422
|
+
this.startPingInterval();
|
|
1423
|
+
this.stopFallbackPolling();
|
|
1424
|
+
};
|
|
1425
|
+
this.ws.onmessage = (event) => {
|
|
1426
|
+
this.handleMessage(event.data);
|
|
1427
|
+
};
|
|
1428
|
+
this.ws.onclose = () => {
|
|
1429
|
+
const connectionDuration = this.connectionOpenedAt > 0 ? Date.now() - this.connectionOpenedAt : 0;
|
|
1430
|
+
const wasStable = connectionDuration >= this.minStableConnectionMs;
|
|
1431
|
+
this.log(`WebSocket closed (duration: ${Math.round(connectionDuration / 1e3)}s)`);
|
|
1432
|
+
this.isConnecting = false;
|
|
1433
|
+
this.connectionOpenedAt = 0;
|
|
1434
|
+
this.stopPingInterval();
|
|
1435
|
+
if (wasStable) {
|
|
1436
|
+
this.reconnectAttempts = 0;
|
|
1437
|
+
}
|
|
1438
|
+
this.startFallbackPolling();
|
|
1439
|
+
this.scheduleReconnect();
|
|
1440
|
+
};
|
|
1441
|
+
this.ws.onerror = () => {
|
|
1442
|
+
this.log("WebSocket error");
|
|
1443
|
+
this.isConnecting = false;
|
|
1444
|
+
};
|
|
1445
|
+
} catch (e) {
|
|
1446
|
+
this.log(`Failed to connect: ${e}`);
|
|
1447
|
+
this.isConnecting = false;
|
|
1448
|
+
this.startFallbackPolling();
|
|
1449
|
+
this.scheduleReconnect();
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Disconnect from the WebSocket server and clean up.
|
|
1454
|
+
*/
|
|
1455
|
+
disconnect() {
|
|
1456
|
+
this.destroyed = true;
|
|
1457
|
+
if (this.reconnectTimeout) {
|
|
1458
|
+
clearTimeout(this.reconnectTimeout);
|
|
1459
|
+
this.reconnectTimeout = null;
|
|
1460
|
+
}
|
|
1461
|
+
this.stopPingInterval();
|
|
1462
|
+
this.stopFallbackPolling();
|
|
1463
|
+
if (this.ws) {
|
|
1464
|
+
this.ws.onopen = null;
|
|
1465
|
+
this.ws.onclose = null;
|
|
1466
|
+
this.ws.onerror = null;
|
|
1467
|
+
this.ws.onmessage = null;
|
|
1468
|
+
this.ws.close();
|
|
1469
|
+
this.ws = null;
|
|
1470
|
+
}
|
|
1471
|
+
this.isConnecting = false;
|
|
1472
|
+
this.reconnectAttempts = 0;
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* Check if connected to the WebSocket server.
|
|
1476
|
+
*/
|
|
1477
|
+
isConnected() {
|
|
1478
|
+
return this.ws?.readyState === WebSocketReadyState.OPEN;
|
|
1479
|
+
}
|
|
1480
|
+
// ---------------------------------------------------------------------------
|
|
1481
|
+
// Internal: Message Handling
|
|
1482
|
+
// ---------------------------------------------------------------------------
|
|
1483
|
+
handleMessage(data) {
|
|
1484
|
+
try {
|
|
1485
|
+
const message = JSON.parse(data);
|
|
1486
|
+
switch (message.type) {
|
|
1487
|
+
case "update":
|
|
1488
|
+
if (message.name && message.sequence !== void 0) {
|
|
1489
|
+
this.notifySubscribers({
|
|
1490
|
+
type: "update",
|
|
1491
|
+
name: message.name,
|
|
1492
|
+
sequence: message.sequence,
|
|
1493
|
+
cid: message.cid ?? "",
|
|
1494
|
+
timestamp: message.timestamp || (/* @__PURE__ */ new Date()).toISOString()
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
break;
|
|
1498
|
+
case "subscribed":
|
|
1499
|
+
this.log(`Subscribed to ${message.names?.length || 0} names`);
|
|
1500
|
+
break;
|
|
1501
|
+
case "unsubscribed":
|
|
1502
|
+
this.log(`Unsubscribed from ${message.names?.length || 0} names`);
|
|
1503
|
+
break;
|
|
1504
|
+
case "pong":
|
|
1505
|
+
break;
|
|
1506
|
+
case "error":
|
|
1507
|
+
this.log(`Server error: ${message.message}`);
|
|
1508
|
+
break;
|
|
1509
|
+
default:
|
|
1510
|
+
break;
|
|
948
1511
|
}
|
|
1512
|
+
} catch {
|
|
1513
|
+
this.log("Failed to parse message");
|
|
949
1514
|
}
|
|
950
|
-
return null;
|
|
951
1515
|
}
|
|
952
|
-
|
|
953
|
-
const
|
|
954
|
-
|
|
1516
|
+
notifySubscribers(update) {
|
|
1517
|
+
const callbacks = this.subscriptions.get(update.name);
|
|
1518
|
+
if (callbacks) {
|
|
1519
|
+
this.log(`Update: ${update.name.slice(0, 16)}... seq=${update.sequence}`);
|
|
1520
|
+
for (const callback of callbacks) {
|
|
1521
|
+
try {
|
|
1522
|
+
callback(update);
|
|
1523
|
+
} catch {
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
const globalCallbacks = this.subscriptions.get("*");
|
|
1528
|
+
if (globalCallbacks) {
|
|
1529
|
+
for (const callback of globalCallbacks) {
|
|
1530
|
+
try {
|
|
1531
|
+
callback(update);
|
|
1532
|
+
} catch {
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
// ---------------------------------------------------------------------------
|
|
1538
|
+
// Internal: WebSocket Send
|
|
1539
|
+
// ---------------------------------------------------------------------------
|
|
1540
|
+
sendSubscribe(names) {
|
|
1541
|
+
if (this.ws?.readyState === WebSocketReadyState.OPEN) {
|
|
1542
|
+
this.ws.send(JSON.stringify({ action: "subscribe", names }));
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
sendUnsubscribe(names) {
|
|
1546
|
+
if (this.ws?.readyState === WebSocketReadyState.OPEN) {
|
|
1547
|
+
this.ws.send(JSON.stringify({ action: "unsubscribe", names }));
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
// ---------------------------------------------------------------------------
|
|
1551
|
+
// Internal: Reconnection
|
|
1552
|
+
// ---------------------------------------------------------------------------
|
|
1553
|
+
/**
|
|
1554
|
+
* Schedule reconnection with exponential backoff.
|
|
1555
|
+
* Sequence: 5s, 10s, 20s, 40s, 60s (capped)
|
|
1556
|
+
*/
|
|
1557
|
+
scheduleReconnect() {
|
|
1558
|
+
if (this.destroyed || this.reconnectTimeout) return;
|
|
1559
|
+
const realSubscriptions = Array.from(this.subscriptions.keys()).filter((n) => n !== "*");
|
|
1560
|
+
if (realSubscriptions.length === 0) return;
|
|
1561
|
+
this.reconnectAttempts++;
|
|
1562
|
+
const delay = Math.min(
|
|
1563
|
+
this.initialReconnectDelayMs * Math.pow(2, this.reconnectAttempts - 1),
|
|
1564
|
+
this.maxReconnectDelayMs
|
|
1565
|
+
);
|
|
1566
|
+
this.log(`Reconnecting in ${(delay / 1e3).toFixed(1)}s (attempt ${this.reconnectAttempts})...`);
|
|
1567
|
+
this.reconnectTimeout = setTimeout(() => {
|
|
1568
|
+
this.reconnectTimeout = null;
|
|
1569
|
+
this.connect();
|
|
1570
|
+
}, delay);
|
|
1571
|
+
}
|
|
1572
|
+
// ---------------------------------------------------------------------------
|
|
1573
|
+
// Internal: Keepalive
|
|
1574
|
+
// ---------------------------------------------------------------------------
|
|
1575
|
+
startPingInterval() {
|
|
1576
|
+
this.stopPingInterval();
|
|
1577
|
+
this.pingInterval = setInterval(() => {
|
|
1578
|
+
if (this.ws?.readyState === WebSocketReadyState.OPEN) {
|
|
1579
|
+
this.ws.send(JSON.stringify({ action: "ping" }));
|
|
1580
|
+
}
|
|
1581
|
+
}, this.pingIntervalMs);
|
|
1582
|
+
}
|
|
1583
|
+
stopPingInterval() {
|
|
1584
|
+
if (this.pingInterval) {
|
|
1585
|
+
clearInterval(this.pingInterval);
|
|
1586
|
+
this.pingInterval = null;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
// ---------------------------------------------------------------------------
|
|
1590
|
+
// Internal: Fallback Polling
|
|
1591
|
+
// ---------------------------------------------------------------------------
|
|
1592
|
+
startFallbackPolling() {
|
|
1593
|
+
if (this.fallbackPollInterval || !this.fallbackPollFn || this.destroyed) return;
|
|
1594
|
+
this.log(`Starting fallback polling (${this.fallbackPollIntervalMs / 1e3}s interval)`);
|
|
1595
|
+
this.fallbackPollFn().catch(() => {
|
|
1596
|
+
});
|
|
1597
|
+
this.fallbackPollInterval = setInterval(() => {
|
|
1598
|
+
this.fallbackPollFn?.().catch(() => {
|
|
1599
|
+
});
|
|
1600
|
+
}, this.fallbackPollIntervalMs);
|
|
1601
|
+
}
|
|
1602
|
+
stopFallbackPolling() {
|
|
1603
|
+
if (this.fallbackPollInterval) {
|
|
1604
|
+
clearInterval(this.fallbackPollInterval);
|
|
1605
|
+
this.fallbackPollInterval = null;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
// ---------------------------------------------------------------------------
|
|
1609
|
+
// Internal: Logging
|
|
1610
|
+
// ---------------------------------------------------------------------------
|
|
1611
|
+
log(message) {
|
|
1612
|
+
if (this.debugEnabled) {
|
|
1613
|
+
console.log(`[IPNS-WS] ${message}`);
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
};
|
|
1617
|
+
|
|
1618
|
+
// impl/shared/ipfs/write-behind-buffer.ts
|
|
1619
|
+
var AsyncSerialQueue = class {
|
|
1620
|
+
tail = Promise.resolve();
|
|
1621
|
+
/** Enqueue an async operation. Returns when it completes. */
|
|
1622
|
+
enqueue(fn) {
|
|
1623
|
+
let resolve;
|
|
1624
|
+
let reject;
|
|
1625
|
+
const promise = new Promise((res, rej) => {
|
|
1626
|
+
resolve = res;
|
|
1627
|
+
reject = rej;
|
|
1628
|
+
});
|
|
1629
|
+
this.tail = this.tail.then(
|
|
1630
|
+
() => fn().then(resolve, reject),
|
|
1631
|
+
() => fn().then(resolve, reject)
|
|
1632
|
+
);
|
|
1633
|
+
return promise;
|
|
1634
|
+
}
|
|
1635
|
+
};
|
|
1636
|
+
var WriteBuffer = class {
|
|
1637
|
+
/** Full TXF data from save() calls — latest wins */
|
|
1638
|
+
txfData = null;
|
|
1639
|
+
/** Individual token mutations: key -> { op: 'save'|'delete', data? } */
|
|
1640
|
+
tokenMutations = /* @__PURE__ */ new Map();
|
|
1641
|
+
get isEmpty() {
|
|
1642
|
+
return this.txfData === null && this.tokenMutations.size === 0;
|
|
1643
|
+
}
|
|
1644
|
+
clear() {
|
|
1645
|
+
this.txfData = null;
|
|
1646
|
+
this.tokenMutations.clear();
|
|
1647
|
+
}
|
|
1648
|
+
/**
|
|
1649
|
+
* Merge another buffer's contents into this one (for rollback).
|
|
1650
|
+
* Existing (newer) mutations in `this` take precedence over `other`.
|
|
1651
|
+
*/
|
|
1652
|
+
mergeFrom(other) {
|
|
1653
|
+
if (other.txfData && !this.txfData) {
|
|
1654
|
+
this.txfData = other.txfData;
|
|
1655
|
+
}
|
|
1656
|
+
for (const [id, mutation] of other.tokenMutations) {
|
|
1657
|
+
if (!this.tokenMutations.has(id)) {
|
|
1658
|
+
this.tokenMutations.set(id, mutation);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
};
|
|
1663
|
+
|
|
1664
|
+
// constants.ts
|
|
1665
|
+
var STORAGE_KEYS_GLOBAL = {
|
|
1666
|
+
/** Encrypted BIP39 mnemonic */
|
|
1667
|
+
MNEMONIC: "mnemonic",
|
|
1668
|
+
/** Encrypted master private key */
|
|
1669
|
+
MASTER_KEY: "master_key",
|
|
1670
|
+
/** BIP32 chain code */
|
|
1671
|
+
CHAIN_CODE: "chain_code",
|
|
1672
|
+
/** HD derivation path (full path like m/44'/0'/0'/0/0) */
|
|
1673
|
+
DERIVATION_PATH: "derivation_path",
|
|
1674
|
+
/** Base derivation path (like m/44'/0'/0' without chain/index) */
|
|
1675
|
+
BASE_PATH: "base_path",
|
|
1676
|
+
/** Derivation mode: bip32, wif_hmac, legacy_hmac */
|
|
1677
|
+
DERIVATION_MODE: "derivation_mode",
|
|
1678
|
+
/** Wallet source: mnemonic, file, unknown */
|
|
1679
|
+
WALLET_SOURCE: "wallet_source",
|
|
1680
|
+
/** Wallet existence flag */
|
|
1681
|
+
WALLET_EXISTS: "wallet_exists",
|
|
1682
|
+
/** Current active address index */
|
|
1683
|
+
CURRENT_ADDRESS_INDEX: "current_address_index",
|
|
1684
|
+
/** Nametag cache per address (separate from tracked addresses registry) */
|
|
1685
|
+
ADDRESS_NAMETAGS: "address_nametags",
|
|
1686
|
+
/** Active addresses registry (JSON: TrackedAddressesStorage) */
|
|
1687
|
+
TRACKED_ADDRESSES: "tracked_addresses",
|
|
1688
|
+
/** Last processed Nostr wallet event timestamp (unix seconds), keyed per pubkey */
|
|
1689
|
+
LAST_WALLET_EVENT_TS: "last_wallet_event_ts"
|
|
1690
|
+
};
|
|
1691
|
+
var STORAGE_KEYS_ADDRESS = {
|
|
1692
|
+
/** Pending transfers for this address */
|
|
1693
|
+
PENDING_TRANSFERS: "pending_transfers",
|
|
1694
|
+
/** Transfer outbox for this address */
|
|
1695
|
+
OUTBOX: "outbox",
|
|
1696
|
+
/** Conversations for this address */
|
|
1697
|
+
CONVERSATIONS: "conversations",
|
|
1698
|
+
/** Messages for this address */
|
|
1699
|
+
MESSAGES: "messages",
|
|
1700
|
+
/** Transaction history for this address */
|
|
1701
|
+
TRANSACTION_HISTORY: "transaction_history",
|
|
1702
|
+
/** Pending V5 finalization tokens (unconfirmed instant split tokens) */
|
|
1703
|
+
PENDING_V5_TOKENS: "pending_v5_tokens"
|
|
1704
|
+
};
|
|
1705
|
+
var STORAGE_KEYS = {
|
|
1706
|
+
...STORAGE_KEYS_GLOBAL,
|
|
1707
|
+
...STORAGE_KEYS_ADDRESS
|
|
1708
|
+
};
|
|
1709
|
+
var UNICITY_IPFS_NODES = [
|
|
1710
|
+
{
|
|
1711
|
+
host: "unicity-ipfs1.dyndns.org",
|
|
1712
|
+
peerId: "12D3KooWDKJqEMAhH4nsSSiKtK1VLcas5coUqSPZAfbWbZpxtL4u",
|
|
1713
|
+
httpPort: 9080,
|
|
1714
|
+
httpsPort: 443
|
|
1715
|
+
}
|
|
1716
|
+
];
|
|
1717
|
+
function getIpfsGatewayUrls(isSecure) {
|
|
1718
|
+
return UNICITY_IPFS_NODES.map(
|
|
1719
|
+
(node) => isSecure !== false ? `https://${node.host}` : `http://${node.host}:${node.httpPort}`
|
|
1720
|
+
);
|
|
1721
|
+
}
|
|
1722
|
+
var DEFAULT_BASE_PATH = "m/44'/0'/0'";
|
|
1723
|
+
var DEFAULT_DERIVATION_PATH = `${DEFAULT_BASE_PATH}/0/0`;
|
|
1724
|
+
|
|
1725
|
+
// impl/shared/ipfs/ipfs-storage-provider.ts
|
|
1726
|
+
var IpfsStorageProvider = class {
|
|
1727
|
+
id = "ipfs";
|
|
1728
|
+
name = "IPFS Storage";
|
|
1729
|
+
type = "p2p";
|
|
1730
|
+
status = "disconnected";
|
|
1731
|
+
identity = null;
|
|
1732
|
+
ipnsKeyPair = null;
|
|
1733
|
+
ipnsName = null;
|
|
1734
|
+
ipnsSequenceNumber = 0n;
|
|
1735
|
+
lastCid = null;
|
|
1736
|
+
lastKnownRemoteSequence = 0n;
|
|
1737
|
+
dataVersion = 0;
|
|
1738
|
+
/**
|
|
1739
|
+
* The CID currently stored on the sidecar for this IPNS name.
|
|
1740
|
+
* Used as `_meta.lastCid` in the next save to satisfy chain validation.
|
|
1741
|
+
* - null for bootstrap (first-ever save)
|
|
1742
|
+
* - set after every successful save() or load()
|
|
1743
|
+
*/
|
|
1744
|
+
remoteCid = null;
|
|
1745
|
+
cache;
|
|
1746
|
+
httpClient;
|
|
1747
|
+
statePersistence;
|
|
1748
|
+
eventCallbacks = /* @__PURE__ */ new Set();
|
|
1749
|
+
debug;
|
|
1750
|
+
ipnsLifetimeMs;
|
|
1751
|
+
/** WebSocket factory for push subscriptions */
|
|
1752
|
+
createWebSocket;
|
|
1753
|
+
/** Override WS URL */
|
|
1754
|
+
wsUrl;
|
|
1755
|
+
/** Fallback poll interval (default: 90000) */
|
|
1756
|
+
fallbackPollIntervalMs;
|
|
1757
|
+
/** IPNS subscription client for push notifications */
|
|
1758
|
+
subscriptionClient = null;
|
|
1759
|
+
/** Unsubscribe function from subscription client */
|
|
1760
|
+
subscriptionUnsubscribe = null;
|
|
1761
|
+
/** In-memory buffer for individual token save/delete calls */
|
|
1762
|
+
tokenBuffer = /* @__PURE__ */ new Map();
|
|
1763
|
+
deletedTokenIds = /* @__PURE__ */ new Set();
|
|
1764
|
+
/** Write-behind buffer: serializes flush / sync / shutdown */
|
|
1765
|
+
flushQueue = new AsyncSerialQueue();
|
|
1766
|
+
/** Pending mutations not yet flushed to IPFS */
|
|
1767
|
+
pendingBuffer = new WriteBuffer();
|
|
1768
|
+
/** Debounce timer for background flush */
|
|
1769
|
+
flushTimer = null;
|
|
1770
|
+
/** Debounce interval in ms */
|
|
1771
|
+
flushDebounceMs;
|
|
1772
|
+
/** Set to true during shutdown to prevent new flushes */
|
|
1773
|
+
isShuttingDown = false;
|
|
1774
|
+
constructor(config, statePersistence) {
|
|
1775
|
+
const gateways = config?.gateways ?? getIpfsGatewayUrls();
|
|
1776
|
+
this.debug = config?.debug ?? false;
|
|
1777
|
+
this.ipnsLifetimeMs = config?.ipnsLifetimeMs ?? 99 * 365 * 24 * 60 * 60 * 1e3;
|
|
1778
|
+
this.flushDebounceMs = config?.flushDebounceMs ?? 2e3;
|
|
1779
|
+
this.cache = new IpfsCache({
|
|
1780
|
+
ipnsTtlMs: config?.ipnsCacheTtlMs,
|
|
1781
|
+
failureCooldownMs: config?.circuitBreakerCooldownMs,
|
|
1782
|
+
failureThreshold: config?.circuitBreakerThreshold,
|
|
1783
|
+
knownFreshWindowMs: config?.knownFreshWindowMs
|
|
1784
|
+
});
|
|
1785
|
+
this.httpClient = new IpfsHttpClient({
|
|
1786
|
+
gateways,
|
|
1787
|
+
fetchTimeoutMs: config?.fetchTimeoutMs,
|
|
1788
|
+
resolveTimeoutMs: config?.resolveTimeoutMs,
|
|
1789
|
+
publishTimeoutMs: config?.publishTimeoutMs,
|
|
1790
|
+
connectivityTimeoutMs: config?.connectivityTimeoutMs,
|
|
1791
|
+
debug: this.debug
|
|
1792
|
+
}, this.cache);
|
|
1793
|
+
this.statePersistence = statePersistence ?? new InMemoryIpfsStatePersistence();
|
|
1794
|
+
this.createWebSocket = config?.createWebSocket;
|
|
1795
|
+
this.wsUrl = config?.wsUrl;
|
|
1796
|
+
this.fallbackPollIntervalMs = config?.fallbackPollIntervalMs ?? 9e4;
|
|
1797
|
+
}
|
|
1798
|
+
// ---------------------------------------------------------------------------
|
|
1799
|
+
// BaseProvider interface
|
|
1800
|
+
// ---------------------------------------------------------------------------
|
|
1801
|
+
async connect() {
|
|
1802
|
+
await this.initialize();
|
|
1803
|
+
}
|
|
1804
|
+
async disconnect() {
|
|
1805
|
+
await this.shutdown();
|
|
1806
|
+
}
|
|
1807
|
+
isConnected() {
|
|
1808
|
+
return this.status === "connected";
|
|
1809
|
+
}
|
|
1810
|
+
getStatus() {
|
|
1811
|
+
return this.status;
|
|
1812
|
+
}
|
|
1813
|
+
// ---------------------------------------------------------------------------
|
|
1814
|
+
// Identity & Initialization
|
|
1815
|
+
// ---------------------------------------------------------------------------
|
|
1816
|
+
setIdentity(identity) {
|
|
1817
|
+
this.identity = identity;
|
|
1818
|
+
}
|
|
1819
|
+
async initialize() {
|
|
1820
|
+
if (!this.identity) {
|
|
1821
|
+
this.log("Cannot initialize: no identity set");
|
|
1822
|
+
return false;
|
|
1823
|
+
}
|
|
1824
|
+
this.status = "connecting";
|
|
1825
|
+
this.emitEvent({ type: "storage:loading", timestamp: Date.now() });
|
|
955
1826
|
try {
|
|
956
|
-
const
|
|
957
|
-
|
|
958
|
-
|
|
1827
|
+
const { keyPair, ipnsName } = await deriveIpnsIdentity(this.identity.privateKey);
|
|
1828
|
+
this.ipnsKeyPair = keyPair;
|
|
1829
|
+
this.ipnsName = ipnsName;
|
|
1830
|
+
this.log(`IPNS name derived: ${ipnsName}`);
|
|
1831
|
+
const persisted = await this.statePersistence.load(ipnsName);
|
|
1832
|
+
if (persisted) {
|
|
1833
|
+
this.ipnsSequenceNumber = BigInt(persisted.sequenceNumber);
|
|
1834
|
+
this.lastCid = persisted.lastCid;
|
|
1835
|
+
this.remoteCid = persisted.lastCid;
|
|
1836
|
+
this.dataVersion = persisted.version;
|
|
1837
|
+
this.log(`Loaded persisted state: seq=${this.ipnsSequenceNumber}, cid=${this.lastCid}`);
|
|
1838
|
+
}
|
|
1839
|
+
if (this.createWebSocket) {
|
|
1840
|
+
try {
|
|
1841
|
+
const wsUrlFinal = this.wsUrl ?? this.deriveWsUrl();
|
|
1842
|
+
if (wsUrlFinal) {
|
|
1843
|
+
this.subscriptionClient = new IpnsSubscriptionClient({
|
|
1844
|
+
wsUrl: wsUrlFinal,
|
|
1845
|
+
createWebSocket: this.createWebSocket,
|
|
1846
|
+
debug: this.debug
|
|
1847
|
+
});
|
|
1848
|
+
this.subscriptionUnsubscribe = this.subscriptionClient.subscribe(
|
|
1849
|
+
ipnsName,
|
|
1850
|
+
(update) => {
|
|
1851
|
+
this.log(`Push update: seq=${update.sequence}, cid=${update.cid}`);
|
|
1852
|
+
this.emitEvent({
|
|
1853
|
+
type: "storage:remote-updated",
|
|
1854
|
+
timestamp: Date.now(),
|
|
1855
|
+
data: { name: update.name, sequence: update.sequence, cid: update.cid }
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
);
|
|
1859
|
+
this.subscriptionClient.setFallbackPoll(
|
|
1860
|
+
() => this.pollForRemoteChanges(),
|
|
1861
|
+
this.fallbackPollIntervalMs
|
|
1862
|
+
);
|
|
1863
|
+
this.subscriptionClient.connect();
|
|
1864
|
+
}
|
|
1865
|
+
} catch (wsError) {
|
|
1866
|
+
this.log(`Failed to set up IPNS subscription: ${wsError}`);
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
this.httpClient.findHealthyGateways().then((healthy) => {
|
|
1870
|
+
if (healthy.length > 0) {
|
|
1871
|
+
this.log(`${healthy.length} healthy gateway(s) found`);
|
|
1872
|
+
} else {
|
|
1873
|
+
this.log("Warning: no healthy gateways found");
|
|
1874
|
+
}
|
|
1875
|
+
}).catch(() => {
|
|
959
1876
|
});
|
|
960
|
-
|
|
961
|
-
|
|
1877
|
+
this.isShuttingDown = false;
|
|
1878
|
+
this.status = "connected";
|
|
1879
|
+
this.emitEvent({ type: "storage:loaded", timestamp: Date.now() });
|
|
1880
|
+
return true;
|
|
1881
|
+
} catch (error) {
|
|
1882
|
+
this.status = "error";
|
|
1883
|
+
this.emitEvent({
|
|
1884
|
+
type: "storage:error",
|
|
1885
|
+
timestamp: Date.now(),
|
|
1886
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1887
|
+
});
|
|
1888
|
+
return false;
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
async shutdown() {
|
|
1892
|
+
this.isShuttingDown = true;
|
|
1893
|
+
if (this.flushTimer) {
|
|
1894
|
+
clearTimeout(this.flushTimer);
|
|
1895
|
+
this.flushTimer = null;
|
|
1896
|
+
}
|
|
1897
|
+
await this.flushQueue.enqueue(async () => {
|
|
1898
|
+
if (!this.pendingBuffer.isEmpty) {
|
|
1899
|
+
try {
|
|
1900
|
+
await this.executeFlush();
|
|
1901
|
+
} catch {
|
|
1902
|
+
this.log("Final flush on shutdown failed (data may be lost)");
|
|
1903
|
+
}
|
|
962
1904
|
}
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1905
|
+
});
|
|
1906
|
+
if (this.subscriptionUnsubscribe) {
|
|
1907
|
+
this.subscriptionUnsubscribe();
|
|
1908
|
+
this.subscriptionUnsubscribe = null;
|
|
1909
|
+
}
|
|
1910
|
+
if (this.subscriptionClient) {
|
|
1911
|
+
this.subscriptionClient.disconnect();
|
|
1912
|
+
this.subscriptionClient = null;
|
|
967
1913
|
}
|
|
1914
|
+
this.cache.clear();
|
|
1915
|
+
this.status = "disconnected";
|
|
1916
|
+
}
|
|
1917
|
+
// ---------------------------------------------------------------------------
|
|
1918
|
+
// Save (non-blocking — buffers data for async flush)
|
|
1919
|
+
// ---------------------------------------------------------------------------
|
|
1920
|
+
async save(data) {
|
|
1921
|
+
if (!this.ipnsKeyPair || !this.ipnsName) {
|
|
1922
|
+
return { success: false, error: "Not initialized", timestamp: Date.now() };
|
|
1923
|
+
}
|
|
1924
|
+
this.pendingBuffer.txfData = data;
|
|
1925
|
+
this.scheduleFlush();
|
|
1926
|
+
return { success: true, timestamp: Date.now() };
|
|
968
1927
|
}
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1928
|
+
// ---------------------------------------------------------------------------
|
|
1929
|
+
// Internal: Blocking save (used by sync and executeFlush)
|
|
1930
|
+
// ---------------------------------------------------------------------------
|
|
1931
|
+
/**
|
|
1932
|
+
* Perform the actual upload + IPNS publish synchronously.
|
|
1933
|
+
* Called by executeFlush() and sync() — never by public save().
|
|
1934
|
+
*/
|
|
1935
|
+
async _doSave(data) {
|
|
1936
|
+
if (!this.ipnsKeyPair || !this.ipnsName) {
|
|
1937
|
+
return { success: false, error: "Not initialized", timestamp: Date.now() };
|
|
973
1938
|
}
|
|
974
|
-
|
|
975
|
-
|
|
1939
|
+
this.emitEvent({ type: "storage:saving", timestamp: Date.now() });
|
|
1940
|
+
try {
|
|
1941
|
+
this.dataVersion++;
|
|
1942
|
+
const metaUpdate = {
|
|
1943
|
+
...data._meta,
|
|
1944
|
+
version: this.dataVersion,
|
|
1945
|
+
ipnsName: this.ipnsName,
|
|
1946
|
+
updatedAt: Date.now()
|
|
1947
|
+
};
|
|
1948
|
+
if (this.remoteCid) {
|
|
1949
|
+
metaUpdate.lastCid = this.remoteCid;
|
|
1950
|
+
}
|
|
1951
|
+
const updatedData = { ...data, _meta: metaUpdate };
|
|
1952
|
+
for (const [tokenId, tokenData] of this.tokenBuffer) {
|
|
1953
|
+
if (!this.deletedTokenIds.has(tokenId)) {
|
|
1954
|
+
updatedData[tokenId] = tokenData;
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
for (const tokenId of this.deletedTokenIds) {
|
|
1958
|
+
delete updatedData[tokenId];
|
|
1959
|
+
}
|
|
1960
|
+
const { cid } = await this.httpClient.upload(updatedData);
|
|
1961
|
+
this.log(`Content uploaded: CID=${cid}`);
|
|
1962
|
+
const baseSeq = this.ipnsSequenceNumber > this.lastKnownRemoteSequence ? this.ipnsSequenceNumber : this.lastKnownRemoteSequence;
|
|
1963
|
+
const newSeq = baseSeq + 1n;
|
|
1964
|
+
const marshalledRecord = await createSignedRecord(
|
|
1965
|
+
this.ipnsKeyPair,
|
|
1966
|
+
cid,
|
|
1967
|
+
newSeq,
|
|
1968
|
+
this.ipnsLifetimeMs
|
|
1969
|
+
);
|
|
1970
|
+
const publishResult = await this.httpClient.publishIpns(
|
|
1971
|
+
this.ipnsName,
|
|
1972
|
+
marshalledRecord
|
|
1973
|
+
);
|
|
1974
|
+
if (!publishResult.success) {
|
|
1975
|
+
this.dataVersion--;
|
|
1976
|
+
this.log(`IPNS publish failed: ${publishResult.error}`);
|
|
1977
|
+
return {
|
|
1978
|
+
success: false,
|
|
1979
|
+
error: publishResult.error ?? "IPNS publish failed",
|
|
1980
|
+
timestamp: Date.now()
|
|
1981
|
+
};
|
|
1982
|
+
}
|
|
1983
|
+
this.ipnsSequenceNumber = newSeq;
|
|
1984
|
+
this.lastCid = cid;
|
|
1985
|
+
this.remoteCid = cid;
|
|
1986
|
+
this.cache.setIpnsRecord(this.ipnsName, {
|
|
1987
|
+
cid,
|
|
1988
|
+
sequence: newSeq,
|
|
1989
|
+
gateway: "local"
|
|
1990
|
+
});
|
|
1991
|
+
this.cache.setContent(cid, updatedData);
|
|
1992
|
+
this.cache.markIpnsFresh(this.ipnsName);
|
|
1993
|
+
await this.statePersistence.save(this.ipnsName, {
|
|
1994
|
+
sequenceNumber: newSeq.toString(),
|
|
1995
|
+
lastCid: cid,
|
|
1996
|
+
version: this.dataVersion
|
|
1997
|
+
});
|
|
1998
|
+
this.deletedTokenIds.clear();
|
|
1999
|
+
this.emitEvent({
|
|
2000
|
+
type: "storage:saved",
|
|
2001
|
+
timestamp: Date.now(),
|
|
2002
|
+
data: { cid, sequence: newSeq.toString() }
|
|
2003
|
+
});
|
|
2004
|
+
this.log(`Saved: CID=${cid}, seq=${newSeq}`);
|
|
2005
|
+
return { success: true, cid, timestamp: Date.now() };
|
|
2006
|
+
} catch (error) {
|
|
2007
|
+
this.dataVersion--;
|
|
2008
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2009
|
+
this.emitEvent({
|
|
2010
|
+
type: "storage:error",
|
|
2011
|
+
timestamp: Date.now(),
|
|
2012
|
+
error: errorMessage
|
|
2013
|
+
});
|
|
2014
|
+
return { success: false, error: errorMessage, timestamp: Date.now() };
|
|
976
2015
|
}
|
|
977
|
-
|
|
2016
|
+
}
|
|
2017
|
+
// ---------------------------------------------------------------------------
|
|
2018
|
+
// Write-behind buffer: scheduling and flushing
|
|
2019
|
+
// ---------------------------------------------------------------------------
|
|
2020
|
+
/**
|
|
2021
|
+
* Schedule a debounced background flush.
|
|
2022
|
+
* Resets the timer on each call so rapid mutations coalesce.
|
|
2023
|
+
*/
|
|
2024
|
+
scheduleFlush() {
|
|
2025
|
+
if (this.isShuttingDown) return;
|
|
2026
|
+
if (this.flushTimer) clearTimeout(this.flushTimer);
|
|
2027
|
+
this.flushTimer = setTimeout(() => {
|
|
2028
|
+
this.flushTimer = null;
|
|
2029
|
+
this.flushQueue.enqueue(() => this.executeFlush()).catch((err) => {
|
|
2030
|
+
this.log(`Background flush failed: ${err}`);
|
|
2031
|
+
});
|
|
2032
|
+
}, this.flushDebounceMs);
|
|
978
2033
|
}
|
|
979
2034
|
/**
|
|
980
|
-
*
|
|
2035
|
+
* Execute a flush of the pending buffer to IPFS.
|
|
2036
|
+
* Runs inside AsyncSerialQueue for concurrency safety.
|
|
981
2037
|
*/
|
|
982
|
-
async
|
|
983
|
-
if (
|
|
984
|
-
|
|
2038
|
+
async executeFlush() {
|
|
2039
|
+
if (this.pendingBuffer.isEmpty) return;
|
|
2040
|
+
const active = this.pendingBuffer;
|
|
2041
|
+
this.pendingBuffer = new WriteBuffer();
|
|
2042
|
+
try {
|
|
2043
|
+
const baseData = active.txfData ?? {
|
|
2044
|
+
_meta: { version: 0, address: this.identity?.directAddress ?? "", formatVersion: "2.0", updatedAt: 0 }
|
|
2045
|
+
};
|
|
2046
|
+
const result = await this._doSave(baseData);
|
|
2047
|
+
if (!result.success) {
|
|
2048
|
+
throw new Error(result.error ?? "Save failed");
|
|
2049
|
+
}
|
|
2050
|
+
this.log(`Flushed successfully: CID=${result.cid}`);
|
|
2051
|
+
} catch (error) {
|
|
2052
|
+
this.pendingBuffer.mergeFrom(active);
|
|
2053
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
2054
|
+
this.log(`Flush failed (will retry): ${msg}`);
|
|
2055
|
+
this.scheduleFlush();
|
|
2056
|
+
throw error;
|
|
985
2057
|
}
|
|
986
|
-
const { CID } = await loadHeliaModules();
|
|
987
|
-
const cid = CID.parse(cidString);
|
|
988
|
-
const data = await this.heliaJson.get(cid);
|
|
989
|
-
this.log("Fetched via Helia, CID:", cidString);
|
|
990
|
-
return data;
|
|
991
2058
|
}
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
2059
|
+
// ---------------------------------------------------------------------------
|
|
2060
|
+
// Load
|
|
2061
|
+
// ---------------------------------------------------------------------------
|
|
2062
|
+
async load(identifier) {
|
|
2063
|
+
if (!this.ipnsName && !identifier) {
|
|
2064
|
+
return { success: false, error: "Not initialized", source: "local", timestamp: Date.now() };
|
|
2065
|
+
}
|
|
2066
|
+
this.emitEvent({ type: "storage:loading", timestamp: Date.now() });
|
|
995
2067
|
try {
|
|
996
|
-
|
|
997
|
-
|
|
2068
|
+
if (identifier) {
|
|
2069
|
+
const data2 = await this.httpClient.fetchContent(identifier);
|
|
2070
|
+
return { success: true, data: data2, source: "remote", timestamp: Date.now() };
|
|
2071
|
+
}
|
|
2072
|
+
const ipnsName = this.ipnsName;
|
|
2073
|
+
if (this.cache.isIpnsKnownFresh(ipnsName)) {
|
|
2074
|
+
const cached = this.cache.getIpnsRecordIgnoreTtl(ipnsName);
|
|
2075
|
+
if (cached) {
|
|
2076
|
+
const content = this.cache.getContent(cached.cid);
|
|
2077
|
+
if (content) {
|
|
2078
|
+
this.log("Using known-fresh cached data");
|
|
2079
|
+
return { success: true, data: content, source: "cache", timestamp: Date.now() };
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
const cachedRecord = this.cache.getIpnsRecord(ipnsName);
|
|
2084
|
+
if (cachedRecord) {
|
|
2085
|
+
const content = this.cache.getContent(cachedRecord.cid);
|
|
2086
|
+
if (content) {
|
|
2087
|
+
this.log("IPNS cache hit");
|
|
2088
|
+
return { success: true, data: content, source: "cache", timestamp: Date.now() };
|
|
2089
|
+
}
|
|
2090
|
+
try {
|
|
2091
|
+
const data2 = await this.httpClient.fetchContent(cachedRecord.cid);
|
|
2092
|
+
return { success: true, data: data2, source: "remote", timestamp: Date.now() };
|
|
2093
|
+
} catch {
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
const { best } = await this.httpClient.resolveIpns(ipnsName);
|
|
2097
|
+
if (!best) {
|
|
2098
|
+
this.log("IPNS record not found (new wallet?)");
|
|
2099
|
+
return { success: false, error: "IPNS record not found", source: "remote", timestamp: Date.now() };
|
|
2100
|
+
}
|
|
2101
|
+
if (best.sequence > this.lastKnownRemoteSequence) {
|
|
2102
|
+
this.lastKnownRemoteSequence = best.sequence;
|
|
2103
|
+
}
|
|
2104
|
+
this.remoteCid = best.cid;
|
|
2105
|
+
const data = await this.httpClient.fetchContent(best.cid);
|
|
2106
|
+
const remoteVersion = data?._meta?.version;
|
|
2107
|
+
if (typeof remoteVersion === "number" && remoteVersion > this.dataVersion) {
|
|
2108
|
+
this.dataVersion = remoteVersion;
|
|
2109
|
+
}
|
|
2110
|
+
this.populateTokenBuffer(data);
|
|
2111
|
+
this.emitEvent({
|
|
2112
|
+
type: "storage:loaded",
|
|
2113
|
+
timestamp: Date.now(),
|
|
2114
|
+
data: { cid: best.cid, sequence: best.sequence.toString() }
|
|
998
2115
|
});
|
|
999
|
-
|
|
1000
|
-
|
|
2116
|
+
return { success: true, data, source: "remote", timestamp: Date.now() };
|
|
2117
|
+
} catch (error) {
|
|
2118
|
+
if (this.ipnsName) {
|
|
2119
|
+
const cached = this.cache.getIpnsRecordIgnoreTtl(this.ipnsName);
|
|
2120
|
+
if (cached) {
|
|
2121
|
+
const content = this.cache.getContent(cached.cid);
|
|
2122
|
+
if (content) {
|
|
2123
|
+
this.log("Network error, returning stale cache");
|
|
2124
|
+
return { success: true, data: content, source: "cache", timestamp: Date.now() };
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
1001
2127
|
}
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
const existing = tombstones.get(t.tokenId);
|
|
1020
|
-
if (!existing || t.timestamp > existing.timestamp) {
|
|
1021
|
-
tombstones.set(t.tokenId, t);
|
|
2128
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2129
|
+
this.emitEvent({
|
|
2130
|
+
type: "storage:error",
|
|
2131
|
+
timestamp: Date.now(),
|
|
2132
|
+
error: errorMessage
|
|
2133
|
+
});
|
|
2134
|
+
return { success: false, error: errorMessage, source: "remote", timestamp: Date.now() };
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
// ---------------------------------------------------------------------------
|
|
2138
|
+
// Sync (enters serial queue to avoid concurrent IPNS conflicts)
|
|
2139
|
+
// ---------------------------------------------------------------------------
|
|
2140
|
+
async sync(localData) {
|
|
2141
|
+
return this.flushQueue.enqueue(async () => {
|
|
2142
|
+
if (this.flushTimer) {
|
|
2143
|
+
clearTimeout(this.flushTimer);
|
|
2144
|
+
this.flushTimer = null;
|
|
1022
2145
|
}
|
|
2146
|
+
this.emitEvent({ type: "sync:started", timestamp: Date.now() });
|
|
2147
|
+
try {
|
|
2148
|
+
this.pendingBuffer.clear();
|
|
2149
|
+
const remoteResult = await this.load();
|
|
2150
|
+
if (!remoteResult.success || !remoteResult.data) {
|
|
2151
|
+
this.log("No remote data found, uploading local data");
|
|
2152
|
+
const saveResult2 = await this._doSave(localData);
|
|
2153
|
+
this.emitEvent({ type: "sync:completed", timestamp: Date.now() });
|
|
2154
|
+
return {
|
|
2155
|
+
success: saveResult2.success,
|
|
2156
|
+
merged: this.enrichWithTokenBuffer(localData),
|
|
2157
|
+
added: 0,
|
|
2158
|
+
removed: 0,
|
|
2159
|
+
conflicts: 0,
|
|
2160
|
+
error: saveResult2.error
|
|
2161
|
+
};
|
|
2162
|
+
}
|
|
2163
|
+
const remoteData = remoteResult.data;
|
|
2164
|
+
const localVersion = localData._meta?.version ?? 0;
|
|
2165
|
+
const remoteVersion = remoteData._meta?.version ?? 0;
|
|
2166
|
+
if (localVersion === remoteVersion && this.lastCid) {
|
|
2167
|
+
this.log("Data is in sync (same version)");
|
|
2168
|
+
this.emitEvent({ type: "sync:completed", timestamp: Date.now() });
|
|
2169
|
+
return {
|
|
2170
|
+
success: true,
|
|
2171
|
+
merged: this.enrichWithTokenBuffer(localData),
|
|
2172
|
+
added: 0,
|
|
2173
|
+
removed: 0,
|
|
2174
|
+
conflicts: 0
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
this.log(`Merging: local v${localVersion} <-> remote v${remoteVersion}`);
|
|
2178
|
+
const { merged, added, removed, conflicts } = mergeTxfData(localData, remoteData);
|
|
2179
|
+
if (conflicts > 0) {
|
|
2180
|
+
this.emitEvent({
|
|
2181
|
+
type: "sync:conflict",
|
|
2182
|
+
timestamp: Date.now(),
|
|
2183
|
+
data: { conflicts }
|
|
2184
|
+
});
|
|
2185
|
+
}
|
|
2186
|
+
const saveResult = await this._doSave(merged);
|
|
2187
|
+
this.emitEvent({
|
|
2188
|
+
type: "sync:completed",
|
|
2189
|
+
timestamp: Date.now(),
|
|
2190
|
+
data: { added, removed, conflicts }
|
|
2191
|
+
});
|
|
2192
|
+
return {
|
|
2193
|
+
success: saveResult.success,
|
|
2194
|
+
merged: this.enrichWithTokenBuffer(merged),
|
|
2195
|
+
added,
|
|
2196
|
+
removed,
|
|
2197
|
+
conflicts,
|
|
2198
|
+
error: saveResult.error
|
|
2199
|
+
};
|
|
2200
|
+
} catch (error) {
|
|
2201
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2202
|
+
this.emitEvent({
|
|
2203
|
+
type: "sync:error",
|
|
2204
|
+
timestamp: Date.now(),
|
|
2205
|
+
error: errorMessage
|
|
2206
|
+
});
|
|
2207
|
+
return {
|
|
2208
|
+
success: false,
|
|
2209
|
+
added: 0,
|
|
2210
|
+
removed: 0,
|
|
2211
|
+
conflicts: 0,
|
|
2212
|
+
error: errorMessage
|
|
2213
|
+
};
|
|
2214
|
+
}
|
|
2215
|
+
});
|
|
2216
|
+
}
|
|
2217
|
+
// ---------------------------------------------------------------------------
|
|
2218
|
+
// Private Helpers
|
|
2219
|
+
// ---------------------------------------------------------------------------
|
|
2220
|
+
/**
|
|
2221
|
+
* Enrich TXF data with individually-buffered tokens before returning to caller.
|
|
2222
|
+
* PaymentsModule.createStorageData() passes empty tokens (they're stored via
|
|
2223
|
+
* saveToken()), but loadFromStorageData() needs them in the merged result.
|
|
2224
|
+
*/
|
|
2225
|
+
enrichWithTokenBuffer(data) {
|
|
2226
|
+
if (this.tokenBuffer.size === 0) return data;
|
|
2227
|
+
const enriched = { ...data };
|
|
2228
|
+
for (const [tokenId, tokenData] of this.tokenBuffer) {
|
|
2229
|
+
if (!this.deletedTokenIds.has(tokenId)) {
|
|
2230
|
+
enriched[tokenId] = tokenData;
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
return enriched;
|
|
2234
|
+
}
|
|
2235
|
+
// ---------------------------------------------------------------------------
|
|
2236
|
+
// Optional Methods
|
|
2237
|
+
// ---------------------------------------------------------------------------
|
|
2238
|
+
async exists() {
|
|
2239
|
+
if (!this.ipnsName) return false;
|
|
2240
|
+
const cached = this.cache.getIpnsRecord(this.ipnsName);
|
|
2241
|
+
if (cached) return true;
|
|
2242
|
+
const { best } = await this.httpClient.resolveIpns(this.ipnsName);
|
|
2243
|
+
return best !== null;
|
|
2244
|
+
}
|
|
2245
|
+
async clear() {
|
|
2246
|
+
if (!this.ipnsKeyPair || !this.ipnsName) return false;
|
|
2247
|
+
this.pendingBuffer.clear();
|
|
2248
|
+
if (this.flushTimer) {
|
|
2249
|
+
clearTimeout(this.flushTimer);
|
|
2250
|
+
this.flushTimer = null;
|
|
1023
2251
|
}
|
|
1024
|
-
const
|
|
2252
|
+
const emptyData = {
|
|
1025
2253
|
_meta: {
|
|
1026
|
-
|
|
1027
|
-
|
|
2254
|
+
version: 0,
|
|
2255
|
+
address: this.identity?.directAddress ?? "",
|
|
2256
|
+
ipnsName: this.ipnsName,
|
|
2257
|
+
formatVersion: "2.0",
|
|
1028
2258
|
updatedAt: Date.now()
|
|
1029
|
-
},
|
|
1030
|
-
_tombstones: Array.from(tombstones.values())
|
|
1031
|
-
};
|
|
1032
|
-
let added = 0;
|
|
1033
|
-
let conflicts = 0;
|
|
1034
|
-
const processedKeys = /* @__PURE__ */ new Set();
|
|
1035
|
-
for (const key of Object.keys(local)) {
|
|
1036
|
-
if (!key.startsWith("_") || key === "_meta" || key === "_tombstones") continue;
|
|
1037
|
-
processedKeys.add(key);
|
|
1038
|
-
const tokenId = key.slice(1);
|
|
1039
|
-
if (tombstones.has(tokenId)) continue;
|
|
1040
|
-
const localToken = local[key];
|
|
1041
|
-
const remoteToken = remote[key];
|
|
1042
|
-
if (remoteToken) {
|
|
1043
|
-
conflicts++;
|
|
1044
|
-
merged[key] = localToken;
|
|
1045
|
-
} else {
|
|
1046
|
-
merged[key] = localToken;
|
|
1047
2259
|
}
|
|
2260
|
+
};
|
|
2261
|
+
const result = await this._doSave(emptyData);
|
|
2262
|
+
if (result.success) {
|
|
2263
|
+
this.cache.clear();
|
|
2264
|
+
this.tokenBuffer.clear();
|
|
2265
|
+
this.deletedTokenIds.clear();
|
|
2266
|
+
await this.statePersistence.clear(this.ipnsName);
|
|
1048
2267
|
}
|
|
1049
|
-
|
|
1050
|
-
if (!key.startsWith("_") || key === "_meta" || key === "_tombstones") continue;
|
|
1051
|
-
if (processedKeys.has(key)) continue;
|
|
1052
|
-
const tokenId = key.slice(1);
|
|
1053
|
-
if (tombstones.has(tokenId)) continue;
|
|
1054
|
-
merged[key] = remote[key];
|
|
1055
|
-
added++;
|
|
1056
|
-
}
|
|
1057
|
-
return { merged, added, removed: 0, conflicts };
|
|
2268
|
+
return result.success;
|
|
1058
2269
|
}
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
2270
|
+
onEvent(callback) {
|
|
2271
|
+
this.eventCallbacks.add(callback);
|
|
2272
|
+
return () => {
|
|
2273
|
+
this.eventCallbacks.delete(callback);
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
2276
|
+
async saveToken(tokenId, tokenData) {
|
|
2277
|
+
this.pendingBuffer.tokenMutations.set(tokenId, { op: "save", data: tokenData });
|
|
2278
|
+
this.tokenBuffer.set(tokenId, tokenData);
|
|
2279
|
+
this.deletedTokenIds.delete(tokenId);
|
|
2280
|
+
this.scheduleFlush();
|
|
2281
|
+
}
|
|
2282
|
+
async getToken(tokenId) {
|
|
2283
|
+
if (this.deletedTokenIds.has(tokenId)) return null;
|
|
2284
|
+
return this.tokenBuffer.get(tokenId) ?? null;
|
|
2285
|
+
}
|
|
2286
|
+
async listTokenIds() {
|
|
2287
|
+
return Array.from(this.tokenBuffer.keys()).filter(
|
|
2288
|
+
(id) => !this.deletedTokenIds.has(id)
|
|
2289
|
+
);
|
|
2290
|
+
}
|
|
2291
|
+
async deleteToken(tokenId) {
|
|
2292
|
+
this.pendingBuffer.tokenMutations.set(tokenId, { op: "delete" });
|
|
2293
|
+
this.tokenBuffer.delete(tokenId);
|
|
2294
|
+
this.deletedTokenIds.add(tokenId);
|
|
2295
|
+
this.scheduleFlush();
|
|
2296
|
+
}
|
|
2297
|
+
// ---------------------------------------------------------------------------
|
|
2298
|
+
// Public Accessors
|
|
2299
|
+
// ---------------------------------------------------------------------------
|
|
2300
|
+
getIpnsName() {
|
|
2301
|
+
return this.ipnsName;
|
|
2302
|
+
}
|
|
2303
|
+
getLastCid() {
|
|
2304
|
+
return this.lastCid;
|
|
2305
|
+
}
|
|
2306
|
+
getSequenceNumber() {
|
|
2307
|
+
return this.ipnsSequenceNumber;
|
|
2308
|
+
}
|
|
2309
|
+
getDataVersion() {
|
|
2310
|
+
return this.dataVersion;
|
|
2311
|
+
}
|
|
2312
|
+
getRemoteCid() {
|
|
2313
|
+
return this.remoteCid;
|
|
2314
|
+
}
|
|
2315
|
+
// ---------------------------------------------------------------------------
|
|
2316
|
+
// Testing helper: wait for pending flush to complete
|
|
2317
|
+
// ---------------------------------------------------------------------------
|
|
2318
|
+
/**
|
|
2319
|
+
* Wait for the pending flush timer to fire and the flush operation to
|
|
2320
|
+
* complete. Useful in tests to await background writes.
|
|
2321
|
+
* Returns immediately if no flush is pending.
|
|
2322
|
+
*/
|
|
2323
|
+
async waitForFlush() {
|
|
2324
|
+
if (this.flushTimer) {
|
|
2325
|
+
clearTimeout(this.flushTimer);
|
|
2326
|
+
this.flushTimer = null;
|
|
2327
|
+
await this.flushQueue.enqueue(() => this.executeFlush()).catch(() => {
|
|
2328
|
+
});
|
|
2329
|
+
} else if (!this.pendingBuffer.isEmpty) {
|
|
2330
|
+
await this.flushQueue.enqueue(() => this.executeFlush()).catch(() => {
|
|
2331
|
+
});
|
|
2332
|
+
} else {
|
|
2333
|
+
await this.flushQueue.enqueue(async () => {
|
|
2334
|
+
});
|
|
1068
2335
|
}
|
|
1069
2336
|
}
|
|
2337
|
+
// ---------------------------------------------------------------------------
|
|
2338
|
+
// Internal: Push Subscription Helpers
|
|
2339
|
+
// ---------------------------------------------------------------------------
|
|
1070
2340
|
/**
|
|
1071
|
-
*
|
|
2341
|
+
* Derive WebSocket URL from the first configured gateway.
|
|
2342
|
+
* Converts https://host → wss://host/ws/ipns
|
|
1072
2343
|
*/
|
|
1073
|
-
|
|
1074
|
-
|
|
2344
|
+
deriveWsUrl() {
|
|
2345
|
+
const gateways = this.httpClient.getGateways();
|
|
2346
|
+
if (gateways.length === 0) return null;
|
|
2347
|
+
const gateway = gateways[0];
|
|
2348
|
+
const wsProtocol = gateway.startsWith("https://") ? "wss://" : "ws://";
|
|
2349
|
+
const host = gateway.replace(/^https?:\/\//, "");
|
|
2350
|
+
return `${wsProtocol}${host}/ws/ipns`;
|
|
1075
2351
|
}
|
|
1076
2352
|
/**
|
|
1077
|
-
*
|
|
2353
|
+
* Poll for remote IPNS changes (fallback when WS is unavailable).
|
|
2354
|
+
* Compares remote sequence number with last known and emits event if changed.
|
|
1078
2355
|
*/
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
2356
|
+
async pollForRemoteChanges() {
|
|
2357
|
+
if (!this.ipnsName) return;
|
|
2358
|
+
try {
|
|
2359
|
+
const { best } = await this.httpClient.resolveIpns(this.ipnsName);
|
|
2360
|
+
if (best && best.sequence > this.lastKnownRemoteSequence) {
|
|
2361
|
+
this.log(`Poll detected remote change: seq=${best.sequence} (was ${this.lastKnownRemoteSequence})`);
|
|
2362
|
+
this.lastKnownRemoteSequence = best.sequence;
|
|
2363
|
+
this.emitEvent({
|
|
2364
|
+
type: "storage:remote-updated",
|
|
2365
|
+
timestamp: Date.now(),
|
|
2366
|
+
data: { name: this.ipnsName, sequence: Number(best.sequence), cid: best.cid }
|
|
2367
|
+
});
|
|
2368
|
+
}
|
|
2369
|
+
} catch {
|
|
1083
2370
|
}
|
|
1084
|
-
return bytes;
|
|
1085
2371
|
}
|
|
2372
|
+
// ---------------------------------------------------------------------------
|
|
2373
|
+
// Internal
|
|
2374
|
+
// ---------------------------------------------------------------------------
|
|
1086
2375
|
emitEvent(event) {
|
|
1087
2376
|
for (const callback of this.eventCallbacks) {
|
|
1088
2377
|
try {
|
|
1089
2378
|
callback(event);
|
|
1090
|
-
} catch
|
|
1091
|
-
|
|
2379
|
+
} catch {
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
log(message) {
|
|
2384
|
+
if (this.debug) {
|
|
2385
|
+
console.log(`[IPFS-Storage] ${message}`);
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
META_KEYS = /* @__PURE__ */ new Set([
|
|
2389
|
+
"_meta",
|
|
2390
|
+
"_tombstones",
|
|
2391
|
+
"_outbox",
|
|
2392
|
+
"_sent",
|
|
2393
|
+
"_invalid",
|
|
2394
|
+
"_nametag",
|
|
2395
|
+
"_mintOutbox",
|
|
2396
|
+
"_invalidatedNametags",
|
|
2397
|
+
"_integrity"
|
|
2398
|
+
]);
|
|
2399
|
+
populateTokenBuffer(data) {
|
|
2400
|
+
this.tokenBuffer.clear();
|
|
2401
|
+
this.deletedTokenIds.clear();
|
|
2402
|
+
for (const key of Object.keys(data)) {
|
|
2403
|
+
if (!this.META_KEYS.has(key)) {
|
|
2404
|
+
this.tokenBuffer.set(key, data[key]);
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
};
|
|
2409
|
+
|
|
2410
|
+
// impl/browser/ipfs/browser-ipfs-state-persistence.ts
|
|
2411
|
+
var KEY_PREFIX = "sphere_ipfs_";
|
|
2412
|
+
function seqKey(ipnsName) {
|
|
2413
|
+
return `${KEY_PREFIX}seq_${ipnsName}`;
|
|
2414
|
+
}
|
|
2415
|
+
function cidKey(ipnsName) {
|
|
2416
|
+
return `${KEY_PREFIX}cid_${ipnsName}`;
|
|
2417
|
+
}
|
|
2418
|
+
function verKey(ipnsName) {
|
|
2419
|
+
return `${KEY_PREFIX}ver_${ipnsName}`;
|
|
2420
|
+
}
|
|
2421
|
+
var BrowserIpfsStatePersistence = class {
|
|
2422
|
+
async load(ipnsName) {
|
|
2423
|
+
try {
|
|
2424
|
+
const seq = localStorage.getItem(seqKey(ipnsName));
|
|
2425
|
+
if (!seq) return null;
|
|
2426
|
+
return {
|
|
2427
|
+
sequenceNumber: seq,
|
|
2428
|
+
lastCid: localStorage.getItem(cidKey(ipnsName)),
|
|
2429
|
+
version: parseInt(localStorage.getItem(verKey(ipnsName)) ?? "0", 10)
|
|
2430
|
+
};
|
|
2431
|
+
} catch {
|
|
2432
|
+
return null;
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
async save(ipnsName, state) {
|
|
2436
|
+
try {
|
|
2437
|
+
localStorage.setItem(seqKey(ipnsName), state.sequenceNumber);
|
|
2438
|
+
if (state.lastCid) {
|
|
2439
|
+
localStorage.setItem(cidKey(ipnsName), state.lastCid);
|
|
2440
|
+
} else {
|
|
2441
|
+
localStorage.removeItem(cidKey(ipnsName));
|
|
1092
2442
|
}
|
|
2443
|
+
localStorage.setItem(verKey(ipnsName), String(state.version));
|
|
2444
|
+
} catch {
|
|
1093
2445
|
}
|
|
1094
2446
|
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
2447
|
+
async clear(ipnsName) {
|
|
2448
|
+
try {
|
|
2449
|
+
localStorage.removeItem(seqKey(ipnsName));
|
|
2450
|
+
localStorage.removeItem(cidKey(ipnsName));
|
|
2451
|
+
localStorage.removeItem(verKey(ipnsName));
|
|
2452
|
+
} catch {
|
|
1098
2453
|
}
|
|
1099
2454
|
}
|
|
1100
2455
|
};
|
|
1101
|
-
|
|
1102
|
-
|
|
2456
|
+
|
|
2457
|
+
// impl/browser/ipfs/index.ts
|
|
2458
|
+
function createBrowserWebSocket(url) {
|
|
2459
|
+
return new WebSocket(url);
|
|
2460
|
+
}
|
|
2461
|
+
function createBrowserIpfsStorageProvider(config) {
|
|
2462
|
+
return new IpfsStorageProvider(
|
|
2463
|
+
{ ...config, createWebSocket: config?.createWebSocket ?? createBrowserWebSocket },
|
|
2464
|
+
new BrowserIpfsStatePersistence()
|
|
2465
|
+
);
|
|
1103
2466
|
}
|
|
2467
|
+
var createIpfsStorageProvider = createBrowserIpfsStorageProvider;
|
|
1104
2468
|
/*! Bundled license information:
|
|
1105
2469
|
|
|
1106
2470
|
@noble/hashes/utils.js:
|