nappup 1.6.0 → 1.7.1

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.6.0",
9
+ "version": "1.7.1",
10
10
  "description": "Nostr App Uploader",
11
11
  "type": "module",
12
12
  "scripts": {
@@ -0,0 +1,27 @@
1
+ import nostrRelays, { seedRelays, freeRelays } from '#services/nostr-relays.js'
2
+
3
+ export async function getRelays () {
4
+ if (this.relays) return this.relays
5
+
6
+ const relayLists = (await nostrRelays.getEvents({ authors: [await this.getPublicKey()], kinds: [10002], limit: 1 }, seedRelays)).result
7
+ const relayList = relayLists.sort((a, b) => b.created_at - a.created_at)[0]
8
+ const rTags = (relayList?.tags ?? []).filter(v => v[0] === 'r' && /^wss?:\/\//.test(v[1]))
9
+ if (rTags.length === 0) {
10
+ const defaults = freeRelays.slice(0, 2)
11
+ return (this.relays = { read: defaults, write: defaults })
12
+ }
13
+
14
+ let keys
15
+ const keyAllowList = { read: true, write: true }
16
+ const relays = rTags.reduce((r, v) => {
17
+ keys = [v[2]].filter(v2 => keyAllowList[v2])
18
+ if (keys.length === 0) keys = ['read', 'write']
19
+ keys.forEach(k => r[k].push(v[1].trim().replace(/\/$/, '')))
20
+ return r
21
+ }, { read: [], write: [] })
22
+ for (const k in relays) {
23
+ if (relays[k].length === 0) relays[k].push(...freeRelays)
24
+ relays[k] = [...new Set(relays[k])]
25
+ }
26
+ return (this.relays = relays)
27
+ }
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 nostrRelays, { nappRelays } from '#services/nostr-relays.js'
4
- import NostrSigner from '#services/nostr-signer.js'
4
+ import { getRelays } from '#helpers/signer.js'
5
5
  import { streamToChunks, streamToText } from '#helpers/stream.js'
6
6
  import { isNostrAppDTagSafe, deriveNostrAppDTag } from '#helpers/app.js'
7
7
  import { extractHtmlMetadata, findFavicon, findIndexFile } from '#helpers/app-metadata.js'
@@ -9,19 +9,70 @@ import { NAPP_CATEGORIES } from '#config/napp-categories.js'
9
9
  import { getBlossomServers, healthCheckServers, uploadFilesToBlossom } from '#services/blossom-upload.js'
10
10
  import { uploadBinaryDataChunks, throttledSendEvent } from '#services/irfs-upload.js'
11
11
 
12
- export default async function (...args) {
12
+ // TL;DR
13
+ // import publishApp from 'nappup'
14
+ // await publishApp(
15
+ // fileList,
16
+ // window.nostr,
17
+ // { dTagRaw: 'My app identifier unique to this nsec', onEvent: ({ progress }) => console.log(progress) }
18
+ // )
19
+ //
20
+ // Simple usage -> onEvent: ({ progress, error }) => { if (error) { throw error } else { progressBar.style.width = `${progress}%` } }
21
+ // Geek usage ->
22
+ // onEvent: (event) => {
23
+ // if (event.type === 'file-uploaded') console.log(`Uploaded ${event.filename} via ${event.service}`)
24
+ // if (event.type === 'complete') console.log(`Done! Access at https://44billion.net/${event.naddr}`)
25
+ // if (event.type === 'error') console.error('Error during publishing:', event.error)
26
+ // }
27
+ //
28
+ export default async function (fileList, nostrSigner, opts = {}) {
29
+ const onEvent = typeof opts.onEvent === 'function' ? opts.onEvent : null
30
+ let lastProgress = 0
13
31
  try {
14
- return await toApp(...args)
32
+ return await toApp(fileList, nostrSigner, onEvent
33
+ ? {
34
+ ...opts,
35
+ onEvent (event) {
36
+ lastProgress = event.progress ?? lastProgress
37
+ onEvent(event)
38
+ }
39
+ }
40
+ : opts)
41
+ } catch (err) {
42
+ if (onEvent) try { onEvent({ type: 'error', error: err, progress: lastProgress }) } catch (_) {}
43
+ throw err
15
44
  } finally {
16
45
  await nostrRelays.disconnectAll()
17
46
  }
18
47
  }
19
48
 
20
- export async function toApp (fileList, nostrSigner, { log = () => {}, dTag, dTagRaw, channel = 'main', shouldReupload = false } = {}) {
49
+ /**
50
+ * Publishes an app to Nostr relays and/or blossom servers.
51
+ *
52
+ * The optional `onEvent` callback receives structured progress events.
53
+ * Every event has `type` (string) and `progress` (0–100 integer).
54
+ *
55
+ * Event types:
56
+ * 'init' — { totalFiles, totalSteps, dTag, relayCount, blossomCount }
57
+ * 'icon-uploaded' — { service: 'blossom'|'irfs'|null }
58
+ * 'file-uploaded' — { filename, service: 'blossom'|'irfs' }
59
+ * 'stall-published' — listing metadata published
60
+ * 'bundle-published' — app bundle published
61
+ * 'complete' — { napp } (terminal, progress === 100)
62
+ * 'error' — { error } (terminal, error is rethrown)
63
+ *
64
+ * Terminal events ('complete' or 'error') signal that no more events will follow.
65
+ * The 'error' event is only emitted when using the default export wrapper.
66
+ * Direct `toApp` callers receive the thrown error via normal async/await.
67
+ */
68
+ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent = () => {}, dTag, dTagRaw, channel = 'main', shouldReupload = false } = {}) {
69
+ let _steps = 0
70
+ let _totalSteps = 1
71
+ const emit = (event) => { try { onEvent({ ...event, progress: event.type === 'complete' ? 100 : Math.round((_steps / _totalSteps) * 100) }) } catch (_) {} }
21
72
  if (!nostrSigner && typeof window !== 'undefined') nostrSigner = window.nostr
22
73
  if (!nostrSigner) throw new Error('No Nostr signer found')
23
74
  if (typeof window !== 'undefined' && nostrSigner === window.nostr) {
24
- nostrSigner.getRelays = NostrSigner.prototype.getRelays
75
+ nostrSigner.getRelays = getRelays
25
76
  }
26
77
  const writeRelays = [...new Set((await nostrSigner.getRelays()).write.map(r => r.trim().replace(/\/$/, '')))]
27
78
  log(`Found ${writeRelays.length} outbox relays for pubkey ${nostrSigner.getPublicKey()}:\n${writeRelays.join(', ')}`)
@@ -82,6 +133,10 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, dTag, dTag
82
133
 
83
134
  const useBlossom = healthyBlossomServers.length > 0
84
135
 
136
+ const hasIconUpload = Boolean(nappJson.stallIcon?.[0]?.[0])
137
+ _totalSteps = fileList.length + (hasIconUpload ? 1 : 0) + 2
138
+ emit({ type: 'init', totalFiles: fileList.length, totalSteps: _totalSteps, dTag, relayCount: writeRelays.length, blossomCount: healthyBlossomServers.length })
139
+
85
140
  // Upload icon from napp.json if present
86
141
  if (nappJson.stallIcon?.[0]?.[0]) {
87
142
  try {
@@ -106,7 +161,7 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, dTag, dTag
106
161
  iconMetadata = {
107
162
  rootHash: uploadedFiles[0].sha256,
108
163
  mimeType,
109
- service: 'blossom'
164
+ service: 'b' // blossom
110
165
  }
111
166
  } else if (failedFiles.length > 0) {
112
167
  log('Blossom icon upload failed, falling back to relay upload')
@@ -127,7 +182,7 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, dTag, dTag
127
182
  iconMetadata = {
128
183
  rootHash: nmmr.getRoot(),
129
184
  mimeType,
130
- service: 'irfs'
185
+ service: 'i' // relay (irfs)
131
186
  }
132
187
  }
133
188
  }
@@ -136,6 +191,11 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, dTag, dTag
136
191
  }
137
192
  }
138
193
 
194
+ if (hasIconUpload) {
195
+ _steps++
196
+ emit({ type: 'icon-uploaded', service: iconMetadata?.service === 'b' ? 'blossom' : iconMetadata ? 'irfs' : null })
197
+ }
198
+
139
199
  log(`Processing ${fileList.length} files`)
140
200
 
141
201
  // Files to upload via relay (irfs) — either all files or blossom failures
@@ -155,16 +215,19 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, dTag, dTag
155
215
  rootHash: uploaded.sha256,
156
216
  filename: uploaded.filename,
157
217
  mimeType: uploaded.mimeType,
158
- service: 'blossom'
218
+ service: 'b'
159
219
  })
