nappup 1.4.2 → 1.5.3

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": "1.4.2",
9
+ "version": "1.5.3",
10
10
  "description": "Nostr App Uploader",
11
11
  "type": "module",
12
12
  "scripts": {
package/src/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import NMMR from 'nmmr'
2
2
  import { appEncode } from '#helpers/nip19.js'
3
3
  import Base93Encoder from '#services/base93-encoder.js'
4
- import nostrRelays from '#services/nostr-relays.js'
4
+ import nostrRelays, { nappRelays } from '#services/nostr-relays.js'
5
5
  import NostrSigner from '#services/nostr-signer.js'
6
6
  import { streamToChunks, streamToText } from '#helpers/stream.js'
7
7
  import { isNostrAppDTagSafe, deriveNostrAppDTag } from '#helpers/app.js'
@@ -151,8 +151,8 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, dTag, dTag
151
151
  hashtags: nappJson.hashtag
152
152
  })))
153
153
 
154
- log(`Uploading bundle #${dTag}`)
155
- const bundle = await uploadBundle({ dTag, channel, fileMetadata, signer: nostrSigner, pause })
154
+ log(`Uploading bundle ${dTag}`)
155
+ const bundle = await uploadBundle({ dTag, channel, fileMetadata, signer: nostrSigner, pause, shouldReupload, log })
156
156
 
