@wovin/core 0.2.2 → 0.3.2

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.
Files changed (112) hide show
  1. package/dist/applog/applog-utils.d.ts +25 -1
  2. package/dist/applog/applog-utils.d.ts.map +1 -1
  3. package/dist/applog/datom-types.d.ts.map +1 -1
  4. package/dist/applog.js +5 -3
  5. package/dist/blockstore.js +2 -0
  6. package/dist/blockstore.js.map +1 -1
  7. package/dist/{chunk-SHUHRHOT.js → chunk-4MKPGQIM.js} +17 -18
  8. package/dist/chunk-4MKPGQIM.js.map +1 -0
  9. package/dist/chunk-64EJIJAJ.js +17 -0
  10. package/dist/chunk-64EJIJAJ.js.map +1 -0
  11. package/dist/{chunk-HUIQ54TT.js → chunk-6CSJTSQP.js} +3 -3
  12. package/dist/chunk-7QEGHKR4.js +17 -0
  13. package/dist/chunk-7QEGHKR4.js.map +1 -0
  14. package/dist/{chunk-BLF5MAWU.js → chunk-BIYQEX3N.js} +2 -2
  15. package/dist/{chunk-6ALNRM3J.js → chunk-H3JNNTVP.js} +21 -8
  16. package/dist/chunk-H3JNNTVP.js.map +1 -0
  17. package/dist/{chunk-OC6Z6CQW.js → chunk-H4YVJKB7.js} +2 -2
  18. package/dist/chunk-ICBK7NC4.js +27 -0
  19. package/dist/chunk-ICBK7NC4.js.map +1 -0
  20. package/dist/chunk-N5QPZNKD.js +288 -0
  21. package/dist/chunk-N5QPZNKD.js.map +1 -0
  22. package/dist/{chunk-3SUFNJEZ.js → chunk-N7SEGHU4.js} +7 -2
  23. package/dist/{chunk-3SUFNJEZ.js.map → chunk-N7SEGHU4.js.map} +1 -1
  24. package/dist/chunk-XF4DWOAE.js +25 -0
  25. package/dist/chunk-XF4DWOAE.js.map +1 -0
  26. package/dist/index.js +20 -10
  27. package/dist/ipfs/car.d.ts.map +1 -1
  28. package/dist/ipfs.js +4 -4
  29. package/dist/ipns/gateway-resolver.d.ts +21 -0
  30. package/dist/ipns/gateway-resolver.d.ts.map +1 -0
  31. package/dist/ipns/ipns-record.d.ts +46 -7
  32. package/dist/ipns/ipns-record.d.ts.map +1 -1
  33. package/dist/ipns/ipns-w3name.d.ts +45 -0
  34. package/dist/ipns/ipns-w3name.d.ts.map +1 -0
  35. package/dist/ipns/ipns-watcher.d.ts +190 -0
  36. package/dist/ipns/ipns-watcher.d.ts.map +1 -0
  37. package/dist/ipns.d.ts +3 -0
  38. package/dist/ipns.d.ts.map +1 -1
  39. package/dist/ipns.js +591 -14
  40. package/dist/ipns.js.map +1 -1
  41. package/dist/pubsub/snap-push.d.ts +2 -2
  42. package/dist/pubsub/snap-push.d.ts.map +1 -1
  43. package/dist/pubsub.js +4 -4
  44. package/dist/query/epoch-snapshot.d.ts +205 -0
  45. package/dist/query/epoch-snapshot.d.ts.map +1 -0
  46. package/dist/query.d.ts +1 -0
  47. package/dist/query.d.ts.map +1 -1
  48. package/dist/query.js +12 -4
  49. package/dist/retrieve.js +4 -4
  50. package/dist/thread.js +1 -1
  51. package/dist/viewmodel/adapters/arktype.d.ts +33 -0
  52. package/dist/viewmodel/adapters/arktype.d.ts.map +1 -0
  53. package/dist/viewmodel/adapters/arktype.js +7 -0
  54. package/dist/viewmodel/adapters/arktype.js.map +1 -0
  55. package/dist/viewmodel/adapters/typebox.d.ts +35 -0
  56. package/dist/viewmodel/adapters/typebox.d.ts.map +1 -0
  57. package/dist/viewmodel/adapters/typebox.js +7 -0
  58. package/dist/viewmodel/adapters/typebox.js.map +1 -0
  59. package/dist/viewmodel/adapters/typia.d.ts +40 -0
  60. package/dist/viewmodel/adapters/typia.d.ts.map +1 -0
  61. package/dist/viewmodel/adapters/typia.js +7 -0
  62. package/dist/viewmodel/adapters/typia.js.map +1 -0
  63. package/dist/viewmodel/adapters/zod.d.ts +30 -0
  64. package/dist/viewmodel/adapters/zod.d.ts.map +1 -0
  65. package/dist/viewmodel/adapters/zod.js +7 -0
  66. package/dist/viewmodel/adapters/zod.js.map +1 -0
  67. package/dist/viewmodel/builder.d.ts +40 -0
  68. package/dist/viewmodel/builder.d.ts.map +1 -0
  69. package/dist/viewmodel/examples/all-adapters.d.ts +26 -0
  70. package/dist/viewmodel/examples/all-adapters.d.ts.map +1 -0
  71. package/dist/viewmodel/factory.d.ts +38 -0
  72. package/dist/viewmodel/factory.d.ts.map +1 -0
  73. package/dist/viewmodel/index.d.ts +10 -0
  74. package/dist/viewmodel/index.d.ts.map +1 -0
  75. package/dist/viewmodel/index.js +313 -0
  76. package/dist/viewmodel/index.js.map +1 -0
  77. package/dist/viewmodel/schema-adapter.d.ts +16 -0
  78. package/dist/viewmodel/schema-adapter.d.ts.map +1 -0
  79. package/dist/viewmodel/types.d.ts +97 -0
  80. package/dist/viewmodel/types.d.ts.map +1 -0
  81. package/package.json +29 -3
  82. package/src/applog/applog-utils.test.ts +43 -1
  83. package/src/applog/applog-utils.ts +38 -21
  84. package/src/applog/datom-types.ts +2 -2
  85. package/src/applog/object-values.test.ts +9 -9
  86. package/src/ipfs/car.ts +14 -2
  87. package/src/ipns/gateway-resolver.ts +63 -0
  88. package/src/ipns/ipns-record.ts +158 -29
  89. package/src/ipns/ipns-w3name.ts +207 -0
  90. package/src/ipns/ipns-watcher.ts +608 -0
  91. package/src/ipns.ts +3 -0
  92. package/src/pubsub/snap-push.ts +6 -5
  93. package/src/query/epoch-snapshot.test.ts +594 -0
  94. package/src/query/epoch-snapshot.ts +392 -0
  95. package/src/query.ts +1 -0
  96. package/src/viewmodel/adapters/arktype.ts +44 -0
  97. package/src/viewmodel/adapters/typebox.ts +59 -0
  98. package/src/viewmodel/adapters/typia.ts +50 -0
  99. package/src/viewmodel/adapters/zod.ts +55 -0
  100. package/src/viewmodel/builder.ts +71 -0
  101. package/src/viewmodel/examples/all-adapters.ts +206 -0
  102. package/src/viewmodel/factory.ts +330 -0
  103. package/src/viewmodel/index.ts +22 -0
  104. package/src/viewmodel/schema-adapter.ts +27 -0
  105. package/src/viewmodel/types.ts +152 -0
  106. package/dist/chunk-22WDFLXO.js +0 -138
  107. package/dist/chunk-22WDFLXO.js.map +0 -1
  108. package/dist/chunk-6ALNRM3J.js.map +0 -1
  109. package/dist/chunk-SHUHRHOT.js.map +0 -1
  110. /package/dist/{chunk-HUIQ54TT.js.map → chunk-6CSJTSQP.js.map} +0 -0
  111. /package/dist/{chunk-BLF5MAWU.js.map → chunk-BIYQEX3N.js.map} +0 -0
  112. /package/dist/{chunk-OC6Z6CQW.js.map → chunk-H4YVJKB7.js.map} +0 -0