160
220
 
161
221
  if (faviconFile && uploaded.file === faviconFile) {
162
222
  iconMetadata = {
163
223
  rootHash: uploaded.sha256,
164
224
  mimeType: uploaded.mimeType,
165
- service: 'blossom'
225
+ service: 'b'
166
226
  }
167
227
  }
228
+
229
+ _steps++
230
+ emit({ type: 'file-uploaded', filename: uploaded.filename, service: 'blossom' })
168
231
  }
169
232
 
170
233
  if (failedFiles.length > 0) {
@@ -193,16 +256,19 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, dTag, dTag
193
256
  rootHash: nmmr.getRoot(),
194
257
  filename,
195
258
  mimeType: file.type || 'application/octet-stream',
196
- service: 'irfs'
259
+ service: 'i'
197
260
  })
198
261
 
199
262
  if (faviconFile && file === faviconFile) {
200
263
  iconMetadata = {
201
264
  rootHash: nmmr.getRoot(),
202
265
  mimeType: file.type || 'application/octet-stream',
203
- service: 'irfs'
266
+ service: 'i'
204
267
  }
205
268
  }
269
+
270
+ _steps++
271
+ emit({ type: 'file-uploaded', filename, service: 'irfs' })
206
272
  }
207
273
  }
208
274
 