157
157
  const appEntity = appEncode({
158
158
  dTag: bundle.tags.find(v => v[0] === 'd')[1],
@@ -165,13 +165,19 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, dTag, dTag
165
165
 
166
166
  async function uploadBinaryDataChunks ({ nmmr, signer, filename, chunkLength, log, pause = 0, mimeType, shouldReupload = false }) {
167
167
  const writeRelays = (await signer.getRelays()).write
168
+ const relays = [...new Set([...writeRelays, ...nappRelays])]
168
169
  let chunkIndex = 0
169
170
  for await (const chunk of nmmr.getChunks()) {
170
171
  const dTag = chunk.x
171
172
  const currentCtag = `${chunk.rootX}:${chunk.index}`
172
- const { otherCtags, hasCurrentCtag } = await getPreviousCtags(dTag, currentCtag, writeRelays, signer)
173
+ const { otherCtags, hasCurrentCtag, foundEvent, missingRelays } = await getPreviousCtags(dTag, currentCtag, relays, signer)
173
174
  if (!shouldReupload && hasCurrentCtag) {
174
- log(`${filename}: Skipping chunk ${++chunkIndex} of ${chunkLength} (already uploaded)`)
175
+ if (missingRelays.length === 0) {
176
+ log(`${filename}: Skipping chunk ${++chunkIndex} of ${chunkLength} (already uploaded)`)
177
+ continue
178
+ }
179
+ log(`${filename}: Re-uploading chunk ${++chunkIndex} of ${chunkLength} to ${missingRelays.length} missing relays (out of ${relays.length})`)
180
+ ;({ pause } = (await throttledSendEvent(foundEvent, missingRelays, { pause, log, trailingPause: true })))
175
181
  continue
176
182
  }
177
183
  const binaryDataChunk = {
@@ -188,8 +194,9 @@ async function uploadBinaryDataChunks ({ nmmr, signer, filename, chunkLength, lo
188
194
  }
189
195
 
190
196
  const event = await signer.signEvent(binaryDataChunk)
191
- log(`${filename}: Uploading file part ${++chunkIndex} of ${chunkLength} to ${writeRelays.length} relays`)
192
- ;({ pause } = (await throttledSendEvent(event, writeRelays, { pause, log, trailingPause: true })))
197
+ const fallbackRelayCount = relays.length - writeRelays.length
198
+ log(`${filename}: Uploading file part ${++chunkIndex} of ${chunkLength} to ${writeRelays.length} relays${fallbackRelayCount > 0 ? ` (+${fallbackRelayCount} fallback)` : ''}`)
199
+ ;({ pause } = (await throttledSendEvent(event, relays, { pause, log, trailingPause: true })))
193
200
  }
194
201
  return { pause }
195
202
  }
@@ -239,20 +246,24 @@ async function throttledSendEvent (event, relays, {
239
246
  })
240
247
  }
241
248
 
242
- async function getPreviousCtags (dTagValue, currentCtagValue, writeRelays, signer) {
249
+ async function getPreviousCtags (dTagValue, currentCtagValue, relays, signer) {
250
+ const targetRelays = [...new Set([...relays, ...nappRelays])]
243
251
  const storedEvents = (await nostrRelays.getEvents({
244
252
  kinds: [34600],
245
253
  authors: [await signer.getPublicKey()],
246
254
  '#d': [dTagValue],
247
255
  limit: 1
248
- }, writeRelays)).result
256
+ }, targetRelays)).result
249
257
 
250
258
  let hasCurrentCtag = false
251
259
  const hasEvent = storedEvents.length > 0
252
260
  if (!hasEvent) return { otherCtags: [], hasEvent, hasCurrentCtag }
253
261
 
254
262
  const cTagValues = { [currentCtagValue]: true }
255
- const prevTags = storedEvents.sort((a, b) => b.created_at - a.created_at)[0].tags
263
+ storedEvents.sort((a, b) => b.created_at - a.created_at)
264
+ const bestEvent = storedEvents[0]
265
+ const prevTags = bestEvent.tags
266
+
256
267
  if (!Array.isArray(prevTags)) return { otherCtags: [], hasEvent, hasCurrentCtag }
257
268
 
258
269
  hasCurrentCtag = prevTags.some(tag =>
@@ -275,26 +286,78 @@ async function getPreviousCtags (dTagValue, currentCtagValue, writeRelays, signe
275
286
  return isCTag && isntDuplicate
276
287
  })
277
288
 
278
- return { otherCtags, hasEvent, hasCurrentCtag }
289
+ const matchingEvents = storedEvents.filter(e => e.id === bestEvent.id)
290
+ const coveredRelays = new Set(matchingEvents.map(e => e.meta?.relay).filter(Boolean))
291
+ const missingRelays = targetRelays.filter(r => !coveredRelays.has(r))
292
+
293
+ return { otherCtags, hasEvent, hasCurrentCtag, foundEvent: bestEvent, missingRelays }
279
294
  }
280
295
 
281
- async function uploadBundle ({ dTag, channel, fileMetadata, signer, pause = 0 }) {
296
+ async function uploadBundle ({ dTag, channel, fileMetadata, signer, pause = 0, shouldReupload = false, log = () => {} }) {
282
297
  const kind = {
283
298
  main: 37448, // stable
284
299
  next: 37449, // insider
285
300
  draft: 37450 // vibe coded preview
286
301
  }[channel] ?? 37448
302
+
303
+ const fileTags = fileMetadata.map(v => ['file', v.rootHash, v.filename, v.mimeType])
304
+ const tags = [
305
+ ['d', dTag],
306
+ ...fileTags
307
+ ]
308
+
309
+ const writeRelays = [...new Set([...(await signer.getRelays()).write, ...nappRelays])]
310
+
311
+ if (!shouldReupload) {
312
+ const events = (await nostrRelays.getEvents({
313
+ kinds: [kind],
314
+ authors: [await signer.getPublicKey()],
315
+ '#d': [dTag],
316
+ limit: 1
317
+ }, writeRelays)).result
318
+
319
+ if (events.length > 0) {
320
+ events.sort((a, b) => {
321
+ if (b.created_at !== a.created_at) return b.created_at - a.created_at
322
+ if (a.id < b.id) return -1
323
+ if (a.id > b.id) return 1
324
+ return 0
325
+ })
326
+
327
+ const mostRecentEvent = events[0]
328
+ const recentFileTags = mostRecentEvent.tags.filter(t => t[0] === 'file')
329
+
330
+ const isSame = fileTags.length === recentFileTags.length && fileTags.every((t, i) => {
331
+ const rt = recentFileTags[i]
332
+ return rt.length >= 4 && rt[1] === t[1] && rt[2] === t[2] && rt[3] === t[3]
333
+ })
334
+
335
+ if (isSame) {
336
+ log(`Bundle based on ${fileTags.length} files is up-to-date (id: ${mostRecentEvent.id} - created_at: ${new Date(mostRecentEvent.created_at * 1000).toISOString()})`)
337
+
338
+ const matchingEvents = events.filter(e => e.id === mostRecentEvent.id)
339
+ const coveredRelays = new Set(matchingEvents.map(e => e.meta?.relay).filter(Boolean))
340
+ const missingRelays = writeRelays.filter(r => !coveredRelays.has(r))
341
+
342
+ if (missingRelays.length === 0) return mostRecentEvent
343
+
344
+ // nostrRelays.getEvents currently doesn't tell us which event came from which relay,
345
+ // so we re-upload to all relays to ensure consistency
346
+ log(`Re-uploading existing bundle event to ${missingRelays.length} missing relays (out of ${writeRelays.length})`)
347
+ await throttledSendEvent(mostRecentEvent, missingRelays, { pause, trailingPause: true, log })
348
+ return mostRecentEvent
349
+ }
350
+ }
351
+ }
352
+
287
353
  const appBundle = {
288
354
  kind,
289
- tags: [
290
- ['d', dTag],
291
- ...fileMetadata.map(v => ['file', v.rootHash, v.filename, v.mimeType])
292
- ],
355
+ tags,
293
356
  content: '',
294
357
  created_at: Math.floor(Date.now() / 1000)
295
358
  }
296
359
  const event = await signer.signEvent(appBundle)
297
- await throttledSendEvent(event, (await signer.getRelays()).write, { pause, trailingPause: true })
360
+ await throttledSendEvent(event, writeRelays, { pause, trailingPause: true, log })
298
361
  return event
299
362
  }
300
363
 
@@ -325,13 +388,14 @@ async function maybeUploadStall ({
325
388
  const hasMetadata = Boolean(trimmedName) || Boolean(trimmedSummary) || Boolean(iconRootHash) ||
326
389
  Boolean(self) || (countries && countries.length > 0) || (categories && categories.length > 0) || (hashtags && hashtags.length > 0)
327
390
 
328
- const previous = await getPreviousStall(dTag, writeRelays, signer, channel)
391
+ const relays = [...new Set([...writeRelays, ...nappRelays])]
392
+
393
+ const previousResult = await getPreviousStall(dTag, relays, signer, channel)
394
+ const previous = previousResult?.previous
329
395
  if (!previous && !hasMetadata) return { pause }
330
396
 
331
397
  const publishStall = async (event) => {
332
398
  const signedEvent = await signer.signEvent(event)
333
- // App stores are fetching stall events just from 44b relay for now
334
- const relays = [...new Set([...writeRelays, 'wss://relay.44billion.net'])]
335
399
  return await throttledSendEvent(signedEvent, relays, { pause, log, trailingPause: true })
336
400
  }
337
401
 
@@ -557,7 +621,18 @@ async function maybeUploadStall ({
557
621
  }
558
622
  }
559
623
 
560
- if (!changed) return { pause }
624
+ if (!changed) {
625
+ const { storedEvents } = previousResult
626
+
627
+ const matchingEvents = storedEvents.filter(e => e.id === previous.id)
628
+ const coveredRelays = new Set(matchingEvents.map(e => e.meta?.relay).filter(Boolean))
629
+ const missingRelays = relays.filter(r => !coveredRelays.has(r))
630
+
631
+ if (missingRelays.length === 0) return { pause }
632
+
633
+ log(`Re-uploading existing stall event to ${missingRelays.length} missing relays (out of ${relays.length})`)
634
+ return await throttledSendEvent(previous, missingRelays, { pause, log, trailingPause: true })
635
+ }
561
636
 
562
637
  return await publishStall({
563
638
  kind,
@@ -567,7 +642,7 @@ async function maybeUploadStall ({
567
642
  })
568
643
  }
569
644
 
570
- async function getPreviousStall (dTagValue, writeRelays, signer, channel) {
645
+ async function getPreviousStall (dTagValue, relays, signer, channel) {
571
646
  const kind = {
572
647
  main: 37348,
573
648
  next: 37349,
@@ -579,8 +654,15 @@ async function getPreviousStall (dTagValue, writeRelays, signer, channel) {
579
654
  authors: [await signer.getPublicKey()],
580
655
  '#d': [dTagValue],
581
656
  limit: 1
582
- }, writeRelays)).result
657
+ }, relays)).result
583
658
 
584
659
  if (storedEvents.length === 0) return null
585
- return storedEvents.sort((a, b) => b.created_at - a.created_at)[0]
660
+
661
+ storedEvents.sort((a, b) => b.created_at - a.created_at)
662
+
663
+ return {
664
+ previous: storedEvents[0],
665
+ storedEvents,
666
+ targetRelayCount: relays.length
667
+ }
586
668
  }
@@ -15,6 +15,9 @@ export const freeRelays = [
15
15
  'wss://relay.damus.io',
16
16
  'wss://relay.nostr.band'
17
17
  ]
18
+ export const nappRelays = [
19
+ 'wss://relay.44billion.net'
20
+ ]
18
21
 
19
22
  // Interacts with Nostr relays
20
23
  export class NostrRelays {
@@ -77,6 +80,7 @@ export class NostrRelays {
77
80
  const relay = await this.#getRelay(url)
78
81
  sub = relay.subscribe([filter], {
79
82
  onevent: (event) => {
83
+ event.meta = { relay: url }
80
84
  events.push(event)
81
85
  },
82
86
  onclose: err => {
@@ -112,6 +116,9 @@ export class NostrRelays {
112
116
 
113
117
  // Send an event to a list of relays
114
118
  async sendEvent (event, relays, timeout = 3000) {
119
+ const eventToSend = event.meta ? { ...event } : event
120
+ if (eventToSend.meta) delete eventToSend.meta
121
+
115
122
  const promises = relays.map(async (url) => {
116
123
  let timer
117
124
  const p = Promise.withResolvers()
@@ -121,7 +128,7 @@ export class NostrRelays {
121
128
  }, timeout))
122
129
 
123
130
  const relay = await this.#getRelay(url)
124
- await relay.publish(event)
131
+ await relay.publish(eventToSend)
125
132
  p.resolve()
126
133
  } catch (err) {
127
134
  if (err.message?.startsWith('duplicate:')) return p.resolve()