@@ -0,0 +1,608 @@
1
+ import { Logger } from 'besonders-logger'
2
+ import { CID } from 'multiformats/cid'
3
+ import ReconnectingWebSocket, { type Options as PartysocketOptions } from 'partysocket/ws'
4
+
5
+ export type { PartysocketOptions }
6
+
7
+ const { WARN, LOG, DEBUG, ERROR } = Logger.setup(Logger.INFO) // eslint-disable-line unused-imports/no-unused-vars
8
+
9
+ /**
10
+ * `nameBaseUrl` is required for both `watchNameRaw` and `IpnsWatcher`. The
11
+ * legacy `https://name.web3.storage` endpoint (used by the old hardcoded
12
+ * `NAME_WS_URL`/`NAME_HTTP_URL` constants) is **shut down** — these classes
13
+ * will not work without a real, configured naming service that supports
14
+ * WebSocket subscriptions to `/name/<ipns>/watch` and HTTP GET on `/name/<ipns>`.
15
+ *
16
+ * As of writing no such public service exists, so most callers should expect
17
+ * these to fail at runtime and handle the error in their `onError` handler.
18
+ */
19
+ function buildWsUrl(nameBaseUrl: string, name: string) {
20
+ const base = nameBaseUrl.replace(/\/+$/, '').replace(/^http/, 'ws')
21
+ return `${base}/${name}/watch`
22
+ }
23
+ function buildHttpUrl(nameBaseUrl: string, name: string) {
24
+ const base = nameBaseUrl.replace(/\/+$/, '')
25
+ return `${base}/${name}`
26
+ }
27
+
28
+ function requireNameBaseUrl(nameBaseUrl: string | undefined, fn: string): string {
29
+ if (!nameBaseUrl) {
30
+ throw new Error(
31
+ `[${fn}] nameBaseUrl is required. The legacy default `
32
+ + `https://name.web3.storage is shut down. Pass the base URL of a naming `
33
+ + `service that supports WebSocket subscriptions to /name/<ipns>/watch `
34
+ + `and HTTP GET on /name/<ipns>.`,
35
+ )
36
+ }
37
+ return nameBaseUrl
38
+ }
39
+
40
+ export interface W3NameRecord {
41
+ value: string // e.g. "/ipfs/bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"
42
+ seq?: number
43
+ validity?: string
44
+ }
45
+
46
+ /**
47
+ * Debug info provided when a stale WebSocket connection is detected.
48
+ */
49
+ export interface StaleConnectionInfo {
50
+ /** When the WebSocket connection was established */
51
+ connectedAt: Date
52
+ /** When we last received a WebSocket message */
53
+ lastMessageAt: Date | null
54
+ /** How long since last message (ms) */
55
+ silenceDuration: number
56
+ /** The stale value from WebSocket */
57
+ staleValue: string | null
58
+ /** The current value from HTTP */
59
+ currentValue: string
60
+ }
61
+
62
+ /**
63
+ * Enriched IPNS update with parsed CID and change detection.
64
+ * Backwards compatible - `.value` still works as before.
65
+ */
66
+ export interface IpnsUpdate {
67
+ /** Raw IPNS value string (e.g. '/ipfs/bafy...') — same as W3NameRecord.value */
68
+ value: string
69
+ /** Parsed CID if value is valid IPFS path, null otherwise */
70
+ cid: CID | null
71
+ /** Previous value (null on first update) */
72
+ lastValue: string | null
73
+ /** Whether this is a change from lastValue */
74
+ isNew: boolean
75
+ /** Original W3NameRecord for access to seq/validity */
76
+ record: W3NameRecord
77
+ }
78
+
79
+ /**
80
+ * Parse CID from IPNS value string (e.g. "/ipfs/bafybeig...")
81
+ * @returns CID if valid, null otherwise
82
+ */
83
+ function parseCidFromIpnsValue(value: string): CID | null {
84
+ try {
85
+ // Strip /ipfs/ prefix if present
86
+ const cidStr = value.startsWith('/ipfs/') ? value.slice(6) : value
87
+ return CID.parse(cidStr)
88
+ } catch {
89
+ DEBUG('[parseCidFromIpnsValue] failed to parse:', value)
90
+ return null
91
+ }
92
+ }
93
+
94
+ export interface WatchRawOptions {
95
+ /** Called when the IPNS record is updated */
96
+ onUpdate: (record: W3NameRecord) => void
97
+ /** Called when an error occurs */
98
+ onError?: (error: Event | Error) => void
99
+ /** Called when the connection is opened */
100
+ onOpen?: () => void
101
+ /** Called when the connection is closed */
102
+ onClose?: (event: CloseEvent) => void
103
+ }
104
+
105
+ export interface WatchRawSubscription {
106
+ /** Close the WebSocket connection */
107
+ close: () => void
108
+ /** The underlying WebSocket instance */
109
+ ws: WebSocket
110
+ }
111
+
112
+ /**
113
+ * Low-level WebSocket watcher for IPNS (no reconnect logic).
114
+ * Use this when you want full control over connection lifecycle.
115
+ * For most cases, prefer `watchName` or `IpnsWatcher` which handle reconnection.
116
+ *
117
+ * @param nameBaseUrl - Base URL of a naming service that supports `/name/<ipns>/watch` (WebSocket) and `/name/<ipns>` (HTTP)
118
+ * @param name - The IPNS name/key to watch
119
+ * @param options - Callback options
120
+ * @returns Subscription with close() and ws
121
+ *
122
+ * @example
123
+ * ```ts
124
+ * const sub = watchNameRaw('https://name.example.com', 'k51qzi5u...', {
125
+ * onUpdate: (record) => console.log('Update:', record.value),
126
+ * onClose: () => console.log('Disconnected - handle reconnect yourself'),
127
+ * })
128
+ * ```
129
+ */
130
+ export function watchNameRaw(nameBaseUrl: string, name: string, options: WatchRawOptions): WatchRawSubscription {
131
+ const resolvedBase = requireNameBaseUrl(nameBaseUrl, 'watchNameRaw')
132
+ const url = buildWsUrl(resolvedBase, name)
133
+ DEBUG('[watchNameRaw] connecting to', url)
134
+
135
+ const ws = new WebSocket(url)
136
+
137
+ ws.onopen = () => {
138
+ LOG('[watchNameRaw] connected to', name)
139
+ options.onOpen?.()
140
+ }
141
+
142
+ ws.onmessage = (event) => {
143
+ try {
144
+ const record: W3NameRecord = JSON.parse(event.data)
145
+ DEBUG('[watchNameRaw] received update for', name, record)
146
+ options.onUpdate(record)
147
+ } catch (err) {
148
+ WARN('[watchNameRaw] failed to parse message:', event.data, err)
149
+ options.onError?.(err instanceof Error ? err : new Error(String(err)))
150
+ }
151
+ }
152
+
153
+ ws.onerror = (event) => {
154
+ const errorMsg = event instanceof ErrorEvent ? event.message : 'WebSocket error'
155
+ WARN('[watchNameRaw] error for', name, ':', errorMsg)
156
+ options.onError?.(new Error(errorMsg))
157
+ }
158
+
159
+ ws.onclose = (event) => {
160
+ DEBUG('[watchNameRaw] closed for', name, 'code:', event.code)
161
+ options.onClose?.(event)
162
+ }
163
+
164
+ return {
165
+ close: () => {
166
+ DEBUG('[watchNameRaw] closing connection for', name)
167
+ ws.close()
168
+ },
169
+ ws,
170
+ }
171
+ }
172
+
173
+ export interface IpnsWatcherOptions {
174
+ /** Called when the IPNS record is updated (enriched payload with CID and change detection) */
175
+ onUpdate: (update: IpnsUpdate) => void | Promise<void>
176
+ /** Called when an error occurs */
177
+ onError?: (error: Error | Event) => void
178
+ /** Called when the connection is opened/reconnected */
179
+ onConnected?: () => void
180
+ /** Called when the connection is closed */
181
+ onDisconnected?: () => void
182
+ /** Fetch current IPNS state on first connect (default: false) */
183
+ fetchInitialState?: boolean
184
+ /** Fetch current IPNS state on reconnect to catch missed updates (default: true) */
185
+ catchUpOnReconnect?: boolean
186
+ /** If true, call onUpdate even when value hasn't changed (default: false) */
187
+ includeUnchanged?: boolean
188
+ /**
189
+ * Enable periodic liveness checks via HTTP to detect zombie connections (default: true).
190
+ * When enabled, periodically fetches current IPNS value and forces reconnect if it
191
+ * differs from the last WebSocket update.
192
+ */
193
+ livenessCheck?: boolean
194
+ /**
195
+ * Liveness check interval in milliseconds (default: 3600000 = 1 hour).
196
+ * Only used when livenessCheck is enabled.
197
+ */
198
+ livenessCheckInterval?: number
199
+ /**
200
+ * Called when a stale connection is detected (WebSocket missed updates).
201
+ * Provides debug info about the connection state.
202
+ */
203
+ onStaleConnection?: (info: StaleConnectionInfo) => void
204
+ /**
205
+ * Partysocket options (passed through to ReconnectingWebSocket).
206
+ * Useful options: startClosed, maxReconnectionDelay, minReconnectionDelay, etc.
207
+ * @see https://github.com/partykit/partykit/tree/main/packages/partysocket
208
+ */
209
+ wsOptions?: PartysocketOptions
210
+ }
211
+
212
+ /**
213
+ * Robust IPNS watcher with auto-reconnect and catch-up logic.
214
+ * Uses partysocket for reliable WebSocket reconnection.
215
+ *
216
+ * @example
217
+ * ```ts
218
+ * const watcher = new IpnsWatcher('k51qzi5uqu...', {
219
+ * onUpdate: (update) => console.log('New CID:', update.cid?.toString()),
220
+ * onError: (err) => console.error('Error:', err),
221
+ * })
222
+ *
223
+ * // Later, to stop watching:
224
+ * watcher.close()
225
+ * ```
226
+ */
227
+ const DEFAULT_LIVENESS_INTERVAL = 3600000 // 1 hour
228
+
229
+ export class IpnsWatcher {
230
+ private name: string
231
+ private nameBaseUrl: string
232
+ private ws: ReconnectingWebSocket
233
+ private lastKnownValue: string | null = null
234
+ private options: IpnsWatcherOptions
235
+ private isFirstConnect = true
236
+ private livenessTimer: ReturnType<typeof setInterval> | null = null
237
+ private connectedAt: Date | null = null
238
+ private lastMessageAt: Date | null = null
239
+
240
+ constructor(nameBaseUrl: string, name: string, options: IpnsWatcherOptions) {
241
+ this.nameBaseUrl = requireNameBaseUrl(nameBaseUrl, 'IpnsWatcher')
242
+ this.name = name
243
+ this.options = options
244
+
245
+ const url = buildWsUrl(this.nameBaseUrl, name)
246
+ DEBUG('[IpnsWatcher] creating for', name)
247
+
248
+ this.ws = new ReconnectingWebSocket(url, [], {
249
+ maxReconnectionDelay: 900000, // 15min
250
+ minReconnectionDelay: 5000,
251
+ reconnectionDelayGrowFactor: 2,
252
+ maxRetries: Infinity,
253
+ ...options.wsOptions,
254
+ })
255
+
256
+ this.ws.onopen = () => {
257
+ LOG('[IpnsWatcher] connected to', name)
258
+ this.connectedAt = new Date()
259
+ options.onConnected?.()
260
+
261
+ // Check for current state on first connect if requested
262
+ if (this.isFirstConnect && (options.fetchInitialState ?? false)) {
263
+ this.checkForMissedUpdates()
264
+ }
265
+ // Check for missed updates on reconnect
266
+ else if (!this.isFirstConnect && (options.catchUpOnReconnect ?? true)) {
267
+ this.checkForMissedUpdates()
268
+ }
269
+
270
+ this.isFirstConnect = false
271
+
272
+ // Start liveness checking (default: enabled)
273
+ if (options.livenessCheck !== false) {
274
+ this.startLivenessCheck()
275
+ }
276
+ }
277
+
278
+ this.ws.onmessage = (event) => {
279
+ this.lastMessageAt = new Date()
280
+ try {
281
+ const record: W3NameRecord = JSON.parse(event.data as string)
282
+ DEBUG('[IpnsWatcher] received update for', name, record)
283
+
284
+ const lastValue = this.lastKnownValue
285
+ const isNew = record.value !== lastValue
286
+ const cid = parseCidFromIpnsValue(record.value)
287
+
288
+ // Skip unchanged values unless includeUnchanged is set
289
+ if (!isNew && !options.includeUnchanged) {
290
+ DEBUG('[IpnsWatcher] skipping unchanged value for', name)
291
+ return
292
+ }
293
+
294
+ // Update lastKnownValue after the skip check
295
+ this.lastKnownValue = record.value
296
+
297
+ const update: IpnsUpdate = {
298
+ value: record.value,
299
+ cid,
300
+ lastValue,
301
+ isNew,
302
+ record,
303
+ }
304
+
305
+ void options.onUpdate(update)
306
+ } catch (err) {
307
+ WARN('[IpnsWatcher] failed to parse message:', event.data, err)
308
+ options.onError?.(err instanceof Error ? err : new Error(String(err)))
309
+ }
310
+ }
311
+
312
+ this.ws.onerror = (event) => {
313
+ // Extract meaningful error info instead of logging entire ErrorEvent
314
+ const errorMsg = event instanceof ErrorEvent ? event.message : 'WebSocket error'
315
+
316
+ // "Unexpected EOF" is a normal disconnection - partysocket will auto-reconnect
317
+ // Log at INFO level as it's expected behavior, not an error
318
+ if (errorMsg === 'Unexpected EOF') {
319
+ LOG('[IpnsWatcher] error for', name, ':', errorMsg, '(auto-reconnect enabled)')
320
+ } else {
321
+ WARN('[IpnsWatcher] error for', name, ':', errorMsg)
322
+ }
323
+
324
+ // Still call the error handler for unexpected errors
325
+ if (errorMsg !== 'Unexpected EOF') {
326
+ options.onError?.(new Error(errorMsg))
327
+ }
328
+ }
329
+
330
+ this.ws.onclose = () => {
331
+ DEBUG('[IpnsWatcher] disconnected from', name)
332
+ this.stopLivenessCheck()
333
+ this.connectedAt = null
334
+ this.lastMessageAt = null
335
+ options.onDisconnected?.()
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Resolve current IPNS value via HTTP API to catch missed updates
341
+ */
342
+ private async checkForMissedUpdates(): Promise<void> {
343
+ try {
344
+ DEBUG('[IpnsWatcher] checking for missed updates for', this.name)
345
+ const response = await fetch(buildHttpUrl(this.nameBaseUrl, this.name))
346
+
347
+ if (!response.ok) {
348
+ if (response.status === 404) {
349
+ DEBUG('[IpnsWatcher] IPNS not yet published:', this.name)
350
+ return
351
+ }
352
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
353
+ }
354
+
355
+ const record: W3NameRecord = await response.json()
356
+ const lastValue = this.lastKnownValue
357
+ const isNew = record.value !== lastValue
358
+ const cid = parseCidFromIpnsValue(record.value)
359
+
360
+ // Skip unchanged values unless includeUnchanged is set
361
+ if (!isNew && !this.options.includeUnchanged) {
362
+ DEBUG('[IpnsWatcher] no new updates for', this.name)
363
+ return
364
+ }
365
+
366
+ const logMsg = lastValue === null
367
+ ? '[IpnsWatcher] fetched initial state for'
368
+ : '[IpnsWatcher] caught missed update for'
369
+ LOG(logMsg, this.name, {
370
+ previous: lastValue,
371
+ current: record.value,
372
+ })
373
+
374
+ // Update lastKnownValue after the skip check
375
+ this.lastKnownValue = record.value
376
+
377
+ const update: IpnsUpdate = {
378
+ value: record.value,
379
+ cid,
380
+ lastValue,
381
+ isNew,
382
+ record,
383
+ }
384
+
385
+ void this.options.onUpdate(update)
386
+ } catch (err) {
387
+ WARN('[IpnsWatcher] failed to check for missed updates:', this.name, err)
388
+ this.options.onError?.(err instanceof Error ? err : new Error(String(err)))
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Start periodic liveness checks to detect zombie connections.
394
+ */
395
+ private startLivenessCheck(): void {
396
+ this.stopLivenessCheck() // Clear any existing timer
397
+ const interval = this.options.livenessCheckInterval ?? DEFAULT_LIVENESS_INTERVAL
398
+ DEBUG('[IpnsWatcher] starting liveness check for', this.name, 'interval:', interval)
399
+
400
+ this.livenessTimer = setInterval(() => {
401
+ void this.performLivenessCheck()
402
+ }, interval)
403
+ }
404
+
405
+ /**
406
+ * Stop periodic liveness checks.
407
+ */
408
+ private stopLivenessCheck(): void {
409
+ if (this.livenessTimer !== null) {
410
+ DEBUG('[IpnsWatcher] stopping liveness check for', this.name)
411
+ clearInterval(this.livenessTimer)
412
+ this.livenessTimer = null
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Perform a single liveness check via HTTP.
418
+ * If the HTTP value differs from lastKnownValue, the connection is stale.
419
+ */
420
+ private async performLivenessCheck(): Promise<void> {
421
+ try {
422
+ DEBUG('[IpnsWatcher] performing liveness check for', this.name)
423
+ const response = await fetch(buildHttpUrl(this.nameBaseUrl, this.name))
424
+
425
+ if (!response.ok) {
426
+ if (response.status === 404) {
427
+ // IPNS not published - if we also have null, that's consistent
428
+ if (this.lastKnownValue === null) {
429
+ DEBUG('[IpnsWatcher] liveness check OK (both null) for', this.name)
430
+ return
431
+ }
432
+ // We have a value but HTTP says 404 - this shouldn't happen normally
433
+ WARN('[IpnsWatcher] liveness check inconsistent (we have value, HTTP 404) for', this.name)
434
+ return
435
+ }
436
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
437
+ }
438
+
439
+ const record: W3NameRecord = await response.json()
440
+
441
+ // Check if values match
442
+ if (record.value === this.lastKnownValue) {
443
+ DEBUG('[IpnsWatcher] liveness check OK for', this.name)
444
+ return
445
+ }
446
+
447
+ // Stale connection detected!
448
+ const now = new Date()
449
+ const silenceDuration = this.lastMessageAt
450
+ ? now.getTime() - this.lastMessageAt.getTime()
451
+ : this.connectedAt
452
+ ? now.getTime() - this.connectedAt.getTime()
453
+ : 0
454
+
455
+ const staleInfo: StaleConnectionInfo = {
456
+ connectedAt: this.connectedAt ?? now,
457
+ lastMessageAt: this.lastMessageAt,
458
+ silenceDuration,
459
+ staleValue: this.lastKnownValue,
460
+ currentValue: record.value,
461
+ }
462
+
463
+ WARN('[IpnsWatcher] stale connection detected for', this.name, {
464
+ connectedAt: staleInfo.connectedAt.toISOString(),
465
+ lastMessageAt: staleInfo.lastMessageAt?.toISOString() ?? 'never',
466
+ silenceDuration: `${Math.round(silenceDuration / 1000)}s`,
467
+ staleValue: staleInfo.staleValue,
468
+ currentValue: staleInfo.currentValue,
469
+ })
470
+
471
+ // Notify via callback
472
+ this.options.onStaleConnection?.(staleInfo)
473
+
474
+ // Fire immediate update with the current value
475
+ const lastValue = this.lastKnownValue
476
+ const cid = parseCidFromIpnsValue(record.value)
477
+ this.lastKnownValue = record.value
478
+
479
+ const update: IpnsUpdate = {
480
+ value: record.value,
481
+ cid,
482
+ lastValue,
483
+ isNew: true,
484
+ record,
485
+ }
486
+ void this.options.onUpdate(update)
487
+
488
+ // Force reconnect to get a fresh connection
489
+ LOG('[IpnsWatcher] forcing reconnect due to stale connection for', this.name)
490
+ this.ws.reconnect()
491
+ } catch (err) {
492
+ // Don't treat HTTP errors as stale - could be network issue
493
+ WARN('[IpnsWatcher] liveness check failed for', this.name, err)
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Manually start/reconnect the WebSocket.
499
+ * Only needed if you used `wsOptions: { startClosed: true }`.
500
+ */
501
+ start(): void {
502
+ LOG('[IpnsWatcher] starting watcher for', this.name)
503
+ this.ws.reconnect()
504
+ }
505
+
506
+ /**
507
+ * Alias for close() - for backward compatibility
508
+ */
509
+ stop(): void {
510
+ this.close()
511
+ }
512
+
513
+ /**
514
+ * Close the WebSocket connection and stop watching
515
+ */
516
+ close(): void {
517
+ LOG('[IpnsWatcher] closing watcher for', this.name)
518
+ this.stopLivenessCheck()
519
+ this.ws.close()
520
+ }
521
+
522
+ /**
523
+ * Get the last known IPNS value
524
+ */
525
+ get lastValue(): string | null {
526
+ return this.lastKnownValue
527
+ }
528
+
529
+ /**
530
+ * Get the WebSocket ready state
531
+ */
532
+ get readyState(): number {
533
+ return this.ws.readyState
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Create an IPNS watcher with auto-reconnect and catch-up logic.
539
+ * Convenience function that creates and returns an IpnsWatcher instance.
540
+ *
541
+ * @param nameBaseUrl - Base URL of a naming service that supports `/name/<ipns>/watch` (WebSocket) and `/name/<ipns>` (HTTP)
542
+ * @param name - The IPNS name/key to watch (e.g. "k51qzi5u...")
543
+ * @param options - Callback options for handling events
544
+ * @returns An IpnsWatcher instance with close() method
545
+ */
546
+ export function watchName(nameBaseUrl: string, name: string, options: IpnsWatcherOptions): IpnsWatcher {
547
+ return new IpnsWatcher(nameBaseUrl, name, options)
548
+ }
549
+
550
+ /**
551
+ * Watch an IPNS name and return updates as an async iterator.
552
+ * Includes auto-reconnect - iterator continues through disconnections.
553
+ *
554
+ * @param nameBaseUrl - Base URL of a naming service that supports `/name/<ipns>/watch` (WebSocket) and `/name/<ipns>` (HTTP)
555
+ * @param name - The IPNS name/key to watch
556
+ * @param signal - Optional AbortSignal to stop the watch
557
+ *
558
+ * @example
559
+ * ```ts
560
+ * const controller = new AbortController()
561
+ * for await (const update of watchNameIterator('https://name.example.com', 'k51qzi5u...', controller.signal)) {
562
+ * console.log('Update:', update.cid?.toString())
563
+ * }
564
+ * ```
565
+ */
566
+ export async function* watchNameIterator(
567
+ nameBaseUrl: string,
568
+ name: string,
569
+ signal?: AbortSignal,
570
+ ): AsyncGenerator<IpnsUpdate, void, unknown> {
571
+ const queue: IpnsUpdate[] = []
572
+ let resolve: (() => void) | null = null
573
+ let error: Error | null = null
574
+
575
+ const watcher = new IpnsWatcher(nameBaseUrl, name, {
576
+ onUpdate: (update) => {
577
+ queue.push(update)
578
+ resolve?.()
579
+ },
580
+ onError: (err) => {
581
+ error = err instanceof Error ? err : new Error('WebSocket error')
582
+ resolve?.()
583
+ },
584
+ })
585
+
586
+ signal?.addEventListener('abort', () => {
587
+ watcher.close()
588
+ })
589
+
590
+ try {
591
+ while (!signal?.aborted) {
592
+ if (queue.length > 0) {
593
+ yield queue.shift()!
594
+ } else if (error) {
595
+ // Log error but continue - partysocket will reconnect
596
+ WARN('[watchNameIterator] error occurred, continuing:', error)
597
+ error = null
598
+ } else {
599
+ await new Promise<void>((r) => {
600
+ resolve = r
601
+ })
602
+ resolve = null
603
+ }
604
+ }
605
+ } finally {
606
+ watcher.close()
607
+ }
608
+ }
package/src/ipns.ts CHANGED
@@ -1 +1,4 @@
1
+ export * from './ipns/gateway-resolver.ts'
1
2
  export * from './ipns/ipns-record.ts'
3
+ export * from './ipns/ipns-w3name.ts'
4
+ export * from './ipns/ipns-watcher.ts'
@@ -228,21 +228,22 @@ export async function chunkApplogs(applogCids: CID<unknown, 297, 18, 1>[], size
228
228
  DEBUG(`[chunkApplogs] ${applogCids.length} logs chunked into ${chunks.length}`, { applogCids, root, blocks, chunks, dagJson })
229
229
  return { rootCID: root.cid, blocks, chunks }
230
230
  }
231
- export async function unchunkApplogsBlock(block: SnapBlockLogsOrChunks, blockStore: BlockStoreish): Promise<CID[]> {
231
+ export async function unchunkApplogsBlock(block: SnapBlockLogsOrChunks | null | undefined, blockStore: BlockStoreish): Promise<CID[]> {
232
+ if (!block) return []
232
233
  if (isSnapBlockChunks(block)) {
233
234
  return (await Promise.all(
234
235
  block.chunks.map(async (chunkCid) => {
235
236
  const block = (await getDecodedBlock(blockStore, chunkCid)) as SnapBlockLogs
236
- if (!block.logs) throw ERROR(`Weird chunk`, block)
237
+ if (!block?.logs) throw ERROR(`Weird chunk`, block)
237
238
  return block.logs
238
239
  }),
239
240
  )).flat()
240
241
  } else {
241
- return block.logs
242
+ return block.logs ?? []
242
243
  }
243
244
  }
244
- export function isSnapBlockChunks(block: SnapBlockLogsOrChunks): block is SnapBlockChunks {
245
- return (block as any).chunks
245
+ export function isSnapBlockChunks(block: SnapBlockLogsOrChunks | null | undefined): block is SnapBlockChunks {
246
+ return !!block && 'chunks' in block
246
247
  }
247
248
  /**
248
249
  * @param applogs Encrypted or plain applogs