@@ -228,6 +294,8 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, dTag, dTag
228
294
  categories: nappJson.category,
229
295
  hashtags: nappJson.hashtag
230
296
  })))
297
+ _steps++
298
+ emit({ type: 'stall-published' })
231
299
 
232
300
  log(`Uploading bundle ${dTag}`)
233
301
  const bundle = await uploadBundle({ dTag, channel, fileMetadata, signer: nostrSigner, pause, shouldReupload, log })
@@ -238,7 +306,11 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, dTag, dTag
238
306
  relays: [],
239
307
  kind: bundle.kind
240
308
  })
309
+ _steps++
310
+ emit({ type: 'bundle-published' })
311
+
241
312
  log(`Visit at https://44billion.net/${appEntity}`)
313
+ emit({ type: 'complete', napp: appEntity })
242
314
  }
243
315
 
244
316
  async function uploadBundle ({ dTag, channel, fileMetadata, signer, pause = 0, shouldReupload = false, log = () => {} }) {
@@ -248,7 +320,7 @@ async function uploadBundle ({ dTag, channel, fileMetadata, signer, pause = 0, s
248
320
  draft: 37450 // vibe coded preview
249
321
  }[channel] ?? 37448
250
322
 
251
- const fileTags = fileMetadata.map(v => ['file', v.rootHash, v.filename, v.mimeType, v.service || 'irfs'])
323
+ const fileTags = fileMetadata.map(v => ['file', v.rootHash, v.filename, v.mimeType, v.service || 'i'])
252
324
  const tags = [
253
325
  ['d', dTag],
254
326
  ...fileTags
@@ -285,7 +357,7 @@ async function uploadBundle ({ dTag, channel, fileMetadata, signer, pause = 0, s
285
357
 
286
358
  const isSame = currentFileTags.length === recentFileTags.length && currentFileTags.every((t, i) => {
287
359
  const rt = recentFileTags[i]
288
- return rt.length >= 4 && rt[1] === t[1] && rt[2] === t[2] && rt[3] === t[3] && (rt[4] || 'irfs') === (t[4] || 'irfs')
360
+ return rt.length >= 4 && rt[1] === t[1] && rt[2] === t[2] && rt[3] === t[3] && (rt[4] || 'i') === (t[4] || 'i')
289
361
  })
290
362
 
291
363
  if (isSame) {
@@ -346,7 +418,7 @@ async function maybeUploadStall ({
346
418
  const trimmedSummary = typeof summary === 'string' ? summary.trim() : ''
347
419
  const iconRootHash = icon?.rootHash
348
420
  const iconMimeType = icon?.mimeType
349
- const iconService = icon?.service || 'irfs'
421
+ const iconService = icon?.service || 'i'
350
422
  const hasMetadata = Boolean(trimmedName) || Boolean(trimmedSummary) || Boolean(iconRootHash) ||
351
423
  Boolean(self) || (countries && countries.length > 0) || (categories && categories.length > 0) || (hashtags && hashtags.length > 0)
352
424
 
@@ -5,6 +5,7 @@ import * as dotenv from 'dotenv'
5
5
  import { getPublicKey } from 'nostr-tools/pure'
6
6
  import { getConversationKey, encrypt, decrypt } from 'nostr-tools/nip44'
7
7
  import nostrRelays, { seedRelays, freeRelays } from '#services/nostr-relays.js'
8
+ import { getRelays } from '#helpers/signer.js'
8
9
  import { bytesToBase16, base16ToBytes } from '#helpers/base16.js'
9
10
  import { finalizeEvent } from '#helpers/nip01.js'
10
11
  import { nsecDecode, nsecEncode } from '#helpers/nip19.js'
@@ -59,50 +60,24 @@ export default class NostrSigner {
59
60
  }
60
61
 
61
62
  async getRelays () {
62
- if (this.relays) return this.relays
63
-
64
- const relayLists = (await nostrRelays.getEvents({ authors: [await this.getPublicKey()], kinds: [10002], limit: 1 }, seedRelays)).result
65
- const relayList = relayLists.sort((a, b) => b.created_at - a.created_at)[0]
66
- const rTags = (relayList?.tags ?? []).filter(v => v[0] === 'r' && /^wss?:\/\//.test(v[1]))
67
- if (rTags.length === 0) return (this.relays = await this.#initRelays())
68
-
69
- let keys
70
- const keyAllowList = { read: true, write: true }
71
- const relays = rTags.reduce((r, v) => {
72
- keys = [v[2]].filter(v2 => keyAllowList[v2])
73
- if (keys.length === 0) keys = ['read', 'write']
74
- keys.forEach(k => r[k].push(v[1].trim().replace(/\/$/, '')))
75
- return r
76
- }, { read: [], write: [] })
77
- for (const k in relays) {
78
- if (relays[k].length === 0) relays[k].push(...freeRelays)
79
- relays[k] = [...new Set(relays[k])]
80
- }
81
- return (this.relays = relays)
63
+ return getRelays.call(this)
82
64
  }
83
65
 
84
- async #initRelays () {
66
+ async #initSk () {
85
67
  const relays = freeRelays.slice(0, 2)
86
- this.relays = {
87
- read: relays,
88
- write: relays
89
- }
68
+ this.relays = { read: relays, write: relays }
90
69
  const relayList = await this.signEvent({
91
70
  kind: 10002,
92
- pubkey: await this.getPublicKey(),
71
+ pubkey: this.getPublicKey(),
93
72
  tags: relays.map(v => ['r', v]),
94
73
  content: '',
95
74
  created_at: Math.floor(Date.now() / 1000)
96
75
  })
97
- await nostrRelays.sendEvent(relayList, [...new Set([...seedRelays, ...this.relays.write].map(r => r.trim().replace(/\/$/, '')))])
98
- return this.relays
99
- }
76
+ await nostrRelays.sendEvent(relayList, [...new Set([...seedRelays, ...relays].map(r => r.trim().replace(/\/$/, '')))])
100
77
 
101
- async #initSk () {
102
- const pubkey = this.getPublicKey()
103
- const profile = this.signEvent({
78
+ const profile = await this.signEvent({
104
79
  kind: 0,
105
- pubkey,
80
+ pubkey: this.getPublicKey(),
106
81
  tags: [],
107
82
  content: JSON.stringify({
108
83
  name: `Publisher #${Math.random().toString(36).slice(2)}`,
@@ -110,8 +85,7 @@ export default class NostrSigner {
110
85
  }),
111
86
  created_at: Math.floor(Date.now() / 1000)
112
87
  })
113
- const writeRelays = (await this.getRelays()).write
114
- await nostrRelays.sendEvent(profile, writeRelays)
88
+ await nostrRelays.sendEvent(profile, relays)
115
89
  }
116
90
 
117
91
  // hex