Skip to content

Commit a1c8aad

Browse files
author
tung@cloud.phamthanh.me
committed
ipfs direct
1 parent 40d288e commit a1c8aad

5 files changed

Lines changed: 258 additions & 127 deletions

File tree

docs/direct-ipfs-plan.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Implementation Plan: Offline-First Direct IPFS (Helia)
2+
3+
This plan outlines the implementation of a direct, client-side IPFS node within FeatherNote using **Helia**. The focus is on a lightweight, serverless, and offline-first synchronization strategy.
4+
5+
## 1. Goal
6+
Enable true decentralized synchronization by running an IPFS node directly in the browser. This eliminates the need for third-party services like Pinata or self-hosted RPC endpoints, allowing the app to function entirely offline and sync when a connection is available.
7+
8+
## 2. Technical Stack
9+
- **Helia**: A modular, lightweight IPFS implementation for JS.
10+
- **Blockstore-IDB / Datastore-IDB**: To persist IPFS data in the browser's IndexedDB.
11+
- **Helia JSON**: For efficient handling of note objects as IPFS blocks.
12+
- **libp2p**: Configured with WebRTC and WebSockets for browser-to-browser and browser-to-node connectivity.
13+
14+
## 3. Core Logic: The "Index File" Approach
15+
Instead of relying on an external metadata API, the app will maintain a local IPFS state:
16+
1. **Local Index (`.feathernote.json`)**: A JSON file containing:
17+
- Map of `noteId` -> `noteCid`
18+
- `updatedAt` timestamp
19+
- `deletedNoteIds` list
20+
2. **Root CID**: The CID of the latest version of this index file.
21+
3. **Persistence**: The Root CID is stored in the app's encrypted settings. When the settings sync (via S3, Git, etc.), other devices receive the Root CID and can "pull" the data from the IPFS network.
22+
23+
## 4. Offline-First Strategy
24+
- **Local Cache**: IPFS blocks are stored in IndexedDB. Adding a note to IPFS while offline will still generate a valid CID and update the local index.
25+
- **Deferred Syncing**: When the app regains connectivity, the Helia node will:
26+
- Connect to bootstrap nodes/relays.
27+
- Announce the Root CID to the network.
28+
- Fetch any missing blocks required by a newer Root CID received from settings.
29+
30+
## 5. Proposed Changes
31+
32+
### A. IPFS Module (`src/ipfs.js`)
33+
- **Initialization**: Logic to start Helia with IndexedDB persistence.
34+
- **`addNoteToHelia(note)`**:
35+
- Convert note to JSON block -> get `noteCid`.
36+
- Update the local Index file -> get `newRootCid`.
37+
- Persist `newRootCid` to app settings.
38+
- **`getNoteFromHelia(noteCid)`**: Retrieve and parse JSON from the IPFS network/local cache.
39+
- **`syncIndex(remoteRootCid)`**:
40+
- Fetch the remote index.
41+
- Compare with local index.
42+
- Download missing note CIDs.
43+
44+
### B. UI & Settings (`src/index.html`, `src/index.js`)
45+
- **Toggle**: "Enable Direct IPFS Node".
46+
- **Status Indicator**: Show if the node is starting, online (connected to peers), or offline.
47+
- **Root CID Display**: Read-only field showing the current state's hash.
48+
49+
### C. Integration (`src/helpers.js`)
50+
- **Sync Flow**: Modify the `synchronize` function to check the local Helia node for updates if enabled.
51+
- **Conflict Resolution**: Use the `updatedAt` field within the `.feathernote.json` index to determine the latest global state.
52+
53+
## 6. Connectivity Requirements
54+
Since browsers cannot accept direct incoming connections, we will use:
55+
- **Public Gateways**: To fetch the index if no direct peers are found.
56+
- **Circuit Relays**: To allow browser nodes to talk to each other through a middleman.
57+
- **WST (Websocket-Star)** or **WebRTC-Star**: For peer discovery.
58+
59+
## 7. Verification Plan
60+
1. **Offline Add**: Disable network, add a note, and verify it exists in the local IPFS blockstore (via CID).
61+
2. **Local Peer Sync**: Open two different browsers on the same machine and verify they can sync notes via the local node.
62+
3. **Persistence**: Refresh the page and ensure the Helia node recovers its previous state from IndexedDB.

