nappup 2.3.9 → 2.3.10

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/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "url": "git+https://github.com/44billion/nappup.git"
7
7
  },
8
8
  "license": "MIT",
9
- "version": "2.3.9",
9
+ "version": "2.3.10",
10
10
  "description": "Nostr App Uploader",
11
11
  "type": "module",
12
12
  "scripts": {
@@ -22,7 +22,7 @@
22
22
  "@noble/hashes": "^2.0.0",
23
23
  "dotenv": "^17.2.0",
24
24
  "file-type": "^21.0.0",
25
- "libp2r2p": "^0.10.10",
25
+ "libp2r2p": "^0.10.12",
26
26
  "mime-types": "^3.0.1",
27
27
  "nmmr": "^2.0.0"
28
28
  },
@@ -18,6 +18,6 @@ export const NAPP_CATEGORIES = {
18
18
  'other', 'podcast', 'music', 'video', 'news'
19
19
  ],
20
20
  utilities: [
21
- 'other', 'weather', 'office', 'finances', 'learning', 'text editor', 'image editor', 'audio editor', 'video editor', 'ar', 'vr', 'ai'
21
+ 'other', 'widget', 'weather', 'office', 'finances', 'learning', 'text editor', 'image editor', 'audio editor', 'video editor', 'ar', 'vr', 'ai'
22
22
  ]
23
23
  }
