@wovin/core 0.2.0 → 0.3.0
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/dist/applog/applog-utils.d.ts +15 -0
- package/dist/applog/applog-utils.d.ts.map +1 -1
- package/dist/applog/datom-types.d.ts +63 -7
- package/dist/applog/datom-types.d.ts.map +1 -1
- package/dist/applog.js +7 -1
- package/dist/blockstore.js +2 -0
- package/dist/blockstore.js.map +1 -1
- package/dist/{chunk-L5EEEGE6.js → chunk-2OXLPZQI.js} +747 -679
- package/dist/chunk-2OXLPZQI.js.map +1 -0
- package/dist/{chunk-QZXKQCAY.js → chunk-2PJFLZRC.js} +7 -2
- package/dist/{chunk-QZXKQCAY.js.map → chunk-2PJFLZRC.js.map} +1 -1
- package/dist/chunk-64EJIJAJ.js +17 -0
- package/dist/chunk-64EJIJAJ.js.map +1 -0
- package/dist/chunk-7QEGHKR4.js +17 -0
- package/dist/chunk-7QEGHKR4.js.map +1 -0
- package/dist/{chunk-PD3C7XUM.js → chunk-EHO2BFFY.js} +2 -2
- package/dist/chunk-ICBK7NC4.js +27 -0
- package/dist/chunk-ICBK7NC4.js.map +1 -0
- package/dist/{chunk-CPSDKFBG.js → chunk-OKXRRWNS.js} +5 -14
- package/dist/chunk-OKXRRWNS.js.map +1 -0
- package/dist/{chunk-3WZVG277.js → chunk-Q4EMPWA3.js} +17 -9
- package/dist/chunk-Q4EMPWA3.js.map +1 -0
- package/dist/{chunk-J2FDHGOZ.js → chunk-VGIACGWX.js} +3 -3
- package/dist/{chunk-3JZMOEOD.js → chunk-WVW4YXB5.js} +2 -2
- package/dist/chunk-XF4DWOAE.js +25 -0
- package/dist/chunk-XF4DWOAE.js.map +1 -0
- package/dist/index.js +17 -9
- package/dist/ipfs/car.d.ts.map +1 -1
- package/dist/ipfs.js +4 -4
- package/dist/ipns/gateway-resolver.d.ts +21 -0
- package/dist/ipns/gateway-resolver.d.ts.map +1 -0
- package/dist/ipns/ipns-record.d.ts +28 -7
- package/dist/ipns/ipns-record.d.ts.map +1 -1
- package/dist/ipns/ipns-w3name.d.ts +15 -0
- package/dist/ipns/ipns-w3name.d.ts.map +1 -0
- package/dist/ipns/ipns-watcher.d.ts +190 -0
- package/dist/ipns/ipns-watcher.d.ts.map +1 -0
- package/dist/ipns.d.ts +3 -0
- package/dist/ipns.d.ts.map +1 -1
- package/dist/ipns.js +488 -8
- package/dist/ipns.js.map +1 -1
- package/dist/pubsub/snap-push.d.ts +2 -2
- package/dist/pubsub/snap-push.d.ts.map +1 -1
- package/dist/pubsub.js +4 -4
- package/dist/query/basic.d.ts +3 -3
- package/dist/query/basic.d.ts.map +1 -1
- package/dist/query/entity-collection.d.ts.map +1 -1
- package/dist/query/matchers.d.ts +12 -1
- package/dist/query/matchers.d.ts.map +1 -1
- package/dist/query.js +7 -5
- package/dist/retrieve.js +4 -4
- package/dist/thread/indexes.d.ts +3 -2
- package/dist/thread/indexes.d.ts.map +1 -1
- package/dist/thread.js +1 -1
- package/dist/viewmodel/adapters/arktype.d.ts +33 -0
- package/dist/viewmodel/adapters/arktype.d.ts.map +1 -0
- package/dist/viewmodel/adapters/arktype.js +7 -0
- package/dist/viewmodel/adapters/arktype.js.map +1 -0
- package/dist/viewmodel/adapters/typebox.d.ts +35 -0
- package/dist/viewmodel/adapters/typebox.d.ts.map +1 -0
- package/dist/viewmodel/adapters/typebox.js +7 -0
- package/dist/viewmodel/adapters/typebox.js.map +1 -0
- package/dist/viewmodel/adapters/typia.d.ts +40 -0
- package/dist/viewmodel/adapters/typia.d.ts.map +1 -0
- package/dist/viewmodel/adapters/typia.js +7 -0
- package/dist/viewmodel/adapters/typia.js.map +1 -0
- package/dist/viewmodel/adapters/zod.d.ts +30 -0
- package/dist/viewmodel/adapters/zod.d.ts.map +1 -0
- package/dist/viewmodel/adapters/zod.js +7 -0
- package/dist/viewmodel/adapters/zod.js.map +1 -0
- package/dist/viewmodel/builder.d.ts +40 -0
- package/dist/viewmodel/builder.d.ts.map +1 -0
- package/dist/viewmodel/examples/all-adapters.d.ts +26 -0
- package/dist/viewmodel/examples/all-adapters.d.ts.map +1 -0
- package/dist/viewmodel/factory.d.ts +38 -0
- package/dist/viewmodel/factory.d.ts.map +1 -0
- package/dist/viewmodel/index.d.ts +10 -0
- package/dist/viewmodel/index.d.ts.map +1 -0
- package/dist/viewmodel/index.js +313 -0
- package/dist/viewmodel/index.js.map +1 -0
- package/dist/viewmodel/schema-adapter.d.ts +16 -0
- package/dist/viewmodel/schema-adapter.d.ts.map +1 -0
- package/dist/viewmodel/types.d.ts +97 -0
- package/dist/viewmodel/types.d.ts.map +1 -0
- package/package.json +29 -3
- package/src/applog/applog-utils.ts +48 -4
- package/src/applog/datom-types.ts +24 -5
- package/src/applog/object-values.test.ts +106 -0
- package/src/ipfs/car.ts +8 -2
- package/src/ipns/gateway-resolver.ts +63 -0
- package/src/ipns/ipns-record.ts +68 -17
- package/src/ipns/ipns-w3name.ts +103 -0
- package/src/ipns/ipns-watcher.ts +607 -0
- package/src/ipns.ts +3 -0
- package/src/pubsub/snap-push.ts +8 -6
- package/src/query/entity-collection.ts +2 -1
- package/src/query/matchers.ts +23 -1
- package/src/thread/basic.ts +2 -2
- package/src/thread/indexes.ts +15 -9
- package/src/viewmodel/adapters/arktype.ts +44 -0
- package/src/viewmodel/adapters/typebox.ts +59 -0
- package/src/viewmodel/adapters/typia.ts +50 -0
- package/src/viewmodel/adapters/zod.ts +55 -0
- package/src/viewmodel/builder.ts +71 -0
- package/src/viewmodel/examples/all-adapters.ts +206 -0
- package/src/viewmodel/factory.ts +330 -0
- package/src/viewmodel/index.ts +22 -0
- package/src/viewmodel/schema-adapter.ts +27 -0
- package/src/viewmodel/types.ts +152 -0
- package/dist/chunk-3WZVG277.js.map +0 -1
- package/dist/chunk-CPSDKFBG.js.map +0 -1
- package/dist/chunk-L5EEEGE6.js.map +0 -1
- /package/dist/{chunk-PD3C7XUM.js.map → chunk-EHO2BFFY.js.map} +0 -0
- /package/dist/{chunk-J2FDHGOZ.js.map → chunk-VGIACGWX.js.map} +0 -0
- /package/dist/{chunk-3JZMOEOD.js.map → chunk-WVW4YXB5.js.map} +0 -0
|
@@ -0,0 +1,607 @@
|
|
|
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
|
+
WARN('[watchNameRaw] error for', name, event)
|
|
155
|
+
options.onError?.(event)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
ws.onclose = (event) => {
|
|
159
|
+
DEBUG('[watchNameRaw] closed for', name, 'code:', event.code)
|
|
160
|
+
options.onClose?.(event)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
close: () => {
|
|
165
|
+
DEBUG('[watchNameRaw] closing connection for', name)
|
|
166
|
+
ws.close()
|
|
167
|
+
},
|
|
168
|
+
ws,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface IpnsWatcherOptions {
|
|
173
|
+
/** Called when the IPNS record is updated (enriched payload with CID and change detection) */
|
|
174
|
+
onUpdate: (update: IpnsUpdate) => void | Promise<void>
|
|
175
|
+
/** Called when an error occurs */
|
|
176
|
+
onError?: (error: Error | Event) => void
|
|
177
|
+
/** Called when the connection is opened/reconnected */
|
|
178
|
+
onConnected?: () => void
|
|
179
|
+
/** Called when the connection is closed */
|
|
180
|
+
onDisconnected?: () => void
|
|
181
|
+
/** Fetch current IPNS state on first connect (default: false) */
|
|
182
|
+
fetchInitialState?: boolean
|
|
183
|
+
/** Fetch current IPNS state on reconnect to catch missed updates (default: true) */
|
|
184
|
+
catchUpOnReconnect?: boolean
|
|
185
|
+
/** If true, call onUpdate even when value hasn't changed (default: false) */
|
|
186
|
+
includeUnchanged?: boolean
|
|
187
|
+
/**
|
|
188
|
+
* Enable periodic liveness checks via HTTP to detect zombie connections (default: true).
|
|
189
|
+
* When enabled, periodically fetches current IPNS value and forces reconnect if it
|
|
190
|
+
* differs from the last WebSocket update.
|
|
191
|
+
*/
|
|
192
|
+
livenessCheck?: boolean
|
|
193
|
+
/**
|
|
194
|
+
* Liveness check interval in milliseconds (default: 3600000 = 1 hour).
|
|
195
|
+
* Only used when livenessCheck is enabled.
|
|
196
|
+
*/
|
|
197
|
+
livenessCheckInterval?: number
|
|
198
|
+
/**
|
|
199
|
+
* Called when a stale connection is detected (WebSocket missed updates).
|
|
200
|
+
* Provides debug info about the connection state.
|
|
201
|
+
*/
|
|
202
|
+
onStaleConnection?: (info: StaleConnectionInfo) => void
|
|
203
|
+
/**
|
|
204
|
+
* Partysocket options (passed through to ReconnectingWebSocket).
|
|
205
|
+
* Useful options: startClosed, maxReconnectionDelay, minReconnectionDelay, etc.
|
|
206
|
+
* @see https://github.com/partykit/partykit/tree/main/packages/partysocket
|
|
207
|
+
*/
|
|
208
|
+
wsOptions?: PartysocketOptions
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Robust IPNS watcher with auto-reconnect and catch-up logic.
|
|
213
|
+
* Uses partysocket for reliable WebSocket reconnection.
|
|
214
|
+
*
|
|
215
|
+
* @example
|
|
216
|
+
* ```ts
|
|
217
|
+
* const watcher = new IpnsWatcher('k51qzi5uqu...', {
|
|
218
|
+
* onUpdate: (update) => console.log('New CID:', update.cid?.toString()),
|
|
219
|
+
* onError: (err) => console.error('Error:', err),
|
|
220
|
+
* })
|
|
221
|
+
*
|
|
222
|
+
* // Later, to stop watching:
|
|
223
|
+
* watcher.close()
|
|
224
|
+
* ```
|
|
225
|
+
*/
|
|
226
|
+
const DEFAULT_LIVENESS_INTERVAL = 3600000 // 1 hour
|
|
227
|
+
|
|
228
|
+
export class IpnsWatcher {
|
|
229
|
+
private name: string
|
|
230
|
+
private nameBaseUrl: string
|
|
231
|
+
private ws: ReconnectingWebSocket
|
|
232
|
+
private lastKnownValue: string | null = null
|
|
233
|
+
private options: IpnsWatcherOptions
|
|
234
|
+
private isFirstConnect = true
|
|
235
|
+
private livenessTimer: ReturnType<typeof setInterval> | null = null
|
|
236
|
+
private connectedAt: Date | null = null
|
|
237
|
+
private lastMessageAt: Date | null = null
|
|
238
|
+
|
|
239
|
+
constructor(nameBaseUrl: string, name: string, options: IpnsWatcherOptions) {
|
|
240
|
+
this.nameBaseUrl = requireNameBaseUrl(nameBaseUrl, 'IpnsWatcher')
|
|
241
|
+
this.name = name
|
|
242
|
+
this.options = options
|
|
243
|
+
|
|
244
|
+
const url = buildWsUrl(this.nameBaseUrl, name)
|
|
245
|
+
DEBUG('[IpnsWatcher] creating for', name)
|
|
246
|
+
|
|
247
|
+
this.ws = new ReconnectingWebSocket(url, [], {
|
|
248
|
+
maxReconnectionDelay: 900000, // 15min
|
|
249
|
+
minReconnectionDelay: 5000,
|
|
250
|
+
reconnectionDelayGrowFactor: 2,
|
|
251
|
+
maxRetries: Infinity,
|
|
252
|
+
...options.wsOptions,
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
this.ws.onopen = () => {
|
|
256
|
+
LOG('[IpnsWatcher] connected to', name)
|
|
257
|
+
this.connectedAt = new Date()
|
|
258
|
+
options.onConnected?.()
|
|
259
|
+
|
|
260
|
+
// Check for current state on first connect if requested
|
|
261
|
+
if (this.isFirstConnect && (options.fetchInitialState ?? false)) {
|
|
262
|
+
this.checkForMissedUpdates()
|
|
263
|
+
}
|
|
264
|
+
// Check for missed updates on reconnect
|
|
265
|
+
else if (!this.isFirstConnect && (options.catchUpOnReconnect ?? true)) {
|
|
266
|
+
this.checkForMissedUpdates()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
this.isFirstConnect = false
|
|
270
|
+
|
|
271
|
+
// Start liveness checking (default: enabled)
|
|
272
|
+
if (options.livenessCheck !== false) {
|
|
273
|
+
this.startLivenessCheck()
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
this.ws.onmessage = (event) => {
|
|
278
|
+
this.lastMessageAt = new Date()
|
|
279
|
+
try {
|
|
280
|
+
const record: W3NameRecord = JSON.parse(event.data as string)
|
|
281
|
+
DEBUG('[IpnsWatcher] received update for', name, record)
|
|
282
|
+
|
|
283
|
+
const lastValue = this.lastKnownValue
|
|
284
|
+
const isNew = record.value !== lastValue
|
|
285
|
+
const cid = parseCidFromIpnsValue(record.value)
|
|
286
|
+
|
|
287
|
+
// Skip unchanged values unless includeUnchanged is set
|
|
288
|
+
if (!isNew && !options.includeUnchanged) {
|
|
289
|
+
DEBUG('[IpnsWatcher] skipping unchanged value for', name)
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Update lastKnownValue after the skip check
|
|
294
|
+
this.lastKnownValue = record.value
|
|
295
|
+
|
|
296
|
+
const update: IpnsUpdate = {
|
|
297
|
+
value: record.value,
|
|
298
|
+
cid,
|
|
299
|
+
lastValue,
|
|
300
|
+
isNew,
|
|
301
|
+
record,
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
void options.onUpdate(update)
|
|
305
|
+
} catch (err) {
|
|
306
|
+
WARN('[IpnsWatcher] failed to parse message:', event.data, err)
|
|
307
|
+
options.onError?.(err instanceof Error ? err : new Error(String(err)))
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
this.ws.onerror = (event) => {
|
|
312
|
+
// Extract meaningful error info instead of logging entire ErrorEvent
|
|
313
|
+
const errorMsg = event instanceof ErrorEvent ? event.message : 'WebSocket error'
|
|
314
|
+
|
|
315
|
+
// "Unexpected EOF" is a normal disconnection - partysocket will auto-reconnect
|
|
316
|
+
// Log at INFO level as it's expected behavior, not an error
|
|
317
|
+
if (errorMsg === 'Unexpected EOF') {
|
|
318
|
+
LOG('[IpnsWatcher] error for', name, ':', errorMsg, '(auto-reconnect enabled)')
|
|
319
|
+
} else {
|
|
320
|
+
WARN('[IpnsWatcher] error for', name, ':', errorMsg)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Still call the error handler for unexpected errors
|
|
324
|
+
if (errorMsg !== 'Unexpected EOF') {
|
|
325
|
+
options.onError?.(event)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
this.ws.onclose = () => {
|
|
330
|
+
DEBUG('[IpnsWatcher] disconnected from', name)
|
|
331
|
+
this.stopLivenessCheck()
|
|
332
|
+
this.connectedAt = null
|
|
333
|
+
this.lastMessageAt = null
|
|
334
|
+
options.onDisconnected?.()
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Resolve current IPNS value via HTTP API to catch missed updates
|
|
340
|
+
*/
|
|
341
|
+
private async checkForMissedUpdates(): Promise<void> {
|
|
342
|
+
try {
|
|
343
|
+
DEBUG('[IpnsWatcher] checking for missed updates for', this.name)
|
|
344
|
+
const response = await fetch(buildHttpUrl(this.nameBaseUrl, this.name))
|
|
345
|
+
|
|
346
|
+
if (!response.ok) {
|
|
347
|
+
if (response.status === 404) {
|
|
348
|
+
DEBUG('[IpnsWatcher] IPNS not yet published:', this.name)
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const record: W3NameRecord = await response.json()
|
|
355
|
+
const lastValue = this.lastKnownValue
|
|
356
|
+
const isNew = record.value !== lastValue
|
|
357
|
+
const cid = parseCidFromIpnsValue(record.value)
|
|
358
|
+
|
|
359
|
+
// Skip unchanged values unless includeUnchanged is set
|
|
360
|
+
if (!isNew && !this.options.includeUnchanged) {
|
|
361
|
+
DEBUG('[IpnsWatcher] no new updates for', this.name)
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const logMsg = lastValue === null
|
|
366
|
+
? '[IpnsWatcher] fetched initial state for'
|
|
367
|
+
: '[IpnsWatcher] caught missed update for'
|
|
368
|
+
LOG(logMsg, this.name, {
|
|
369
|
+
previous: lastValue,
|
|
370
|
+
current: record.value,
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
// Update lastKnownValue after the skip check
|
|
374
|
+
this.lastKnownValue = record.value
|
|
375
|
+
|
|
376
|
+
const update: IpnsUpdate = {
|
|
377
|
+
value: record.value,
|
|
378
|
+
cid,
|
|
379
|
+
lastValue,
|
|
380
|
+
isNew,
|
|
381
|
+
record,
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
void this.options.onUpdate(update)
|
|
385
|
+
} catch (err) {
|
|
386
|
+
WARN('[IpnsWatcher] failed to check for missed updates:', this.name, err)
|
|
387
|
+
this.options.onError?.(err instanceof Error ? err : new Error(String(err)))
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Start periodic liveness checks to detect zombie connections.
|
|
393
|
+
*/
|
|
394
|
+
private startLivenessCheck(): void {
|
|
395
|
+
this.stopLivenessCheck() // Clear any existing timer
|
|
396
|
+
const interval = this.options.livenessCheckInterval ?? DEFAULT_LIVENESS_INTERVAL
|
|
397
|
+
DEBUG('[IpnsWatcher] starting liveness check for', this.name, 'interval:', interval)
|
|
398
|
+
|
|
399
|
+
this.livenessTimer = setInterval(() => {
|
|
400
|
+
void this.performLivenessCheck()
|
|
401
|
+
}, interval)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Stop periodic liveness checks.
|
|
406
|
+
*/
|
|
407
|
+
private stopLivenessCheck(): void {
|
|
408
|
+
if (this.livenessTimer !== null) {
|
|
409
|
+
DEBUG('[IpnsWatcher] stopping liveness check for', this.name)
|
|
410
|
+
clearInterval(this.livenessTimer)
|
|
411
|
+
this.livenessTimer = null
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Perform a single liveness check via HTTP.
|
|
417
|
+
* If the HTTP value differs from lastKnownValue, the connection is stale.
|
|
418
|
+
*/
|
|
419
|
+
private async performLivenessCheck(): Promise<void> {
|
|
420
|
+
try {
|
|
421
|
+
DEBUG('[IpnsWatcher] performing liveness check for', this.name)
|
|
422
|
+
const response = await fetch(buildHttpUrl(this.nameBaseUrl, this.name))
|
|
423
|
+
|
|
424
|
+
if (!response.ok) {
|
|
425
|
+
if (response.status === 404) {
|
|
426
|
+
// IPNS not published - if we also have null, that's consistent
|
|
427
|
+
if (this.lastKnownValue === null) {
|
|
428
|
+
DEBUG('[IpnsWatcher] liveness check OK (both null) for', this.name)
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
// We have a value but HTTP says 404 - this shouldn't happen normally
|
|
432
|
+
WARN('[IpnsWatcher] liveness check inconsistent (we have value, HTTP 404) for', this.name)
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const record: W3NameRecord = await response.json()
|
|
439
|
+
|
|
440
|
+
// Check if values match
|
|
441
|
+
if (record.value === this.lastKnownValue) {
|
|
442
|
+
DEBUG('[IpnsWatcher] liveness check OK for', this.name)
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Stale connection detected!
|
|
447
|
+
const now = new Date()
|
|
448
|
+
const silenceDuration = this.lastMessageAt
|
|
449
|
+
? now.getTime() - this.lastMessageAt.getTime()
|
|
450
|
+
: this.connectedAt
|
|
451
|
+
? now.getTime() - this.connectedAt.getTime()
|
|
452
|
+
: 0
|
|
453
|
+
|
|
454
|
+
const staleInfo: StaleConnectionInfo = {
|
|
455
|
+
connectedAt: this.connectedAt ?? now,
|
|
456
|
+
lastMessageAt: this.lastMessageAt,
|
|
457
|
+
silenceDuration,
|
|
458
|
+
staleValue: this.lastKnownValue,
|
|
459
|
+
currentValue: record.value,
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
WARN('[IpnsWatcher] stale connection detected for', this.name, {
|
|
463
|
+
connectedAt: staleInfo.connectedAt.toISOString(),
|
|
464
|
+
lastMessageAt: staleInfo.lastMessageAt?.toISOString() ?? 'never',
|
|
465
|
+
silenceDuration: `${Math.round(silenceDuration / 1000)}s`,
|
|
466
|
+
staleValue: staleInfo.staleValue,
|
|
467
|
+
currentValue: staleInfo.currentValue,
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
// Notify via callback
|
|
471
|
+
this.options.onStaleConnection?.(staleInfo)
|
|
472
|
+
|
|
473
|
+
// Fire immediate update with the current value
|
|
474
|
+
const lastValue = this.lastKnownValue
|
|
475
|
+
const cid = parseCidFromIpnsValue(record.value)
|
|
476
|
+
this.lastKnownValue = record.value
|
|
477
|
+
|
|
478
|
+
const update: IpnsUpdate = {
|
|
479
|
+
value: record.value,
|
|
480
|
+
cid,
|
|
481
|
+
lastValue,
|
|
482
|
+
isNew: true,
|
|
483
|
+
record,
|
|
484
|
+
}
|
|
485
|
+
void this.options.onUpdate(update)
|
|
486
|
+
|
|
487
|
+
// Force reconnect to get a fresh connection
|
|
488
|
+
LOG('[IpnsWatcher] forcing reconnect due to stale connection for', this.name)
|
|
489
|
+
this.ws.reconnect()
|
|
490
|
+
} catch (err) {
|
|
491
|
+
// Don't treat HTTP errors as stale - could be network issue
|
|
492
|
+
WARN('[IpnsWatcher] liveness check failed for', this.name, err)
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Manually start/reconnect the WebSocket.
|
|
498
|
+
* Only needed if you used `wsOptions: { startClosed: true }`.
|
|
499
|
+
*/
|
|
500
|
+
start(): void {
|
|
501
|
+
LOG('[IpnsWatcher] starting watcher for', this.name)
|
|
502
|
+
this.ws.reconnect()
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Alias for close() - for backward compatibility
|
|
507
|
+
*/
|
|
508
|
+
stop(): void {
|
|
509
|
+
this.close()
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Close the WebSocket connection and stop watching
|
|
514
|
+
*/
|
|
515
|
+
close(): void {
|
|
516
|
+
LOG('[IpnsWatcher] closing watcher for', this.name)
|
|
517
|
+
this.stopLivenessCheck()
|
|
518
|
+
this.ws.close()
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Get the last known IPNS value
|
|
523
|
+
*/
|
|
524
|
+
get lastValue(): string | null {
|
|
525
|
+
return this.lastKnownValue
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Get the WebSocket ready state
|
|
530
|
+
*/
|
|
531
|
+
get readyState(): number {
|
|
532
|
+
return this.ws.readyState
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Create an IPNS watcher with auto-reconnect and catch-up logic.
|
|
538
|
+
* Convenience function that creates and returns an IpnsWatcher instance.
|
|
539
|
+
*
|
|
540
|
+
* @param nameBaseUrl - Base URL of a naming service that supports `/name/<ipns>/watch` (WebSocket) and `/name/<ipns>` (HTTP)
|
|
541
|
+
* @param name - The IPNS name/key to watch (e.g. "k51qzi5u...")
|
|
542
|
+
* @param options - Callback options for handling events
|
|
543
|
+
* @returns An IpnsWatcher instance with close() method
|
|
544
|
+
*/
|
|
545
|
+
export function watchName(nameBaseUrl: string, name: string, options: IpnsWatcherOptions): IpnsWatcher {
|
|
546
|
+
return new IpnsWatcher(nameBaseUrl, name, options)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Watch an IPNS name and return updates as an async iterator.
|
|
551
|
+
* Includes auto-reconnect - iterator continues through disconnections.
|
|
552
|
+
*
|
|
553
|
+
* @param nameBaseUrl - Base URL of a naming service that supports `/name/<ipns>/watch` (WebSocket) and `/name/<ipns>` (HTTP)
|
|
554
|
+
* @param name - The IPNS name/key to watch
|
|
555
|
+
* @param signal - Optional AbortSignal to stop the watch
|
|
556
|
+
*
|
|
557
|
+
* @example
|
|
558
|
+
* ```ts
|
|
559
|
+
* const controller = new AbortController()
|
|
560
|
+
* for await (const update of watchNameIterator('https://name.example.com', 'k51qzi5u...', controller.signal)) {
|
|
561
|
+
* console.log('Update:', update.cid?.toString())
|
|
562
|
+
* }
|
|
563
|
+
* ```
|
|
564
|
+
*/
|
|
565
|
+
export async function* watchNameIterator(
|
|
566
|
+
nameBaseUrl: string,
|
|
567
|
+
name: string,
|
|
568
|
+
signal?: AbortSignal,
|
|
569
|
+
): AsyncGenerator<IpnsUpdate, void, unknown> {
|
|
570
|
+
const queue: IpnsUpdate[] = []
|
|
571
|
+
let resolve: (() => void) | null = null
|
|
572
|
+
let error: Error | null = null
|
|
573
|
+
|
|
574
|
+
const watcher = new IpnsWatcher(nameBaseUrl, name, {
|
|
575
|
+
onUpdate: (update) => {
|
|
576
|
+
queue.push(update)
|
|
577
|
+
resolve?.()
|
|
578
|
+
},
|
|
579
|
+
onError: (err) => {
|
|
580
|
+
error = err instanceof Error ? err : new Error('WebSocket error')
|
|
581
|
+
resolve?.()
|
|
582
|
+
},
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
signal?.addEventListener('abort', () => {
|
|
586
|
+
watcher.close()
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
try {
|
|
590
|
+
while (!signal?.aborted) {
|
|
591
|
+
if (queue.length > 0) {
|
|
592
|
+
yield queue.shift()!
|
|
593
|
+
} else if (error) {
|
|
594
|
+
// Log error but continue - partysocket will reconnect
|
|
595
|
+
WARN('[watchNameIterator] error occurred, continuing:', error)
|
|
596
|
+
error = null
|
|
597
|
+
} else {
|
|
598
|
+
await new Promise<void>((r) => {
|
|
599
|
+
resolve = r
|
|
600
|
+
})
|
|
601
|
+
resolve = null
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
} finally {
|
|
605
|
+
watcher.close()
|
|
606
|
+
}
|
|
607
|
+
}
|
package/src/ipns.ts
CHANGED
package/src/pubsub/snap-push.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
import { BlockStoreish, DecodedCar, getDecodedBlock, makeCarBlob } from '../ipfs/car.ts'
|
|
16
16
|
import { encodeBlockOriginal, prepareForPub } from '../ipfs/ipfs-utils.ts'
|
|
17
17
|
import { lastWriteWins } from './../query/basic.ts'
|
|
18
|
+
import { anyOf } from '../query/matchers.ts'
|
|
18
19
|
import { ApplogsOrThread, getLogsFromThread, Thread } from '../thread.ts'
|
|
19
20
|
import { rollingFilter } from '../thread/filters.ts'
|
|
20
21
|
import { keepTruthy } from '../utils.ts'
|
|
@@ -146,7 +147,7 @@ export async function prepareSnapshotForPush(
|
|
|
146
147
|
const infoLogs = [
|
|
147
148
|
...rollingFilter(lastWriteWins(appThread), { // TODO: use static filter for performance
|
|
148
149
|
en: agent.ag,
|
|
149
|
-
at:
|
|
150
|
+
at: anyOf('agent/ecdh', 'agent/jwkd', 'agent/appAgent'),
|
|
150
151
|
}).applogs,
|
|
151
152
|
...(shareNameLog ? [shareNameLog] : []),
|
|
152
153
|
...(shareCounterLog ? [shareCounterLog] : []),
|
|
@@ -227,21 +228,22 @@ export async function chunkApplogs(applogCids: CID<unknown, 297, 18, 1>[], size
|
|
|
227
228
|
DEBUG(`[chunkApplogs] ${applogCids.length} logs chunked into ${chunks.length}`, { applogCids, root, blocks, chunks, dagJson })
|
|
228
229
|
return { rootCID: root.cid, blocks, chunks }
|
|
229
230
|
}
|
|
230
|
-
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 []
|
|
231
233
|
if (isSnapBlockChunks(block)) {
|
|
232
234
|
return (await Promise.all(
|
|
233
235
|
block.chunks.map(async (chunkCid) => {
|
|
234
236
|
const block = (await getDecodedBlock(blockStore, chunkCid)) as SnapBlockLogs
|
|
235
|
-
if (!block
|
|
237
|
+
if (!block?.logs) throw ERROR(`Weird chunk`, block)
|
|
236
238
|
return block.logs
|
|
237
239
|
}),
|
|
238
240
|
)).flat()
|
|
239
241
|
} else {
|
|
240
|
-
return block.logs
|
|
242
|
+
return block.logs ?? []
|
|
241
243
|
}
|
|
242
244
|
}
|
|
243
|
-
export function isSnapBlockChunks(block: SnapBlockLogsOrChunks): block is SnapBlockChunks {
|
|
244
|
-
return
|
|
245
|
+
export function isSnapBlockChunks(block: SnapBlockLogsOrChunks | null | undefined): block is SnapBlockChunks {
|
|
246
|
+
return !!block && 'chunks' in block
|
|
245
247
|
}
|
|
246
248
|
/**
|
|
247
249
|
* @param applogs Encrypted or plain applogs
|
|
@@ -3,6 +3,7 @@ import { Applog, ApplogValue, DatalogQueryPattern, EntityID } from '../applog/da
|
|
|
3
3
|
import { isInitEvent, Thread } from '../thread/basic.ts'
|
|
4
4
|
import { makeFilter, rollingFilter } from '../thread/filters.ts'
|
|
5
5
|
import { resolveKeyMapper } from './basic.ts'
|
|
6
|
+
import { anyOf } from './matchers.ts'
|
|
6
7
|
import { memoizedFn } from './memoized.ts'
|
|
7
8
|
import { SubscribableImpl } from './subscribable.ts'
|
|
8
9
|
import type { Subscribable } from './subscribable.ts'
|
|
@@ -49,7 +50,7 @@ const _liveEntityCollection = memoizedFn('liveEntityCollection',
|
|
|
49
50
|
DEBUG('liveEntityCollection', discoveryPattern, liveAttributes)
|
|
50
51
|
const discoveryAttr = discoveryPattern.at as string
|
|
51
52
|
const allAttrs = new Set([discoveryAttr, ...liveAttributes])
|
|
52
|
-
const filtered = rollingFilter(thread, { at:
|
|
53
|
+
const filtered = rollingFilter(thread, { at: anyOf(...allAttrs) })
|
|
53
54
|
const isDiscoveryMatch = makeFilter(discoveryPattern)
|
|
54
55
|
const attrSet = new Set<string>(liveAttributes)
|
|
55
56
|
const key = resolveKeyMapper(opts)
|
package/src/query/matchers.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DatomPart } from '../applog/datom-types.ts'
|
|
1
|
+
import { ApplogValue, DatomPart } from '../applog/datom-types.ts'
|
|
2
2
|
|
|
3
3
|
export function includes(str: string) {
|
|
4
4
|
return (vl: DatomPart) => vl?.includes?.(str)
|
|
@@ -6,3 +6,25 @@ export function includes(str: string) {
|
|
|
6
6
|
export function includedIn(arr: string[]) {
|
|
7
7
|
return (vl: DatomPart) => arr?.includes?.(vl)
|
|
8
8
|
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Set-membership matcher: matches when the field equals any of `vals`. Use this instead of a
|
|
12
|
+
* bare array in a query pattern — bare arrays are rejected by the matcher (a bare array is
|
|
13
|
+
* ambiguous now that `vl` can hold a literal array value).
|
|
14
|
+
*
|
|
15
|
+
* Returns a `Set`, which the matcher engine checks via `.has` (O(1)). Members must be
|
|
16
|
+
* primitives: `Set.has` is referential, so object/array members would silently never match —
|
|
17
|
+
* we throw rather than fail silently. For object membership, use a predicate matcher, e.g.
|
|
18
|
+
* `(v) => members.some(m => isEqual(v, m))`.
|
|
19
|
+
*/
|
|
20
|
+
export function anyOf<T extends ApplogValue>(...vals: T[]): ReadonlySet<T> {
|
|
21
|
+
for (const v of vals) {
|
|
22
|
+
if (v !== null && typeof v === 'object') {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`[anyOf] object/array members are not supported (referential Set membership would silently never match).`
|
|
25
|
+
+ ` Use a predicate matcher instead, e.g. (v) => members.some(m => isEqual(v, m)).`,
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return new Set(vals)
|
|
30
|
+
}
|