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
|
+
"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.
|
|
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
|
}
|
package/src/helpers/event.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
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}
|
|
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
|
-
* -
|
|
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,
|
|
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 (
|
|
157
|
-
const timedOutRelays =
|
|
158
|
-
log(`${
|
|
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
|
-
|
|
162
|
+
noRetryErrors.push(...timeoutRetryErrors)
|
|
162
163
|
}
|
|
163
164
|
|
|
164
|
-
if (
|
|
165
|
-
log(`${
|
|
166
|
-
|
|
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 -
|
|
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, ...
|
|
175
|
-
|
|
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 -
|
|
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(
|
|
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}
|
|
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
|
})
|