/ packages / browser-app / src / BlogData.ts
BlogData.ts
  1  import { BlogCID } from "./BlogCID"
  2  import type { Blog, Post } from "./blog_model"
  3  import { BrowserIPFSService } from "./browser_ipfs_service"
  4  
  5  const IPFS_HTTP_API: string = (typeof window !== 'undefined' && (window as any).__IPFS_HTTP_API__) || 'http://127.0.0.1:3399/api'
  6  
  7  async function httpPost(url: string, body: any) {
  8    const res = await fetch(url, {
  9      method: 'POST',
 10      headers: { 'Content-Type': 'application/json' },
 11      body: JSON.stringify(body)
 12    })
 13    if (!res.ok) {
 14      let msg = `${res.status} ${res.statusText}`
 15      try { msg += `: ${JSON.stringify(await res.json())}` } catch {}
 16      throw new Error(msg)
 17    }
 18    return res.json()
 19  }
 20  
 21  class BlogData {
 22  
 23    _blogData: Blog
 24    private _communityId?: string
 25    private _communityVersion: number = 0
 26    private _activeSSE?: EventSource
 27  
 28    setCommunityId(id: string) {
 29      this._communityId = (id || '').trim()
 30      // Increment version to invalidate any in-flight requests for prior community
 31      this._communityVersion++
 32      // Proactively close any active SSE subscription
 33      try { this._activeSSE?.close() } catch {}
 34      this._activeSSE = undefined
 35    }
 36  
 37    getCommunityId(): string | undefined {
 38      return this._communityId
 39    }
 40  
 41    async refreshContentFromIPFS(): Promise<void> {
 42      // No-op in browser app; Home component in common-app polls this.
 43      return
 44    }
 45  
 46    async loadFromIPFS(cid: string) {
 47      const ipfs = new BrowserIPFSService()
 48      // TODO: Add validation
 49  
 50      const res = await ipfs.getContent(cid)
 51      if (!res.ok) { throw new Error("failed to get content from ipfs") }
 52  
 53      return res.json()
 54  
 55      // console.log(await res.json())
 56      // console.log({res})
 57  
 58      // if (typeof res !== 'string') {
 59      //   throw new Error("Expected res to be a string")
 60      // }
 61  
 62      // const json = JSON.parse(res)
 63      // console.log({ res, json})
 64  
 65      // return json
 66    }
 67  
 68    // FIXME: this is still using the http gateway, instead
 69    //        should publish from helia in browser node.
 70    async persistToIPFS(): Promise<string> {
 71      const data = await this.getJSON()
 72  
 73      const res = await fetch('https://ipfs-http.rk-devbox1.internal.venerablesystems.com/ipfs-add',
 74        {
 75          method: "POST",
 76          body: JSON.stringify(data),
 77          headers: { "Content-Type": "application/json" }
 78        })
 79  
 80      const json = await res.json()
 81  
 82      console.log({newCid: json.cid})
 83  
 84      return json.cid
 85    }
 86  
 87    async blogData() {
 88      // Determine the communityId: use configured one or fallback to a default for demo
 89      const communityId = (this._communityId && this._communityId.length > 0)
 90        ? this._communityId
 91        : '6d41612fdbeec7ebf0486e3368a80434b19e639ef48ab348f10b0111758b2593'
 92      // First: request the CID via pubsub response topic
 93      const ns = 'pnc.zpress.' + communityId
 94      const reqTopic = ns + '.getCommunityDoc'
 95      const resTopic = ns + '.getCommunityDoc.res'
 96  
 97      // Capture current community version to detect staleness
 98      const version = this._communityVersion
 99  
100      // Subscribe first, then publish request; this returns either a CID string or an object with { cid }
101      const resPayload: any = await this.requestViaPubsub(resTopic, reqTopic, '{}', 15000, version)
102  
103      // Extract CID from response
104      let cid: string | undefined
105      if (typeof resPayload === 'string') {
106        cid = resPayload
107      } else if (resPayload && typeof resPayload.cid === 'string') {
108        cid = resPayload.cid
109      }
110      if (!cid) {
111        throw new Error('Did not receive CID from pubsub response')
112      }
113  
114      // Fetch the document from the new ipfs-http-interface endpoint and parse JSON
115      const resp = await fetch(`${IPFS_HTTP_API}/ipfs/cat/${encodeURIComponent(cid)}?encoding=utf8`)
116      if (!resp.ok) {
117        throw new Error(`Failed to fetch IPFS content for cid=${cid}: ${resp.status} ${resp.statusText}`)
118      }
119      const { data } = await resp.json()
120      let communityDoc: any
121      try {
122        communityDoc = JSON.parse(data)
123      } catch (e) {
124        throw new Error('IPFS content is not valid JSON')
125      }
126  
127      // Additionally: from community document, fetch all blog JSONs referenced in blogIndex and log them
128      try {
129        const index = communityDoc && typeof communityDoc === 'object' ? (communityDoc.blogIndex || {}) : {}
130        const cids: string[] = Array.from(new Set(Object.values(index).filter((v: any) => typeof v === 'string' && v.length > 0)))
131        const blogJsons: any[] = []
132        for (const bc of cids) {
133          try {
134            const r = await fetch(`${IPFS_HTTP_API}/ipfs/cat/${encodeURIComponent(bc)}?encoding=utf8`)
135            if (!r.ok) {
136              console.warn('Failed to fetch blog by CID', bc, r.status, r.statusText)
137              continue
138            }
139            const { data } = await r.json()
140            try {
141              const blogObj = JSON.parse(data)
142              blogJsons.push(blogObj)
143            } catch (e) {
144              console.warn('Blog content is not valid JSON for CID', bc)
145            }
146          } catch (e) {
147            console.warn('Error fetching blog CID', bc, e)
148          }
149        }
150        console.log('All blog JSONs from blogIndex:', blogJsons)
151        return blogJsons[0];
152      } catch {}
153  
154      // Do not save; just print and return community document as before
155      try { console.log('Community document (via CID):', communityDoc) } catch {}
156      return communityDoc
157    }
158  
159    // async setBlogData(data: BlogData) {
160    // }
161  
162    async getName() {
163      return (await this.blogData()).blog_name
164    }
165  
166  
167    async getPosts() {
168      return (await this.blogData()).posts
169    }
170  
171    async getPost(id: string) {
172      const posts = await this.getPosts()
173      return posts.find((post) => post.id === id)
174    }
175  
176    async addPost(post: Post) {
177      const current = await this.blogData();
178      current.posts.push(post)
179  
180      const cid = await this.persistToIPFS()
181      console.log("new cid: " + cid)
182  
183      const cidTracker = new BlogCID()
184      cidTracker.setCached(cid)
185      
186    }
187  
188    async getJSON() {
189      return this.blogData()
190    }
191  
192    private async requestViaPubsub(resTopic: string, reqTopic: string, requestData: string, timeoutMs = 15000, version: number): Promise<any> {
193      return new Promise<any>((resolve, reject) => {
194        const url = `${IPFS_HTTP_API}/pubsub/subscribe/${encodeURIComponent(resTopic)}`
195        const es = new EventSource(url)
196        this._activeSSE = es
197  
198        let done = false
199        const stale = () => version !== this._communityVersion
200        const cleanup = () => {
201          try { es.close() } catch {}
202          try { if (this._activeSSE === es) this._activeSSE = undefined } catch {}
203          clearTimeout(timer as any)
204        }
205  
206        const abortIfStale = () => {
207          if (!done && stale()) {
208            done = true
209            cleanup()
210            reject(new Error('Aborted due to community change'))
211            return true
212          }
213          return false
214        }
215  
216        const onError = (e: any) => {
217          if (abortIfStale()) return
218          // Ignore transient errors if not yet done; if connection never opens, timeout will handle it
219          // But if EventSource reports readyState as 2 (closed), reject
220          try {
221            if ((es as any).readyState === 2 && !done) {
222              done = true
223              cleanup()
224              reject(new Error('EventSource connection closed'))
225            }
226          } catch {}
227        }
228  
229        es.addEventListener('error', onError as any)
230  
231        es.addEventListener('open', async () => {
232          if (abortIfStale()) return
233          try {
234            await httpPost(`${IPFS_HTTP_API}/pubsub/publish`, {
235              topic: reqTopic,
236              data: requestData,
237              encoding: 'utf8'
238            })
239          } catch (err) {
240            if (!done) {
241              done = true
242              cleanup()
243              reject(err)
244            }
245          }
246        })
247  
248        es.addEventListener('message', (ev: MessageEvent) => {
249          if (done) return
250          if (abortIfStale()) return
251          try {
252            const payload = JSON.parse(ev.data)
253            let decoded = ''
254            if (payload && typeof payload === 'object' && typeof payload.data === 'string') {
255              // Base64 decode to UTF-8 string
256              decoded = atob(payload.data)
257            } else if (typeof payload === 'string') {
258              decoded = payload
259            }
260  
261            let parsed: any = decoded
262            try { parsed = JSON.parse(decoded) } catch {}
263  
264            done = true
265            cleanup()
266            resolve(parsed)
267          } catch (e) {
268            done = true
269            cleanup()
270            reject(e)
271          }
272        })
273  
274        const timer = setTimeout(() => {
275          if (done) return
276          if (abortIfStale()) return
277          done = true
278          cleanup()
279          reject(new Error('Timeout waiting for community document'))
280        }, timeoutMs)
281      })
282    }
283  }
284  
285  export { BlogData }