@@ -1,3 +1,21 @@
1
+ // Groups copies of the same signed event while retaining every observed origin.
2
+ export function aggregateEventRelays (events) {
3
+ const byId = new Map()
4
+ for (const event of events) {
5
+ let entry = byId.get(event.id)
6
+ if (!entry) {
7
+ entry = { event: { ...event, meta: { relays: [] } }, relays: new Set() }
8
+ byId.set(event.id, entry)
9
+ }
10
+ const relay = event.meta?.relay
11
+ if (typeof relay === 'string' && relay && !entry.relays.has(relay)) {
12
+ entry.relays.add(relay)
13
+ entry.event.meta.relays.push(relay)
14
+ }
15
+ }
16
+ return [...byId.values()].map(entry => entry.event)
17
+ }
18
+
1
19
  export function stringifyEvent (event) {
2
20
  event = { ...event }
3
21
 
@@ -0,0 +1,25 @@
1
+ const MAX_ERROR_DEPTH = 6
2
+
3
+ // Formats only diagnostic fields, retaining nested native errors without cycles.
4
+ function formatError (error, seen = new Set(), depth = 0) {
5
+ if (depth >= MAX_ERROR_DEPTH) return '[maximum error depth reached]'
6
+ if (error === null || typeof error !== 'object') return String(error || 'No error message provided')
7
+ if (seen.has(error)) return '[circular error reference]'
8
+ seen.add(error)
9
+ const fields = ['code', 'closeCode', 'closeReason', 'wasClean']
10
+ .filter(key => error[key] !== undefined)
11
+ .map(key => `${key}=${String(error[key])}`)
12
+ let text = `${error.name || 'Error'}: ${error.message || 'No error message provided'}`
13
+ if (fields.length) text += ` (${fields.join(', ')})`
14
+ if (error.cause !== undefined) text += `; cause: ${formatError(error.cause, seen, depth + 1)}`
15
+ if (Array.isArray(error.errors)) {
16
+ text += `; errors: [${error.errors.map(child => formatError(child, seen, depth + 1)).join('; ')}]`
17
+ }
18
+ seen.delete(error)
19
+ return text
20
+ }
21
+
22
+ // Keeps the destination relay separate from any event provenance metadata.
23
+ export function formatRelayFailure ({ relay, reason }) {
24
+ return `${relay} [${reason?.category || 'unclassified'}]: ${formatError(reason)}`
25
+ }
@@ -1,7 +1,8 @@
1
1
  import nostrRelays, { nappRelays, sendEventReport } from '#services/nostr-relays.js'
2
2
  import NMMR from 'nmmr'
3
3
  import { decode as base93Decode, encode as base93Encode } from 'libp2r2p/base93'
4
- import { stringifyEvent } from '#helpers/event.js'
4
+ import { aggregateEventRelays } from '#helpers/event.js'
5
+ import { formatRelayFailure } from '#helpers/relay-error.js'
5
6
 
6
7
  /**
7
8
  * Uploads binary data chunks for a file to Nostr relays using the InterRelay File System (IRFS).
@@ -37,7 +38,7 @@ export async function uploadBinaryDataChunks ({ nmmr, signer, filename, chunkLen
37
38
  const validEvents = storedEvents.filter(event => isExpectedChunkEvent(event, { rootHash, index: chunk.index, total: chunk.total, dTag }))
38
39
  .sort(compareEventsNewestFirst)
39
40
  const foundEvent = validEvents[0]
40
- const coveredRelays = new Set(validEvents.filter(event => event.id === foundEvent?.id).map(event => event.meta?.relay).filter(Boolean))
41
+ const coveredRelays = new Set(foundEvent?.meta.relays ?? [])
41
42
  const missingRelays = relays.filter(relay => !coveredRelays.has(relay))
42
43
 
43
44
  if (!shouldReupload && foundEvent) {
@@ -45,7 +46,7 @@ export async function uploadBinaryDataChunks ({ nmmr, signer, filename, chunkLen
45
46
  log(`${filename}: Skipping chunk ${++chunkIndex} of ${chunkLength} (already uploaded)`)
46
47
  continue
47
48
  }
48
- log(`${filename}: Re-uploading chunk ${++chunkIndex} of ${chunkLength} to ${missingRelays.length} missing relays (out of ${relays.length})`)
49
+ log(`${filename}: Re-uploading chunk ${++chunkIndex} of ${chunkLength} to ${missingRelays.length} relays without a confirmed copy (out of ${relays.length})`)
49
50
  ;({ pause } = (await throttledSendEvent(foundEvent, missingRelays, { pause, log, trailingPause: true, minSuccessfulRelays: 0 })))
50
51
  continue
51
52
  }
@@ -114,7 +115,7 @@ function isExpectedChunkEvent (event, expected) {
114
115
  * Handles three error categories:
115
116
  * - Rate-limit errors: retries with increasing pause (+2000ms per retry)
116
117
  * - Timeout errors: one-time immediate retry
117
- * - Unretryable errors: logged and counted against success threshold
118
+ * - Other errors: no further automatic retry in this operation; counted against the success threshold
118
119
  *
119
120
  * @param {object} event - Signed Nostr event to send
120
121
  * @param {string[]} relays - Array of relay URLs
@@ -143,7 +144,7 @@ export async function throttledSendEvent (event, relays, {
143
144
  return { pause }
144
145
  }
145
146
 
146
- const [rateLimitErrors, maybeUnretryableErrors, unretryableErrors] =
147
+ const [rateLimitErrors, timeoutErrors, noRetryErrors] =
147
148
  errors.reduce((r, v) => {
148
149
  const message = v.reason?.message ?? ''
149
150
  if (message.startsWith('rate-limited:')) r[0].push(v)
@@ -153,26 +154,28 @@ export async function throttledSendEvent (event, relays, {
153
154
  }, [[], [], []])
154
155
 
155
156
  // One-time special retry
156
- if (maybeUnretryableErrors.length > 0) {
157
- const timedOutRelays = maybeUnretryableErrors.map(v => v.relay)
158
- log(`${maybeUnretryableErrors.length} timeout errors, retrying once after ${pause}ms:\n${maybeUnretryableErrors.map(v => `${v.relay}: ${v.reason.message}`).join('; ')}`)
157
+ if (timeoutErrors.length > 0) {
158
+ const timedOutRelays = timeoutErrors.map(v => v.relay)
159
+ log(`${timeoutErrors.length} timeout errors, retrying once after ${pause}ms:\n${timeoutErrors.map(formatRelayFailure).join('\n')}`)
159
160
  if (pause) await new Promise(resolve => setTimeout(resolve, pause))
160
161
  const { errors: timeoutRetryErrors } = await sendEventReport(event, timedOutRelays, { timeout: 15000, timeoutUntilFirstFulfillment: null })
161
- unretryableErrors.push(...timeoutRetryErrors)
162
+ noRetryErrors.push(...timeoutRetryErrors)
162
163
  }
163
164
 
164
- if (unretryableErrors.length > 0) {
165
- log(`${unretryableErrors.length} unretryable errors:\n${unretryableErrors.map(v => `${v.relay}: ${v.reason.message}`).join('; ')}`)
166
- console.log('Erroed event:', stringifyEvent(event))
165
+ if (noRetryErrors.length > 0) {
166
+ log(`${noRetryErrors.length} failures with no further automatic retry in this operation:\n${noRetryErrors.map(formatRelayFailure).join('\n')}`)
167
+ log(`Event: id=${event.id ?? 'unknown'} kind=${event.kind ?? 'unknown'}`)
167
168
  }
168
- const maybeSuccessfulRelays = relays.length - unretryableErrors.length
169
+ const maybeSuccessfulRelays = relays.length - noRetryErrors.length
169
170
  const hasReachedMaxRetries = retries > maxRetries
170
171
  if (
171
172
  hasReachedMaxRetries ||
172
173
  maybeSuccessfulRelays < minSuccessfulRelays
173
174
  ) {
174
- const finalErrors = [...rateLimitErrors, ...unretryableErrors]
175
- throw new Error(finalErrors.map(v => `\n${v.relay}: ${v.reason}`).join('\n'))
175
+ const finalErrors = [...rateLimitErrors, ...noRetryErrors]
176
+ const error = new AggregateError(finalErrors.map(item => item.reason), finalErrors.map(formatRelayFailure).join('\n'))
177
+ error.failures = finalErrors
178
+ throw error
176
179
  }
177
180
 
178
181
  if (rateLimitErrors.length === 0) {
@@ -185,7 +188,7 @@ export async function throttledSendEvent (event, relays, {
185
188
  await new Promise(resolve => setTimeout(resolve, (pause += 2000)))
186
189
 
187
190
  // Subtracts the successful publishes from the original minSuccessfulRelays goal
188
- minSuccessfulRelays = Math.max(0, minSuccessfulRelays - (relays.length - erroedRelays.length - unretryableErrors.length))
191
+ minSuccessfulRelays = Math.max(0, minSuccessfulRelays - (relays.length - erroedRelays.length - noRetryErrors.length))
189
192
  return await throttledSendEvent(event, erroedRelays, {
190
193
  pause, log, retries: ++retries, maxRetries, minSuccessfulRelays, leadingPause: false, trailingPause
191
194
  })
@@ -198,12 +201,12 @@ export async function getPreviousChunks (dTagValues, relays, signer) {
198
201
 
199
202
  for (let offset = 0; offset < dTagValues.length; offset += 100) {
200
203
  const batch = dTagValues.slice(offset, offset + 100)
201
- const storedEvents = (await nostrRelays.getEvents({
204
+ const storedEvents = aggregateEventRelays((await nostrRelays.getEvents({
202
205
  kinds: [34601],
203
206
  authors: [pubkey],
204
207
  '#d': batch,
205
208
  limit: batch.length
206
- }, targetRelays, { timeoutAfterFirstEose: null })).result
209
+ }, targetRelays, { timeoutAfterFirstEose: null, deduplicateAcrossRelays: false })).result)
207
210
 
208
211
  for (const event of storedEvents) {
209
212
  const dTag = event.tags?.find(tag => tag[0] === 'd')?.[1]
@@ -1,3 +1,4 @@
1
+ import { aggregateEventRelays } from '#helpers/event.js'
1
2
  import { NAPP_CATEGORIES } from '#config/napp-categories.js'
2
3
  import nostrRelays, { nappRelays } from '#services/nostr-relays.js'
3
4
  import { throttledSendEvent } from '#services/irfs-upload.js'
@@ -249,12 +250,12 @@ export async function uploadSiteManifest ({
249
250
  const kind = manifestKind(channel)
250
251
  const relays = [...new Set([...(await signer.getRelays()).write, ...nappRelays]
251
252
  .map(relay => relay.trim().replace(/\/$/, '')))]
252
- const events = (await nostrRelays.getEvents({
253
+ const events = aggregateEventRelays((await nostrRelays.getEvents({
253
254
  kinds: [kind],
254
255
  authors: [await signer.getPublicKey()],
255
256
  '#d': [dTag],
256
257
  limit: 1
257
- }, relays, { timeoutAfterFirstEose: null })).result
258
+ }, relays, { timeoutAfterFirstEose: null, deduplicateAcrossRelays: false })).result)
258
259
  events.sort(newestFirst)
259
260
  const previous = events[0]
260
261
 
@@ -288,13 +289,10 @@ export async function uploadSiteManifest ({
288
289
  })
289
290
 
290
291
  if (!shouldReupload && previous && previous.content === '' && JSON.stringify(previous.tags) === JSON.stringify(tags)) {
291
- const coveredRelays = new Set(events
292
- .filter(event => event.id === previous.id)
293
- .map(event => event.meta?.relay)
294
- .filter(Boolean))
292
+ const coveredRelays = new Set(previous.meta.relays)
295
293
  const missingRelays = relays.filter(relay => !coveredRelays.has(relay))
296
294
  if (!missingRelays.length) return previous
297
- log(`Re-uploading existing site manifest to ${missingRelays.length} missing relays`)
295
+ log(`Re-uploading existing site manifest to ${missingRelays.length} relays without a confirmed copy`)
298
296
  await throttledSendEvent(previous, missingRelays, {
299
297
  pause, trailingPause: true, log, minSuccessfulRelays: 0
300
298
  })