nappup 1.7.1 → 1.8.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.
@@ -9,7 +9,6 @@ export function parseArgs (args) {
9
9
  let dir = null
10
10
  let sk = null
11
11
  let dTag = null
12
- let dTagRaw = null
13
12
  let channel = null
14
13
  let shouldReupload = false
15
14
  let yes = false
@@ -21,9 +20,6 @@ export function parseArgs (args) {
21
20
  } else if (args[i] === '-d' && args[i + 1]) {
22
21
  dTag = args[i + 1]
23
22
  i++
24
- } else if (args[i] === '-D' && args[i + 1]) {
25
- dTagRaw = args[i + 1]
26
- i++
27
23
  } else if (args[i] === '--main' && channel === null) {
28
24
  channel = 'main'
29
25
  } else if (args[i] === '--next' && channel === null) {
@@ -43,7 +39,6 @@ export function parseArgs (args) {
43
39
  dir: path.resolve(dir ?? '.'),
44
40
  sk,
45
41
  dTag,
46
- dTagRaw,
47
42
  channel: channel || 'main',
48
43
  shouldReupload,
49
44
  yes
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import path from 'node:path'
2
3
  import NostrSigner from '#services/nostr-signer.js'
4
+ import { GENERIC_BUILD_FOLDER_NAMES } from '#helpers/app.js'
3
5
  import {
4
6
  parseArgs,
5
7
  confirmArgs,
@@ -11,7 +13,23 @@ import toApp from '#index.js'
11
13
  const args = parseArgs(process.argv.slice(2))
12
14
  await confirmArgs(args)
13
15
 
14
- const { dir, sk, dTag, dTagRaw, channel, shouldReupload } = args
16
+ const { dir, sk, channel, shouldReupload } = args
17
+ let { dTag } = args
18
+
19
+ if (!dTag) {
20
+ let folderName = path.basename(dir)
21
+ if (GENERIC_BUILD_FOLDER_NAMES.has(folderName.toLowerCase())) {
22
+ const parentName = path.basename(path.dirname(dir))
23
+ if (parentName && parentName !== '.' && parentName !== '/' && !GENERIC_BUILD_FOLDER_NAMES.has(parentName.toLowerCase())) {
24
+ folderName = parentName
25
+ } else {
26
+ console.error(`Directory name "${folderName}" is a generic build folder. Please provide a d tag with -d.`)
27
+ process.exit(1)
28
+ }
29
+ }
30
+ dTag = folderName
31
+ }
32
+
15
33
  const fileList = await toFileList(getFiles(dir), dir)
16
34
 
17
- await toApp(fileList, await NostrSigner.create(sk), { log: console.log.bind(console), dTag, dTagRaw, channel, shouldReupload })
35
+ await toApp(fileList, await NostrSigner.create(sk), { log: console.log.bind(console), dTag, channel, shouldReupload })
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.7.1",
9
+ "version": "1.8.0",
10
10
  "description": "Nostr App Uploader",
11
11
  "type": "module",
12
12
  "scripts": {
@@ -1,41 +1,9 @@
1
- import { bytesToBase36, isBase36 } from '#helpers/base36.js'
1
+ export const NOSTR_APP_D_TAG_MAX_LENGTH = 260
2
2
 
3
- // 63 - (1<channel> + 5<b36loggeduserpkslug> 50<b36pk>)
4
- // <b36loggeduserpkslug> pk chars at positions [7][17][27][37][47]
5
- // to avoid vanity or pow colisions
6
- export const NOSTR_APP_D_TAG_MAX_LENGTH = 7
3
+ export const GENERIC_BUILD_FOLDER_NAMES = new Set([
4
+ 'build', 'dist', 'out', 'output', 'public', 'www', '_site', '.next', '.output', '.nuxt'
5
+ ])
7
6
 
8
7
  export function isNostrAppDTagSafe (string) {
9
- return string.length > 0 && string.length <= NOSTR_APP_D_TAG_MAX_LENGTH && isBase36(string)
10
- }
11
-
12
- export function isSubdomainSafe (string) {
13
- return /(?:^[a-z0-9]$)|(?:^(?!.*--)[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$)/.test(string)
14
- }
15
-
16
- export function deriveNostrAppDTag (string) {
17
- return toSubdomainSafe(string, NOSTR_APP_D_TAG_MAX_LENGTH)
18
- }
19
-
20
- async function toSubdomainSafe (string, maxStringLength) {
21
- const byteLength = baseMaxLengthToMaxSourceByteLength(maxStringLength, 36)
22
- const bytes = (await toSha1(string)).slice(0, byteLength)
23
- return bytesToBase36(bytes, maxStringLength)
24
- }
25
-
26
- async function toSha1 (string) {
27
- const bytes = new TextEncoder().encode(string)
28
- return new Uint8Array(await crypto.subtle.digest('SHA-1', bytes))
29
- }
30
-
31
- // baseMaxLengthToMaxSourceByteLength(19, 62) === 14 byte length
32
- // baseMaxLengthToMaxSourceByteLength(7, 36) === 4 byte length
33
- function baseMaxLengthToMaxSourceByteLength (maxStringLength, base) {
34
- if (!base) throw new Error('Which base?')
35
- const baseLog = Math.log(base)
36
- const log256 = Math.log(256)
37
-
38
- const maxByteLength = (maxStringLength * baseLog) / log256
39
-
40
- return Math.floor(maxByteLength)
8
+ return typeof string === 'string' && string.length <= NOSTR_APP_D_TAG_MAX_LENGTH
41
9
  }
@@ -9,9 +9,9 @@ const textEncoder = new TextEncoder()
9
9
  const textDecoder = new TextDecoder()
10
10
 
11
11
  const kindByChannel = {
12
- main: 37448,
13
- next: 37449,
14
- draft: 37450
12
+ main: 35128,
13
+ next: 35129,
14
+ draft: 35130
15
15
  }
16
16
  const channelByKind = Object.fromEntries(
17
17
  Object.entries(kindByChannel).map(([k, v]) => [v, k])
package/src/index.js CHANGED
@@ -3,7 +3,7 @@ import { appEncode } from '#helpers/nip19.js'
3
3
  import nostrRelays, { nappRelays } from '#services/nostr-relays.js'
4
4
  import { getRelays } from '#helpers/signer.js'
5
5
  import { streamToChunks, streamToText } from '#helpers/stream.js'
6
- import { isNostrAppDTagSafe, deriveNostrAppDTag } from '#helpers/app.js'
6
+ import { isNostrAppDTagSafe, GENERIC_BUILD_FOLDER_NAMES } from '#helpers/app.js'
7
7
  import { extractHtmlMetadata, findFavicon, findIndexFile } from '#helpers/app-metadata.js'
8
8
  import { NAPP_CATEGORIES } from '#config/napp-categories.js'
9
9
  import { getBlossomServers, healthCheckServers, uploadFilesToBlossom } from '#services/blossom-upload.js'
@@ -14,14 +14,14 @@ import { uploadBinaryDataChunks, throttledSendEvent } from '#services/irfs-uploa
14
14
  // await publishApp(
15
15
  // fileList,
16
16
  // window.nostr,
17
- // { dTagRaw: 'My app identifier unique to this nsec', onEvent: ({ progress }) => console.log(progress) }
17
+ // { dTag: 'My app identifier unique to this nsec', onEvent: ({ progress }) => console.log(progress) }
18
18
  // )
19
19
  //
20
20
  // Simple usage -> onEvent: ({ progress, error }) => { if (error) { throw error } else { progressBar.style.width = `${progress}%` } }
21
21
  // Geek usage ->
22
22
  // onEvent: (event) => {
23
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}`)
24
+ // if (event.type === 'complete') console.log(`Done! Access at https://44billion.net/${event.napp}`)
25
25
  // if (event.type === 'error') console.error('Error during publishing:', event.error)
26
26
  // }
27
27
  //
@@ -47,25 +47,25 @@ export default async function (fileList, nostrSigner, opts = {}) {
47
47
  }
48
48
 
49
49
  /**
50
- * Publishes an app to Nostr relays and/or blossom servers.
50
+ * Publishes a site to Nostr relays and/or blossom servers.
51
51
  *
52
52
  * The optional `onEvent` callback receives structured progress events.
53
53
  * Every event has `type` (string) and `progress` (0–100 integer).
54
54
  *
55
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)
56
+ * 'init' — { totalFiles, totalSteps, dTag, relayCount, blossomCount }
57
+ * 'media-uploaded' — { mediaType: 'icon'|'key_art'|'screenshot', service: 'blossom'|'irfs'|null }
58
+ * 'file-uploaded' — { filename, service: 'blossom'|'irfs' }
59
+ * 'listing-published' — app listing metadata published
60
+ * 'manifest-published' — site manifest published
61
+ * 'complete' — { napp } (terminal, progress === 100)
62
+ * 'error' — { error } (terminal, error is rethrown)
63
63
  *
64
64
  * Terminal events ('complete' or 'error') signal that no more events will follow.
65
65
  * The 'error' event is only emitted when using the default export wrapper.
66
66
  * Direct `toApp` callers receive the thrown error via normal async/await.
67
67
  */
68
- export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent = () => {}, dTag, dTagRaw, channel = 'main', shouldReupload = false } = {}) {
68
+ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent = () => {}, dTag, channel = 'main', shouldReupload = false } = {}) {
69
69
  let _steps = 0
70
70
  let _totalSteps = 1
71
71
  const emit = (event) => { try { onEvent({ ...event, progress: event.type === 'complete' ? 100 : Math.round((_steps / _totalSteps) * 100) }) } catch (_) {} }
@@ -79,12 +79,15 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent =
79
79
  if (writeRelays.length === 0) throw new Error('No outbox relays found')
80
80
 
81
81
  if (typeof dTag === 'string') {
82
- if (!isNostrAppDTagSafe(dTag)) throw new Error('dTag should be [A-Za-z0-9] with length ranging from 1 to 19')
82
+ if (!isNostrAppDTagSafe(dTag)) throw new Error('dTag must be a non-empty string with at most 260 characters')
83
83
  } else {
84
- dTag = dTagRaw || fileList[0].webkitRelativePath.split('/')[0].trim()
85
- if (!isNostrAppDTagSafe(dTag)) dTag = await deriveNostrAppDTag(dTag || Math.random().toString(36))
84
+ const folderName = fileList[0].webkitRelativePath.split('/')[0].trim()
85
+ if (GENERIC_BUILD_FOLDER_NAMES.has(folderName.toLowerCase())) {
86
+ throw new Error(`Folder name "${folderName}" is a generic build folder. Please provide a d tag with the -d flag.`)
87
+ }
88
+ dTag = folderName
89
+ if (!isNostrAppDTagSafe(dTag)) throw new Error('Could not derive a valid d tag from the folder name. Please provide one with the -d flag.')
86
90
  }
87
- let nmmr
88
91
  const fileMetadata = []
89
92
 
90
93
  // Check for .well-known/napp.json
@@ -101,15 +104,15 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent =
101
104
  }
102
105
 
103
106
  const indexFile = findIndexFile(fileList)
104
- let stallName = nappJson.stallName?.[0]?.[0]
105
- let stallSummary = nappJson.stallSummary?.[0]?.[0]
107
+ let listingName = nappJson.name?.[0]?.[0]
108
+ let listingSummary = nappJson.summary?.[0]?.[0]
106
109
 
107
- if (indexFile && (!stallName || !stallSummary)) {
110
+ if (indexFile && (!listingName || !listingSummary)) {
108
111
  try {
109
112
  const htmlContent = await streamToText(indexFile.stream())
110
113
  const { name, description } = extractHtmlMetadata(htmlContent)
111
- if (!stallName) stallName = name
112
- if (!stallSummary) stallSummary = description
114
+ if (!listingName) listingName = name
115
+ if (!listingSummary) listingSummary = description
113
116
  } catch (err) {
114
117
  log('Error extracting HTML metadata:', err)
115
118
  }
@@ -131,77 +134,97 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent =
131
134
  log('No blossom servers configured, will use relay-based file upload (irfs)')
132
135
  }
133
136
 
134
- const useBlossom = healthyBlossomServers.length > 0
137
+ const uploadService = healthyBlossomServers.length > 0 ? 'blossom' : 'irfs'
138
+
139
+ // Helper: upload a data URL to the chosen service, returns { rootHash, mimeType }
140
+ const uploadMediaFromDataUrl = async (dataUrl, mediaName) => {
141
+ const res = await fetch(dataUrl)
142
+ const blob = await res.blob()
143
+ const mimeType = blob.type
144
+ const extension = mimeType.split('/')[1] || 'bin'
145
+ const filename = `${mediaName}.${extension}`
146
+
147
+ if (uploadService === 'blossom') {
148
+ const { uploadedFiles, failedFiles } = await uploadFilesToBlossom({
149
+ fileList: [Object.assign(blob, { webkitRelativePath: `_/${filename}` })],
150
+ servers: healthyBlossomServers,
151
+ signer: nostrSigner,
152
+ shouldReupload,
153
+ log
154
+ })
155
+ if (failedFiles.length > 0) throw new Error(`Blossom upload failed for ${mediaName}`)
156
+ return { rootHash: uploadedFiles[0].sha256, mimeType }
157
+ }
158
+
159
+ const nmmr = new NMMR()
160
+ const stream = blob.stream()
161
+ let chunkLength = 0
162
+ for await (const chunk of streamToChunks(stream, 51000)) {
163
+ chunkLength++
164
+ await nmmr.append(chunk)
165
+ }
166
+ if (!chunkLength) return null
167
+ ;({ pause } = (await uploadBinaryDataChunks({ nmmr, signer: nostrSigner, filename, chunkLength, log, pause, mimeType, shouldReupload })))
168
+ return { rootHash: nmmr.getRoot(), mimeType }
169
+ }
135
170
 
136
- const hasIconUpload = Boolean(nappJson.stallIcon?.[0]?.[0])
137
- _totalSteps = fileList.length + (hasIconUpload ? 1 : 0) + 2
171
+ // Count media uploads for progress tracking
172
+ const hasIconUpload = Boolean(nappJson.icon?.[0]?.[0])
173
+ const keyArtEntries = nappJson.keyArt || []
174
+ const screenshotEntries = nappJson.screenshot || []
175
+ const mediaUploadCount = (hasIconUpload ? 1 : 0) + keyArtEntries.length + screenshotEntries.length
176
+ _totalSteps = fileList.length + mediaUploadCount + 2
138
177
  emit({ type: 'init', totalFiles: fileList.length, totalSteps: _totalSteps, dTag, relayCount: writeRelays.length, blossomCount: healthyBlossomServers.length })
139
178
 
140
179
  // Upload icon from napp.json if present
141
- if (nappJson.stallIcon?.[0]?.[0]) {
180
+ if (nappJson.icon?.[0]?.[0]) {
142
181
  try {
143
- const dataUrl = nappJson.stallIcon[0][0]
144
- const res = await fetch(dataUrl)
145
- const blob = await res.blob()
146
- const mimeType = blob.type
147
- const extension = mimeType.split('/')[1] || 'bin'
148
- const filename = `icon.${extension}`
149
-
150
182
  log('Uploading icon from napp.json')
151
-
152
- if (useBlossom) {
153
- const { uploadedFiles, failedFiles } = await uploadFilesToBlossom({
154
- fileList: [Object.assign(blob, { webkitRelativePath: `_/${filename}` })],
155
- servers: healthyBlossomServers,
156
- signer: nostrSigner,
157
- shouldReupload,
158
- log
159
- })
160
- if (uploadedFiles.length > 0) {
161
- iconMetadata = {
162
- rootHash: uploadedFiles[0].sha256,
163
- mimeType,
164
- service: 'b' // blossom
165
- }
166
- } else if (failedFiles.length > 0) {
167
- log('Blossom icon upload failed, falling back to relay upload')
168
- }
169
- }
170
-
171
- if (!iconMetadata) {
172
- nmmr = new NMMR()
173
- const stream = blob.stream()
174
- let chunkLength = 0
175
- for await (const chunk of streamToChunks(stream, 51000)) {
176
- chunkLength++
177
- await nmmr.append(chunk)
178
- }
179
-
180
- if (chunkLength) {
181
- ;({ pause } = (await uploadBinaryDataChunks({ nmmr, signer: nostrSigner, filename, chunkLength, log, pause, mimeType, shouldReupload })))
182
- iconMetadata = {
183
- rootHash: nmmr.getRoot(),
184
- mimeType,
185
- service: 'i' // relay (irfs)
186
- }
187
- }
188
- }
183
+ iconMetadata = await uploadMediaFromDataUrl(nappJson.icon[0][0], 'icon')
189
184
  } catch (e) {
190
185
  log('Failed to upload icon from napp.json', e)
191
186
  }
187
+ _steps++
188
+ emit({ type: 'media-uploaded', mediaType: 'icon', service: iconMetadata ? uploadService : null })
192
189
  }
193
190
 
194
- if (hasIconUpload) {
191
+ // Upload key art from napp.json
192
+ const keyArtMetadata = []
193
+ for (const entry of keyArtEntries) {
194
+ const dataUrl = entry[0]
195
+ const country = entry[1]
196
+ if (!dataUrl) { _steps++; emit({ type: 'media-uploaded', mediaType: 'key_art', service: null }); continue }
197
+ try {
198
+ log(`Uploading key art from napp.json${country ? ` (${country})` : ''}`)
199
+ const uploaded = await uploadMediaFromDataUrl(dataUrl, 'key_art')
200
+ if (uploaded) keyArtMetadata.push({ ...uploaded, country })
201
+ } catch (e) {
202
+ log('Failed to upload key art from napp.json', e)
203
+ }
195
204
  _steps++
196
- emit({ type: 'icon-uploaded', service: iconMetadata?.service === 'b' ? 'blossom' : iconMetadata ? 'irfs' : null })
205
+ emit({ type: 'media-uploaded', mediaType: 'key_art', service: keyArtMetadata.length > 0 ? uploadService : null })
197
206
  }
198
207
 
199
- log(`Processing ${fileList.length} files`)
208
+ // Upload screenshots from napp.json
209
+ const screenshotMetadata = []
210
+ for (const entry of screenshotEntries) {
211
+ const dataUrl = entry[0]
212
+ const country = entry[1]
213
+ if (!dataUrl) { _steps++; emit({ type: 'media-uploaded', mediaType: 'screenshot', service: null }); continue }
214
+ try {
215
+ log(`Uploading screenshot from napp.json${country ? ` (${country})` : ''}`)
216
+ const uploaded = await uploadMediaFromDataUrl(dataUrl, 'screenshot')
217
+ if (uploaded) screenshotMetadata.push({ ...uploaded, country })
218
+ } catch (e) {
219
+ log('Failed to upload screenshot from napp.json', e)
220
+ }
221
+ _steps++
222
+ emit({ type: 'media-uploaded', mediaType: 'screenshot', service: screenshotMetadata.length > 0 ? uploadService : null })
223
+ }
200
224
 
201
- // Files to upload via relay (irfs) either all files or blossom failures
202
- let irfsFileList = fileList
225
+ log(`Processing ${fileList.length} files`)
203
226
 
204
- if (useBlossom) {
227
+ if (uploadService === 'blossom') {
205
228
  const { uploadedFiles, failedFiles } = await uploadFilesToBlossom({
206
229
  fileList,
207
230
  servers: healthyBlossomServers,
@@ -210,80 +233,77 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent =
210
233
  log
211
234
  })
212
235
 
236
+ if (failedFiles.length > 0) {
237
+ throw new Error(`${failedFiles.length} file(s) failed to upload to blossom`)
238
+ }
239
+
213
240
  for (const uploaded of uploadedFiles) {
214
241
  fileMetadata.push({
215
242
  rootHash: uploaded.sha256,
216
243
  filename: uploaded.filename,
217
- mimeType: uploaded.mimeType,
218
- service: 'b'
244
+ mimeType: uploaded.mimeType
219
245
  })
220
246
 
221
247
  if (faviconFile && uploaded.file === faviconFile) {
222
248
  iconMetadata = {
223
249
  rootHash: uploaded.sha256,
224
- mimeType: uploaded.mimeType,
225
- service: 'b'
250
+ mimeType: uploaded.mimeType
226
251
  }
227
252
  }
228
253
 
229
254
  _steps++
230
255
  emit({ type: 'file-uploaded', filename: uploaded.filename, service: 'blossom' })
231
256
  }
232
-
233
- if (failedFiles.length > 0) {
234
- log(`${failedFiles.length} files failed blossom upload, falling back to relay upload (irfs)`)
235
- irfsFileList = failedFiles.map(f => f.file)
236
- } else {
237
- irfsFileList = []
238
- }
239
- }
240
-
241
- for (const file of irfsFileList) {
242
- nmmr = new NMMR()
243
- const stream = file.stream()
244
-
245
- let chunkLength = 0
246
- for await (const chunk of streamToChunks(stream, 51000)) {
247
- chunkLength++
248
- await nmmr.append(chunk)
249
- }
250
- if (chunkLength) {
251
- // remove root dir
252
- const filename = file.webkitRelativePath.split('/').slice(1).join('/')
253
- log(`Uploading ${chunkLength} file parts of ${filename}`)
254
- ;({ pause } = (await uploadBinaryDataChunks({ nmmr, signer: nostrSigner, filename, chunkLength, log, pause, mimeType: file.type || 'application/octet-stream', shouldReupload })))
255
- fileMetadata.push({
256
- rootHash: nmmr.getRoot(),
257
- filename,
258
- mimeType: file.type || 'application/octet-stream',
259
- service: 'i'
260
- })
261
-
262
- if (faviconFile && file === faviconFile) {
263
- iconMetadata = {
257
+ } else {
258
+ for (const file of fileList) {
259
+ const nmmr = new NMMR()
260
+ const stream = file.stream()
261
+
262
+ let chunkLength = 0
263
+ for await (const chunk of streamToChunks(stream, 51000)) {
264
+ chunkLength++
265
+ await nmmr.append(chunk)
266
+ }
267
+ if (chunkLength) {
268
+ // remove root dir
269
+ const filename = file.webkitRelativePath.split('/').slice(1).join('/')
270
+ log(`Uploading ${chunkLength} file parts of ${filename}`)
271
+ ;({ pause } = (await uploadBinaryDataChunks({ nmmr, signer: nostrSigner, filename, chunkLength, log, pause, mimeType: file.type || 'application/octet-stream', shouldReupload })))
272
+ fileMetadata.push({
264
273
  rootHash: nmmr.getRoot(),
265
- mimeType: file.type || 'application/octet-stream',
266
- service: 'i'
274
+ filename,
275
+ mimeType: file.type || 'application/octet-stream'
276
+ })
277
+
278
+ if (faviconFile && file === faviconFile) {
279
+ iconMetadata = {
280
+ rootHash: nmmr.getRoot(),
281
+ mimeType: file.type || 'application/octet-stream'
282
+ }
267
283
  }
268
- }
269
284
 
270
- _steps++
271
- emit({ type: 'file-uploaded', filename, service: 'irfs' })
285
+ _steps++
286
+ emit({ type: 'file-uploaded', filename, service: 'irfs' })
287
+ }
272
288
  }
273
289
  }
274
290
 
275
- log(`Uploading stall event for ${dTag}`)
276
- ;({ pause } = (await maybeUploadStall({
291
+ log(`Uploading app listing event for ${dTag}`)
292
+ ;({ pause } = (await maybeUploadAppListing({
277
293
  dTag,
278
294
  channel,
279
- name: stallName,
280
- nameLang: nappJson.stallName?.[0]?.[1],
281
- isNameAuto: !nappJson.stallName?.[0]?.[0],
282
- summary: stallSummary,
283
- summaryLang: nappJson.stallSummary?.[0]?.[1],
284
- isSummaryAuto: !nappJson.stallSummary?.[0]?.[0],
295
+ name: listingName,
296
+ nameLang: nappJson.name?.[0]?.[1],
297
+ isNameAuto: !nappJson.name?.[0]?.[0],
298
+ summary: listingSummary,
299
+ summaryLang: nappJson.summary?.[0]?.[1],
300
+ isSummaryAuto: !nappJson.summary?.[0]?.[0],
285
301
  icon: iconMetadata,
286
- isIconAuto: !nappJson.stallIcon?.[0]?.[0],
302
+ isIconAuto: !nappJson.icon?.[0]?.[0],
303
+ descriptions: nappJson.description,
304
+ keyArt: keyArtMetadata,
305
+ screenshots: screenshotMetadata,
306
+ uploadService,
287
307
  signer: nostrSigner,
288
308
  writeRelays,
289
309
  log,
@@ -295,35 +315,36 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent =
295
315
  hashtags: nappJson.hashtag
296
316
  })))
297
317
  _steps++
298
- emit({ type: 'stall-published' })
318
+ emit({ type: 'listing-published' })
299
319
 
300
- log(`Uploading bundle ${dTag}`)
301
- const bundle = await uploadBundle({ dTag, channel, fileMetadata, signer: nostrSigner, pause, shouldReupload, log })
320
+ log(`Uploading site manifest ${dTag}`)
321
+ const manifest = await uploadSiteManifest({ dTag, channel, fileMetadata, uploadService, signer: nostrSigner, pause, shouldReupload, log })
302
322
 
303
323
  const appEntity = appEncode({
304
- dTag: bundle.tags.find(v => v[0] === 'd')[1],
305
- pubkey: bundle.pubkey,
324
+ dTag: manifest.tags.find(v => v[0] === 'd')[1],
325
+ pubkey: manifest.pubkey,
306
326
  relays: [],
307
- kind: bundle.kind
327
+ kind: manifest.kind
308
328
  })
309
329
  _steps++
310
- emit({ type: 'bundle-published' })
330
+ emit({ type: 'manifest-published' })
311
331
 
312
332
  log(`Visit at https://44billion.net/${appEntity}`)
313
333
  emit({ type: 'complete', napp: appEntity })
314
334
  }
315
335
 
316
- async function uploadBundle ({ dTag, channel, fileMetadata, signer, pause = 0, shouldReupload = false, log = () => {} }) {
336
+ async function uploadSiteManifest ({ dTag, channel, fileMetadata, uploadService, signer, pause = 0, shouldReupload = false, log = () => {} }) {
317
337
  const kind = {
318
- main: 37448, // stable
319
- next: 37449, // insider
320
- draft: 37450 // vibe coded preview
321
- }[channel] ?? 37448
338
+ main: 35128, // stable
339
+ next: 35129, // insider
340
+ draft: 35130 // vibe coded preview
341
+ }[channel] ?? 35128
322
342
 
323
- const fileTags = fileMetadata.map(v => ['file', v.rootHash, v.filename, v.mimeType, v.service || 'i'])
343
+ const pathTags = fileMetadata.map(v => ['path', v.rootHash, v.filename, v.mimeType])
324
344
  const tags = [
325
345
  ['d', dTag],
326
- ...fileTags
346
+ ...pathTags,
347
+ ['service', uploadService]
327
348
  ]
328
349
 
329
350
  const writeRelays = [...new Set([...(await signer.getRelays()).write, ...nappRelays].map(r => r.trim().replace(/\/$/, '')))]
@@ -347,21 +368,24 @@ async function uploadBundle ({ dTag, channel, fileMetadata, signer, pause = 0, s
347
368
  }
348
369
 
349
370
  if (!shouldReupload && mostRecentEvent) {
350
- const recentFileTags = mostRecentEvent.tags
351
- .filter(t => t[0] === 'file' && t[2] !== '.well-known/napp.json')
371
+ const recentPathTags = mostRecentEvent.tags
372
+ .filter(t => t[0] === 'path' && t[2] !== '.well-known/napp.json')
352
373
  .sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
353
374
 
354
- const currentFileTags = fileTags
375
+ const currentPathTags = pathTags
355
376
  .filter(t => t[2] !== '.well-known/napp.json')
356
377
  .sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
357
378
 
358
- const isSame = currentFileTags.length === recentFileTags.length && currentFileTags.every((t, i) => {
359
- const rt = recentFileTags[i]
360
- return rt.length >= 4 && rt[1] === t[1] && rt[2] === t[2] && rt[3] === t[3] && (rt[4] || 'i') === (t[4] || 'i')
379
+ const recentServiceTag = mostRecentEvent.tags.find(t => t[0] === 'service')
380
+ const serviceChanged = recentServiceTag?.[1] !== uploadService
381
+
382
+ const isSame = !serviceChanged && currentPathTags.length === recentPathTags.length && currentPathTags.every((t, i) => {
383
+ const rt = recentPathTags[i]
384
+ return rt.length >= 4 && rt[1] === t[1] && rt[2] === t[2] && rt[3] === t[3]
361
385
  })
362
386
 
363
387
  if (isSame) {
364
- log(`Bundle based on ${fileTags.length} files is up-to-date (id: ${mostRecentEvent.id} - created_at: ${new Date(mostRecentEvent.created_at * 1000).toISOString()})`)
388
+ log(`Site manifest based on ${pathTags.length} files is up-to-date (id: ${mostRecentEvent.id} - created_at: ${new Date(mostRecentEvent.created_at * 1000).toISOString()})`)
365
389
 
366
390
  const matchingEvents = events.filter(e => e.id === mostRecentEvent.id)
367
391
  const coveredRelays = new Set(matchingEvents.map(e => e.meta?.relay).filter(Boolean))
@@ -369,9 +393,7 @@ async function uploadBundle ({ dTag, channel, fileMetadata, signer, pause = 0, s
369
393
 
370
394
  if (missingRelays.length === 0) return mostRecentEvent
371
395
 
372
- // nostrRelays.getEvents currently doesn't tell us which event came from which relay,
373
- // so we re-upload to all relays to ensure consistency
374
- log(`Re-uploading existing bundle event to ${missingRelays.length} missing relays (out of ${writeRelays.length})`)
396
+ log(`Re-uploading existing site manifest event to ${missingRelays.length} missing relays (out of ${writeRelays.length})`)
375
397
  await throttledSendEvent(mostRecentEvent, missingRelays, { pause, trailingPause: true, log, minSuccessfulRelays: 0 })
376
398
  return mostRecentEvent
377
399
  }
@@ -382,18 +404,18 @@ async function uploadBundle ({ dTag, channel, fileMetadata, signer, pause = 0, s
382
404
  const maxCreatedAt = createdAt + 172800 // 2 days ahead
383
405
  if (effectiveCreatedAt > maxCreatedAt) effectiveCreatedAt = maxCreatedAt
384
406
 
385
- const appBundle = {
407
+ const siteManifest = {
386
408
  kind,
387
409
  tags,
388
410
  content: '',
389
411
  created_at: effectiveCreatedAt
390
412
  }
391
- const event = await signer.signEvent(appBundle)
413
+ const event = await signer.signEvent(siteManifest)
392
414
  await throttledSendEvent(event, writeRelays, { pause, trailingPause: true, log })
393
415
  return event
394
416
  }
395
417
 
396
- async function maybeUploadStall ({
418
+ async function maybeUploadAppListing ({
397
419
  dTag,
398
420
  channel,
399
421
  name,
@@ -404,6 +426,10 @@ async function maybeUploadStall ({
404
426
  isSummaryAuto,
405
427
  icon,
406
428
  isIconAuto,
429
+ descriptions,
430
+ keyArt,
431
+ screenshots,
432
+ uploadService,
407
433
  signer,
408
434
  writeRelays,
409
435
  log,
@@ -418,20 +444,20 @@ async function maybeUploadStall ({
418
444
  const trimmedSummary = typeof summary === 'string' ? summary.trim() : ''
419
445
  const iconRootHash = icon?.rootHash
420
446
  const iconMimeType = icon?.mimeType
421
- const iconService = icon?.service || 'i'
422
447
  const hasMetadata = Boolean(trimmedName) || Boolean(trimmedSummary) || Boolean(iconRootHash) ||
423
- Boolean(self) || (countries && countries.length > 0) || (categories && categories.length > 0) || (hashtags && hashtags.length > 0)
448
+ Boolean(self) || (countries && countries.length > 0) || (categories && categories.length > 0) || (hashtags && hashtags.length > 0) ||
449
+ (descriptions && descriptions.length > 0) || (keyArt && keyArt.length > 0) || (screenshots && screenshots.length > 0)
424
450
 
425
451
  const relays = [...new Set([...writeRelays, ...nappRelays].map(r => r.trim().replace(/\/$/, '')))]
426
452
 
427
- const previousResult = await getPreviousStall(dTag, relays, signer, channel)
453
+ const previousResult = await getPreviousAppListing(dTag, relays, signer, channel)
428
454
  const previous = previousResult?.previous
429
455
  if (!previous && !hasMetadata) {
430
- if (shouldReupload) log('Skipping stall event upload: No previous event found and no metadata provided.')
456
+ if (shouldReupload) log('Skipping app listing event upload: No previous event found and no metadata provided.')
431
457
  return { pause }
432
458
  }
433
459
 
434
- const publishStall = async (event) => {
460
+ const publishListing = async (event) => {
435
461
  const signedEvent = await signer.signEvent(event)
436
462
  return await throttledSendEvent(signedEvent, relays, { pause, log, trailingPause: true })
437
463
  }
@@ -443,6 +469,39 @@ async function maybeUploadStall ({
443
469
  draft: 37350
444
470
  }[channel] ?? 37348
445
471
 
472
+ // Helper to push media-related tags (icon, key_art, screenshot, service)
473
+ const pushMediaTags = (tags) => {
474
+ let hasMedia = false
475
+
476
+ if (iconRootHash && iconMimeType) {
477
+ tags.push(['icon', iconRootHash, iconMimeType])
478
+ if (isIconAuto) tags.push(['auto', 'icon'])
479
+ hasMedia = true
480
+ }
481
+
482
+ if (keyArt && keyArt.length > 0) {
483
+ for (const ka of keyArt) {
484
+ const row = ['key_art', ka.rootHash, ka.mimeType]
485
+ if (ka.country) row.push(ka.country)
486
+ tags.push(row)
487
+ }
488
+ hasMedia = true
489
+ }
490
+
491
+ if (screenshots && screenshots.length > 0) {
492
+ for (const ss of screenshots) {
493
+ const row = ['screenshot', ss.rootHash, ss.mimeType]
494
+ if (ss.country) row.push(ss.country)
495
+ tags.push(row)
496
+ }
497
+ hasMedia = true
498
+ }
499
+
500
+ if (hasMedia) tags.push(['service', uploadService])
501
+
502
+ return { hasIcon: Boolean(iconRootHash && iconMimeType) }
503
+ }
504
+
446
505
  if (!previous) {
447
506
  const tags = [
448
507
  ['d', dTag]
@@ -481,14 +540,9 @@ async function maybeUploadStall ({
481
540
  })
482
541
  }
483
542
 
484
- let hasIcon = false
485
- let hasName = false
486
- if (iconRootHash && iconMimeType) {
487
- hasIcon = true
488
- tags.push(['icon', iconRootHash, iconMimeType, iconService])
489
- if (isIconAuto) tags.push(['auto', 'icon'])
490
- }
543
+ const { hasIcon } = pushMediaTags(tags)
491
544
 
545
+ let hasName = false
492
546
  if (trimmedName) {
493
547
  hasName = true
494
548
  const row = ['name', trimmedName]
@@ -504,12 +558,22 @@ async function maybeUploadStall ({
504
558
  if (isSummaryAuto) tags.push(['auto', 'summary'])
505
559
  }
506
560
 
561
+ if (descriptions) {
562
+ for (const [text, lang] of descriptions) {
563
+ if (text) {
564
+ const row = ['description', text]
565
+ if (lang) row.push(lang)
566
+ tags.push(row)
567
+ }
568
+ }
569
+ }
570
+
507
571
  if (!hasIcon || !hasName) {
508
- log(`Skipping stall event creation: Missing required metadata.${!hasName ? ' Name is missing.' : ''}${!hasIcon ? ' Icon is missing.' : ''}`)
572
+ log(`Skipping app listing event creation: Missing required metadata.${!hasName ? ' Name is missing.' : ''}${!hasIcon ? ' Icon is missing.' : ''}`)
509
573
  return { pause }
510
574
  }
511
575
 
512
- return await publishStall({
576
+ return await publishListing({
513
577
  kind,
514
578
  tags,
515
579
  content: '',
@@ -589,6 +653,41 @@ async function maybeUploadStall ({
589
653
  changed = true
590
654
  }
591
655
 
656
+ // Update descriptions
657
+ if (descriptions) {
658
+ removeTags('description')
659
+ for (const [text, lang] of descriptions) {
660
+ if (text) {
661
+ const row = ['description', text]
662
+ if (lang) row.push(lang)
663
+ tags.push(row)
664
+ }
665
+ }
666
+ changed = true
667
+ }
668
+
669
+ // Update key art
670
+ if (keyArt && keyArt.length > 0) {
671
+ removeTags('key_art')
672
+ for (const ka of keyArt) {
673
+ const row = ['key_art', ka.rootHash, ka.mimeType]
674
+ if (ka.country) row.push(ka.country)
675
+ tags.push(row)
676
+ }
677
+ changed = true
678
+ }
679
+
680
+ // Update screenshots
681
+ if (screenshots && screenshots.length > 0) {
682
+ removeTags('screenshot')
683
+ for (const ss of screenshots) {
684
+ const row = ['screenshot', ss.rootHash, ss.mimeType]
685
+ if (ss.country) row.push(ss.country)
686
+ tags.push(row)
687
+ }
688
+ changed = true
689
+ }
690
+
592
691
  const ensureTagValue = (key, updater) => {
593
692
  const index = tags.findIndex(tag => Array.isArray(tag) && tag[0] === key)
594
693
  if (index === -1) {
@@ -655,12 +754,18 @@ async function maybeUploadStall ({
655
754
  if (iconRootHash && iconMimeType) {
656
755
  if (!isIconAuto || hasAuto('icon')) {
657
756
  ensureTagValue('icon', (_) => {
658
- return ['icon', iconRootHash, iconMimeType, iconService]
757
+ return ['icon', iconRootHash, iconMimeType]
659
758
  })
660
759
  if (!isIconAuto) removeAuto('icon')
661
760
  }
662
761
  }
663
762
 
763
+ // Update service tag if any media exists
764
+ const hasMedia = Boolean(iconRootHash) || (keyArt && keyArt.length > 0) || (screenshots && screenshots.length > 0)
765
+ if (hasMedia) {
766
+ ensureTagValue('service', () => ['service', uploadService])
767
+ }
768
+
664
769
  if (!changed && !shouldReupload) {
665
770
  const { storedEvents } = previousResult
666
771
 
@@ -670,7 +775,7 @@ async function maybeUploadStall ({
670
775
 
671
776
  if (missingRelays.length === 0) return { pause }
672
777
 
673
- log(`Re-uploading existing stall event to ${missingRelays.length} missing relays (out of ${relays.length})`)
778
+ log(`Re-uploading existing app listing event to ${missingRelays.length} missing relays (out of ${relays.length})`)
674
779
  return await throttledSendEvent(previous, missingRelays, { pause, log, trailingPause: true, minSuccessfulRelays: 0 })
675
780
  }
676
781
 
@@ -678,7 +783,7 @@ async function maybeUploadStall ({
678
783
  const maxCreatedAt = createdAt + 172800 // 2 days ahead
679
784
  if (effectiveCreatedAt > maxCreatedAt) effectiveCreatedAt = maxCreatedAt
680
785
 
681
- return await publishStall({
786
+ return await publishListing({
682
787
  kind,
683
788
  tags,
684
789
  content: typeof previous.content === 'string' ? previous.content : '',
@@ -686,7 +791,7 @@ async function maybeUploadStall ({
686
791
  })
687
792
  }
688
793
 
689
- async function getPreviousStall (dTagValue, relays, signer, channel) {
794
+ async function getPreviousAppListing (dTagValue, relays, signer, channel) {
690
795
  const kind = {
691
796
  main: 37348,
692
797
  next: 37349,
@@ -2,18 +2,17 @@ import { Relay } from 'nostr-tools/relay'
2
2
  import { maybeUnref } from '#helpers/timer.js'
3
3
 
4
4
  export const seedRelays = [
5
+ 'wss://relay.44billion.net',
5
6
  'wss://purplepag.es',
6
7
  'wss://user.kindpag.es',
7
8
  'wss://relay.nos.social',
8
- 'wss://relay.nostr.band',
9
9
  'wss://nostr.land',
10
10
  'wss://indexer.coracle.social'
11
11
  ]
12
12
  export const freeRelays = [
13
13
  'wss://relay.primal.net',
14
14
  'wss://nos.lol',
15
- 'wss://relay.damus.io',
16
- 'wss://relay.nostr.band'
15
+ 'wss://relay.damus.io'
17
16
  ]
18
17
  export const nappRelays = [
19
18
  'wss://relay.44billion.net'