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 }