src/helpers.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@ async function syncDeletedNoteIds({deletedNoteIds, credentials, gitCredentials,
377377
if (gdriveStore?.connected) {
378378
remoteLists.push(await _GLOBAL.downloadDeletedNotesFromGoogleDrive());
379379
}
380-
if (credentials?.pinataJwt || credentials?.pinataApiKey) {
380+
if (credentials?.useDirectIpfs || credentials?.pinataJwt || credentials?.pinataApiKey) {
381381
remoteLists.push(await window.downloadDeletedNotesFromIPFS(credentials));
382382
}
383383

@@ -422,7 +422,7 @@ async function syncDeletedNoteIds({deletedNoteIds, credentials, gitCredentials,
422422
if (gdriveStore?.connected) {
423423
uploadPromises.push(_GLOBAL.uploadDeletedNotesToGoogleDrive(finalIdArray));
424424
}
425-
if (credentials?.pinataJwt || credentials?.pinataApiKey) {
425+
if (credentials?.useDirectIpfs || credentials?.pinataJwt || credentials?.pinataApiKey) {
426426
uploadPromises.push(window.uploadDeletedNotesToIPFS(finalIdArray, credentials));
427427
}
428428
await Promise.allSettled(uploadPromises);
@@ -442,7 +442,7 @@ async function synchronize({notes, deletedNoteIds, isSilent, credentials, nostrP
442442
}
443443
}
444444

445-
if (!credentials.secretAccessKey && !gitCredentials?.repoUrl && !gdriveStore?.connected && !credentials.pinataJwt && !credentials.pinataApiKey) return {
445+
if (!credentials.secretAccessKey && !gitCredentials?.repoUrl && !gdriveStore?.connected && !credentials.pinataJwt && !credentials.pinataApiKey && !credentials.useDirectIpfs) return {
446446
success: false,
447447
error: 'No sync provider configured (S3, Git, IPFS, or GDrive)',
448448
};
@@ -743,7 +743,7 @@ async function uploadNote({note, credentials, nostrPrivateKey, nostrRelays, gitC
743743
const shouldUploadToNostr = nostrPrivateKey && (!nostrMap.has(note.id) || localDate > new Date(nostrMap.get(note.id).updatedAt || nostrMap.get(note.id).createdAt));
744744
const shouldUploadToGit = gitCredentials?.repoUrl && typeof _GLOBAL.uploadNoteToGit === 'function' && (!gitMap.has(note.id) || localDate > new Date(gitMap.get(note.id).updatedAt || gitMap.get(note.id).createdAt));
745745
const shouldUploadToGDrive = gdriveStore?.connected && typeof _GLOBAL.uploadNoteToGoogleDrive === 'function' && (!gdriveMap.has(note.id) || localDate > new Date(gdriveMap.get(note.id).updatedAt || gdriveMap.get(note.id).createdAt));
746-
const shouldUploadToIPFS = (credentials.pinataJwt || credentials.pinataApiKey) && typeof _GLOBAL.uploadNoteToIPFS === 'function' && (!ipfsMap.has(note.id) || localDate > new Date(ipfsMap.get(note.id).updatedAt));
746+
const shouldUploadToIPFS = (credentials.useDirectIpfs || credentials.pinataJwt || credentials.pinataApiKey) && typeof _GLOBAL.uploadNoteToIPFS === 'function' && (!ipfsMap.has(note.id) || localDate > new Date(ipfsMap.get(note.id).updatedAt));
747747

748748
await Promise.allSettled([
749749
shouldUploadToS3 ? uploadNoteToS3(note, credentials) : null,
@@ -796,7 +796,7 @@ async function listNotes({credentials, nostrPrivateKey, nostrRelays, lastSync, g
796796
}
797797

798798
let ipfsNotesPromise;
799-
if ((credentials?.pinataJwt || credentials?.pinataApiKey) && typeof window.listNotesInIPFS === 'function') {
799+
if ((credentials?.useDirectIpfs || credentials?.pinataJwt || credentials?.pinataApiKey) && typeof window.listNotesInIPFS === 'function') {
800800
ipfsNotesPromise = window.listNotesInIPFS(credentials);
801801
} else {
802802
ipfsNotesPromise = Promise.resolve([]);
@@ -958,7 +958,7 @@ async function deleteNoteFromRemotes({noteId, credentials, nostrPrivateKey, nost
958958
promises.push(_GLOBAL.deleteNoteFromGit(remoteMeta?.path || noteId, gitCredentials));
959959
}
960960

961-
if ((credentials?.pinataJwt || credentials?.pinataApiKey) && typeof window.deleteNoteFromIPFS === 'function') {
961+
if ((credentials?.useDirectIpfs || credentials?.pinataJwt || credentials?.pinataApiKey) && typeof window.deleteNoteFromIPFS === 'function') {
962962
promises.push(window.deleteNoteFromIPFS(noteId, credentials));
963963
}
964964

src/index.html

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,14 @@ <h3 class="text-lg font-bold">Settings <span class="text-sm" x-text="'v.'+appVer
379379
<label for="ipfs-gateway" class="text-right">IPFS Gateway</label>
380380
<input id="ipfs-gateway" x-model="ipfsGateway" class="col-span-3 border p-1" placeholder="https://gateway.pinata.cloud/ipfs/" />
381381
</div>
382+
<div class="grid grid-cols-4 items-center gap-2">
383+
<label for="use-direct-ipfs" class="text-right">Direct IPFS</label>
384+
<input id="use-direct-ipfs" type="checkbox" x-model="useDirectIpfs" class="col-span-3 h-4 w-4" />
385+
</div>
386+
<div class="grid grid-cols-4 items-center gap-2" x-show="useDirectIpfs">
387+
<label for="ipfs-root-cid" class="text-right">Root CID</label>
388+
<input id="ipfs-root-cid" x-model="ipfsRootCid" class="col-span-3 border p-1 bg-gray-100" placeholder="Index File CID..." readonly />
389+
</div>
382390
</fieldset>
383391

384392
<fieldset class="col-span-2 lg:col-span-1 grid gap-2 border-t pt-4 text-sm">
@@ -489,7 +497,7 @@ <h4 class="text-sm font-bold" x-text="toast.title"></h4>
489497
<script defer src="/s3.js?v=___VERSION___"></script>
490498
<script defer src="/nostr.js?v=___VERSION___"></script>
491499
<script defer src="/gdrive.js?v=___VERSION___"></script>
492-
<script defer src="/ipfs.js?v=___VERSION___"></script>
500+
<script type="module" src="/ipfs.js?v=___VERSION___"></script>
493501
<script defer src="/helpers.js?v=___VERSION___"></script>
494502
<script defer src="/index.js?v=___VERSION___" fetchpriority="high"></script>
495503

src/index.js

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,15 @@ document.addEventListener('alpine:init', () => { Alpine.data('mainApp', () => ({
155155

156156
// --- Main App Init ---
157157
init() {
158+
// Set up global helper for IPFS root CID updates
159+
window.updateIpfsRootCid = async (newCid) => {
160+
if (!newCid || newCid === this.ipfsRootCid) return;
161+
console.log(`Updating IPFS Root CID to: ${newCid}`);
162+
this.ipfsRootCid = newCid;
163+
// Trigger a save to persist the new CID in encrypted settings
164+
await this.handleSave(true);
165+
};
166+
158167
// Check hash early to prioritize editor loading
159168
if (window.location.hash === '#new_note') {
160169
this.createNewNote();
@@ -2367,6 +2376,8 @@ document.addEventListener('alpine:init', () => { Alpine.data('mainApp', () => ({
23672376
pinataApiKey: '',
23682377
pinataSecretApiKey: '',
23692378
ipfsGateway: '',
2379+
useDirectIpfs: true,
2380+
ipfsRootCid: '',
23702381

23712382
aiApiKey: '',
23722383
aiApiRoute: '',
@@ -2403,6 +2414,8 @@ document.addEventListener('alpine:init', () => { Alpine.data('mainApp', () => ({
24032414
this.pinataApiKey = decrypted.pinataApiKey || '';
24042415
this.pinataSecretApiKey = decrypted.pinataSecretApiKey || '';
24052416
this.ipfsGateway = decrypted.ipfsGateway || '';
2417+
this.useDirectIpfs = decrypted.useDirectIpfs !== undefined ? !!decrypted.useDirectIpfs : true;
2418+
this.ipfsRootCid = decrypted.ipfsRootCid || '';
24062419
this.aiApiKey = decrypted.aiApiKey || '';
24072420
this.aiApiRoute = decrypted.aiApiRoute || '';
24082421
this.aiModel = decrypted.aiModel || '';
@@ -2419,9 +2432,9 @@ document.addEventListener('alpine:init', () => { Alpine.data('mainApp', () => ({
24192432
this.gitEmail = decrypted.gitEmail || '';
24202433
},
24212434

2422-
async handleSave() {
2435+
async handleSave(isAutoSave = false) {
24232436
this.isSavingSettings = true;
2424-
localStorage.setItem('feathernote-nostr-relays', this.nostrRelays);
2437+
if (!isAutoSave) localStorage.setItem('feathernote-nostr-relays', this.nostrRelays);
24252438

24262439
// Get existing settings to preserve the secret key if not changed
24272440
const storedData = await getEncryptedSettingsDB();
@@ -2444,6 +2457,8 @@ document.addEventListener('alpine:init', () => { Alpine.data('mainApp', () => ({
24442457
pinataApiKey: this.pinataApiKey,
24452458
pinataSecretApiKey: this.pinataSecretApiKey,
24462459
ipfsGateway: this.ipfsGateway,
2460+
useDirectIpfs: this.useDirectIpfs,
2461+
ipfsRootCid: this.ipfsRootCid,
24472462
aiApiKey: this.aiApiKey,
24482463
aiApiRoute: this.aiApiRoute,
24492464
aiModel: this.aiModel,

0 commit comments

Comments
 (0)