nappup 2.3.3 → 2.3.5

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/README.md CHANGED
@@ -138,3 +138,20 @@ await publishApp(fileList, signer, {
138
138
  - **`fileList`** — a `FileList` or array of `File` objects (each needs `webkitRelativePath`).
139
139
  - **`signer`** — a [NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md)-compatible signer. In the browser, `window.nostr` is used automatically if omitted.
140
140
  - **`onEvent`** — optional callback that receives progress events with a `type` (`'init'`, `'file-uploaded'`, `'complete'`, `'error'`, …) and `progress` (0–100).
141
+
142
+ Rejected uploads use `NappupError`, with a stable code from
143
+ `NAPPUP_ERROR_CODES`. The original error is retained as `cause`, and some
144
+ errors include structured `details`, so applications can show their own
145
+ recovery instructions without matching CLI-oriented message text:
146
+
147
+ ```js
148
+ import publishApp, { NAPPUP_ERROR_CODES } from 'nappup'
149
+
150
+ try {
151
+ await publishApp(fileList, signer)
152
+ } catch (error) {
153
+ if (error.code === NAPPUP_ERROR_CODES.GENERIC_FOLDER_NAME) {
154
+ // Ask the user to choose a unique app folder name.
155
+ }
156
+ }
157
+ ```
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.3",
9
+ "version": "2.3.5",
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.2",
25
+ "libp2r2p": "^0.10.5",
26
26
  "mime-types": "^3.0.1",
27
27
  "nmmr": "^2.0.0"
28
28
  },
package/src/errors.js ADDED
@@ -0,0 +1,78 @@
1
+ const ERROR_CODE = /^[A-Z][A-Z0-9_]*$/
2
+
3
+ export const NAPPUP_ERROR_CODES = Object.freeze({
4
+ UPLOAD_CANCELLED: 'NAPPUP_UPLOAD_CANCELLED',
5
+ NO_SIGNER: 'NAPPUP_NO_SIGNER',
6
+ EMPTY_FILE_LIST: 'NAPPUP_EMPTY_FILE_LIST',
7
+ RELAY_LOOKUP_FAILED: 'NAPPUP_RELAY_LOOKUP_FAILED',
8
+ NO_OUTBOX_RELAYS: 'NAPPUP_NO_OUTBOX_RELAYS',
9
+ INVALID_D_TAG: 'NAPPUP_INVALID_D_TAG',
10
+ GENERIC_FOLDER_NAME: 'NAPPUP_GENERIC_FOLDER_NAME',
11
+ INVALID_FOLDER_NAME: 'NAPPUP_INVALID_FOLDER_NAME',
12
+ BLOSSOM_UPLOAD_FAILED: 'NAPPUP_BLOSSOM_UPLOAD_FAILED',
13
+ IRFS_UPLOAD_FAILED: 'NAPPUP_IRFS_UPLOAD_FAILED',
14
+ MANIFEST_UPLOAD_FAILED: 'NAPPUP_MANIFEST_UPLOAD_FAILED',
15
+ UPLOAD_FAILED: 'NAPPUP_UPLOAD_FAILED'
16
+ })
17
+
18
+ // Carries a stable machine-readable code while retaining technical context.
19
+ export class NappupError extends Error {
20
+ constructor (code, messageOrOptions = code, causeOrOptions) {
21
+ if (typeof code !== 'string' || !ERROR_CODE.test(code)) {
22
+ throw new TypeError('Nappup error code should be uppercase snake case')
23
+ }
24
+ const objectOptions = messageOrOptions && typeof messageOrOptions === 'object'
25
+ ? messageOrOptions
26
+ : null
27
+ const trailingOptions = !objectOptions && causeOrOptions && typeof causeOrOptions === 'object' &&
28
+ (Object.hasOwn(causeOrOptions, 'cause') || Object.hasOwn(causeOrOptions, 'details'))
29
+ ? causeOrOptions
30
+ : null
31
+ const options = objectOptions || trailingOptions
32
+ const message = objectOptions
33
+ ? (objectOptions.message ?? code)
34
+ : (messageOrOptions ?? code)
35
+ const cause = objectOptions
36
+ ? objectOptions.cause
37
+ : trailingOptions
38
+ ? trailingOptions.cause
39
+ : causeOrOptions
40
+ super(message, cause === undefined ? undefined : { cause })
41
+ Object.defineProperty(this, 'name', {
42
+ configurable: true,
43
+ value: 'NappupError',
44
+ writable: true
45
+ })
46
+ Object.defineProperty(this, 'code', {
47
+ configurable: false,
48
+ enumerable: true,
49
+ value: code,
50
+ writable: false
51
+ })
52
+ if (options?.details !== undefined) {
53
+ Object.defineProperty(this, 'details', {
54
+ configurable: false,
55
+ enumerable: true,
56
+ value: options.details,
57
+ writable: false
58
+ })
59
+ }
60
+ }
61
+ }
62
+
63
+ // Ensures every error crossing nappup's public API has a documented code.
64
+ export function normalizeNappupError (error) {
65
+ if (typeof error?.code === 'string' && error.code.startsWith('NAPPUP_')) return error
66
+ if (error?.name === 'AbortError' || error?.code === 'ABORT_ERR') {
67
+ return new NappupError(
68
+ NAPPUP_ERROR_CODES.UPLOAD_CANCELLED,
69
+ error?.message || 'Upload cancelled',
70
+ { cause: error }
71
+ )
72
+ }
73
+ return new NappupError(
74
+ NAPPUP_ERROR_CODES.UPLOAD_FAILED,
75
+ error?.message || 'Upload failed',
76
+ { cause: error }
77
+ )
78
+ }
@@ -1,4 +1,5 @@
1
1
  const IMAGE_EXTENSIONS = /\.(?:ico|svg|webp|png|jpe?g|gif|avif)(?:[?#].*)?$/i
2
+ const CONVENTIONAL_ICON_BASENAME = /^(?:favicon(?:[-_.]\w+)*|apple-touch-icon(?:-precomposed|[-_.]\w+)*)\.(?:ico|svg|webp|png|jpe?g|gif|avif)$/i
2
3
 
3
4
  // Decodes the character references commonly used in metadata attributes.
4
5
  function decodeHtml (value) {
@@ -55,11 +56,11 @@ function scanTags (htmlContent, names) {
55
56
  }
56
57
 
57
58
  // Adds a unique icon source with a stable priority and document order.
58
- function addIconSource (sources, seen, href, kind, priority, index) {
59
+ function addIconSource (sources, seen, href, kind, priority, index, { sizes, type } = {}) {
59
60
  const value = typeof href === 'string' ? href.trim() : ''
60
61
  if (!value || seen.has(value)) return
61
62
  seen.add(value)
62
- sources.push({ href: value, kind, priority, index })
63
+ sources.push({ href: value, kind, priority, index, sizes, type })
63
64
  }
64
65
 
65
66
  function metadataMarkup (htmlContent) {
@@ -88,10 +89,11 @@ export function extractHtmlMetadata (htmlContent) {
88
89
  }
89
90
  if (tagName === 'link') {
90
91
  const rels = new Set((attributes.rel || '').toLowerCase().split(/\s+/).filter(Boolean))
91
- if (rels.has('icon')) addIconSource(iconSources, seenIcons, attributes.href, 'icon', 10, index)
92
- else if (rels.has('apple-touch-icon')) addIconSource(iconSources, seenIcons, attributes.href, 'apple-touch-icon', 20, index)
93
- else if (rels.has('apple-touch-icon-precomposed')) addIconSource(iconSources, seenIcons, attributes.href, 'apple-touch-icon', 21, index)
94
- else if (rels.has('mask-icon') || rels.has('fluid-icon')) addIconSource(iconSources, seenIcons, attributes.href, 'mask-icon', 30, index)
92
+ const iconAttributes = { sizes: attributes.sizes, type: attributes.type }
93
+ if (rels.has('icon')) addIconSource(iconSources, seenIcons, attributes.href, 'icon', 10, index, iconAttributes)
94
+ else if (rels.has('apple-touch-icon')) addIconSource(iconSources, seenIcons, attributes.href, 'apple-touch-icon', 20, index, iconAttributes)
95
+ else if (rels.has('apple-touch-icon-precomposed')) addIconSource(iconSources, seenIcons, attributes.href, 'apple-touch-icon', 21, index, iconAttributes)
96
+ else if (rels.has('mask-icon') || rels.has('fluid-icon')) addIconSource(iconSources, seenIcons, attributes.href, 'mask-icon', 30, index, iconAttributes)
95
97
  else if (rels.has('manifest')) addIconSource(iconSources, seenIcons, attributes.href, 'manifest', 40, index)
96
98
  else if (rels.has('image_src')) addIconSource(iconSources, seenIcons, attributes.href, 'social-image', 70, index)
97
99
  continue
@@ -127,7 +129,12 @@ export function extractHtmlMetadata (htmlContent) {
127
129
  name,
128
130
  description,
129
131
  baseHref,
130
- iconSources: iconSources.map(({ href, kind }) => ({ href, kind }))
132
+ iconSources: iconSources.map(({ href, kind, sizes, type }) => ({
133
+ href,
134
+ kind,
135
+ ...(sizes ? { sizes } : {}),
136
+ ...(type ? { type } : {})
137
+ }))
131
138
  }
132
139
  }
133
140
 
@@ -139,6 +146,8 @@ export function extractWebManifestIcons (manifest) {
139
146
  .map((icon, index) => ({
140
147
  href: typeof icon?.src === 'string' ? icon.src.trim() : '',
141
148
  kind: 'web-app-manifest',
149
+ sizes: typeof icon?.sizes === 'string' ? icon.sizes : undefined,
150
+ type: typeof icon?.type === 'string' ? icon.type : undefined,
142
151
  purpose: typeof icon?.purpose === 'string' ? icon.purpose.toLowerCase().split(/\s+/) : ['any'],
143
152
  index
144
153
  }))
@@ -147,7 +156,12 @@ export function extractWebManifestIcons (manifest) {
147
156
  const rank = icon => icon.purpose.includes('any') ? 0 : icon.purpose.includes('maskable') ? 1 : 2
148
157
  return rank(left) - rank(right) || left.index - right.index
149
158
  })
150
- .map(({ href, kind }) => ({ href, kind }))
159
+ .map(({ href, kind, sizes, type }) => ({
160
+ href,
161
+ kind,
162
+ ...(sizes ? { sizes } : {}),
163
+ ...(type ? { type } : {})
164
+ }))
151
165
  } catch (_) {
152
166
  return []
153
167
  }
@@ -174,10 +188,26 @@ function filePath (file) {
174
188
 
175
189
  // Finds a conventional favicon file in a bundle.
176
190
  export function findFavicon (fileList) {
177
- return fileList.find(file => {
191
+ const candidates = fileList.filter(file => {
178
192
  const filename = filePath(file).split('/').pop().toLowerCase()
179
- return filename.startsWith('favicon.') && IMAGE_EXTENSIONS.test(filename)
180
- }) || null
193
+ return CONVENTIONAL_ICON_BASENAME.test(filename) && IMAGE_EXTENSIONS.test(filename)
194
+ })
195
+ return candidates.sort((left, right) => iconQuality({}, filePath(right)) - iconQuality({}, filePath(left)))[0] || null
196
+ }
197
+
198
+ function iconQuality (source, path) {
199
+ const sizes = typeof source?.sizes === 'string' ? source.sizes.toLowerCase() : ''
200
+ const dimensions = [...sizes.matchAll(/(\d{1,5})x(\d{1,5})/g)]
201
+ .map(match => Math.min(Number(match[1]), Number(match[2])))
202
+ if (dimensions.length) return Math.max(...dimensions)
203
+ if (/\bany\b/.test(sizes) || /\.svg(?:[?#]|$)/i.test(path) || source?.type === 'image/svg+xml') return 512
204
+ const filename = path.split('/').pop()
205
+ const inferred = [...filename.matchAll(/(?:^|[-_.])(\d{2,5})x(\d{2,5})(?=[-_.]|$)/gi)]
206
+ .map(match => Math.min(Number(match[1]), Number(match[2])))
207
+ if (inferred.length) return Math.max(...inferred)
208
+ if (/^apple-touch-icon/i.test(filename) || source?.kind === 'apple-touch-icon') return 180
209
+ if (/\.ico$/i.test(filename)) return 32
210
+ return 1
181
211
  }
182
212
 
183
213
  // Finds the best local icon referenced by HTML or its Web App Manifest.
@@ -186,21 +216,32 @@ export async function findAppIcon (fileList, htmlContent, indexFile, readFileTex
186
216
  const indexPath = filePath(indexFile) || 'index.html'
187
217
  const byPath = new Map(fileList.map(file => [filePath(file), file]))
188
218
  const findFromSources = async sources => {
219
+ const candidates = []
220
+ let order = 0
189
221
  for (const source of sources) {
190
222
  const path = resolveAppPath(source.href, indexPath, metadata.baseHref)
191
223
  const file = path && byPath.get(path)
192
224
  if (!file) continue
193
- if (source.kind !== 'manifest') return file
225
+ if (source.kind !== 'manifest') {
226
+ candidates.push({ file, quality: iconQuality(source, path), order: order++ })
227
+ continue
228
+ }
194
229
 
195
230
  try {
196
231
  const manifestText = readFileText ? await readFileText(file) : await file.text()
197
232
  for (const icon of extractWebManifestIcons(manifestText)) {
198
233
  const iconPath = resolveAppPath(icon.href, path)
199
- if (iconPath && byPath.has(iconPath)) return byPath.get(iconPath)
234
+ if (iconPath && byPath.has(iconPath)) {
235
+ candidates.push({
236
+ file: byPath.get(iconPath),
237
+ quality: iconQuality(icon, iconPath),
238
+ order: order++
239
+ })
240
+ }
200
241
  }
201
242
  } catch (_) {}
202
243
  }
203
- return null
244
+ return candidates.sort((left, right) => right.quality - left.quality || left.order - right.order)[0]?.file || null
204
245
  }
205
246
 
206
247
  const specificSources = metadata.iconSources.filter(source => !['tile-image', 'social-image'].includes(source.kind))
@@ -5,5 +5,5 @@ export const GENERIC_BUILD_FOLDER_NAMES = new Set([
5
5
  ])
6
6
 
7
7
  export function isNostrAppDTagSafe (string) {
8
- return typeof string === 'string' && string.length <= NOSTR_APP_D_TAG_MAX_LENGTH
8
+ return typeof string === 'string' && string.length > 0 && string.length <= NOSTR_APP_D_TAG_MAX_LENGTH
9
9
  }
package/src/index.js CHANGED
@@ -8,6 +8,9 @@ import { extractHtmlMetadata, findAppIcon, findIndexFile } from '#helpers/app-me
8
8
  import { getBlossomServers, healthCheckServers, uploadFilesToBlossom } from '#services/blossom-upload.js'
9
9
  import { uploadBinaryDataChunks } from '#services/irfs-upload.js'
10
10
  import { uploadSiteManifest } from '#services/site-manifest.js'
11
+ import { NappupError, NAPPUP_ERROR_CODES, normalizeNappupError } from '#errors.js'
12
+
13
+ export { NappupError, NAPPUP_ERROR_CODES } from '#errors.js'
11
14
 
12
15
  // TL;DR
13
16
  // import publishApp from 'nappup'
@@ -26,21 +29,8 @@ import { uploadSiteManifest } from '#services/site-manifest.js'
26
29
  // }
27
30
  //
28
31
  export default async function (fileList, nostrSigner, opts = {}) {
29
- const onEvent = typeof opts.onEvent === 'function' ? opts.onEvent : null
30
- let lastProgress = 0
31
32
  try {
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
33
+ return await toApp(fileList, nostrSigner, opts)
44
34
  } finally {
45
35
  await nostrRelays.disconnectAll()
46
36
  }
@@ -59,8 +49,34 @@ export default async function (fileList, nostrSigner, opts = {}) {
59
49
  * 'manifest-published' — unified site manifest and app metadata published
60
50
  * 'complete' — { napp } (terminal, progress === 100)
61
51
  * 'error' — { error } (terminal, error is rethrown)
52
+ *
53
+ * Every rejected error has a stable `NAPPUP_*` code and may retain its cause.
62
54
  */
63
- export async function toApp (fileList, nostrSigner, {
55
+ export async function toApp (fileList, nostrSigner, opts = {}) {
56
+ const onEvent = typeof opts.onEvent === 'function' ? opts.onEvent : null
57
+ let lastProgress = 0
58
+ const publishOptions = onEvent
59
+ ? {
60
+ ...opts,
61
+ onEvent (event) {
62
+ lastProgress = event.progress ?? lastProgress
63
+ onEvent(event)
64
+ }
65
+ }
66
+ : opts
67
+ try {
68
+ return await publishApp(fileList, nostrSigner, publishOptions)
69
+ } catch (error) {
70
+ const normalized = normalizeNappupError(error)
71
+ if (onEvent) {
72
+ try { onEvent({ type: 'error', error: normalized, progress: lastProgress }) } catch (_) {}
73
+ }
74
+ throw normalized
75
+ }
76
+ }
77
+
78
+ // Implements publishing after the public boundary has installed error normalization.
79
+ async function publishApp (fileList, nostrSigner, {
64
80
  log = () => {}, onEvent = () => {}, dTag, channel = 'main', shouldReupload = false
65
81
  } = {}) {
66
82
  let steps = 0
@@ -74,27 +90,66 @@ export async function toApp (fileList, nostrSigner, {
74
90
  } catch (_) {}
75
91
  }
76
92
  if (!nostrSigner && typeof window !== 'undefined') nostrSigner = window.nostr
77
- if (!nostrSigner) throw new Error('No Nostr signer found')
93
+ if (!nostrSigner) {
94
+ throw new NappupError(NAPPUP_ERROR_CODES.NO_SIGNER, 'No Nostr signer found')
95
+ }
78
96
  if (typeof window !== 'undefined' && nostrSigner === window.nostr) nostrSigner.getRelays = getRelays
79
97
 
80
- const writeRelays = [...new Set((await nostrSigner.getRelays()).write
81
- .map(relay => relay.trim().replace(/\/$/, '')))]
82
- log(`Found ${writeRelays.length} outbox relays:\n${writeRelays.join(', ')}`)
83
- if (!writeRelays.length) throw new Error('No outbox relays found')
98
+ fileList = Array.from(fileList || [])
99
+ if (!fileList.length) {
100
+ throw new NappupError(NAPPUP_ERROR_CODES.EMPTY_FILE_LIST, 'No app files were provided')
101
+ }
84
102
 
85
- if (typeof dTag === 'string') {
86
- if (!isNostrAppDTagSafe(dTag)) throw new Error('dTag must be a non-empty string with at most 260 characters')
103
+ if (dTag !== undefined && dTag !== null) {
104
+ if (!isNostrAppDTagSafe(dTag)) {
105
+ throw new NappupError(
106
+ NAPPUP_ERROR_CODES.INVALID_D_TAG,
107
+ 'dTag must be a non-empty string with at most 260 characters',
108
+ { details: { dTag } }
109
+ )
110
+ }
87
111
  } else {
88
- const folderName = fileList[0].webkitRelativePath.split('/')[0].trim()
112
+ const relativePath = typeof fileList[0]?.webkitRelativePath === 'string'
113
+ ? fileList[0].webkitRelativePath
114
+ : ''
115
+ const folderName = relativePath.split('/')[0].trim()
89
116
  if (GENERIC_BUILD_FOLDER_NAMES.has(folderName.toLowerCase())) {
90
- throw new Error(`Folder name "${folderName}" is a generic build folder. Please provide a d tag with the -d flag.`)
117
+ throw new NappupError(
118
+ NAPPUP_ERROR_CODES.GENERIC_FOLDER_NAME,
119
+ `Folder name "${folderName}" is a generic build folder. Please provide a d tag with the -d flag.`,
120
+ { details: { folderName } }
121
+ )
91
122
  }
92
123
  dTag = folderName
93
124
  if (!isNostrAppDTagSafe(dTag)) {
94
- throw new Error('Could not derive a valid d tag from the folder name. Please provide one with the -d flag.')
125
+ throw new NappupError(
126
+ NAPPUP_ERROR_CODES.INVALID_FOLDER_NAME,
127
+ 'Could not derive a valid d tag from the folder name. Please provide one with the -d flag.',
128
+ { details: { folderName } }
129
+ )
95
130
  }
96
131
  }
97
132
 
133
+ let signerRelays
134
+ try {
135
+ signerRelays = await nostrSigner.getRelays()
136
+ } catch (error) {
137
+ throw new NappupError(
138
+ NAPPUP_ERROR_CODES.RELAY_LOOKUP_FAILED,
139
+ 'Could not read the signer outbox relays',
140
+ { cause: error }
141
+ )
142
+ }
143
+ const writeRelays = [...new Set((Array.isArray(signerRelays?.write) ? signerRelays.write : [])
144
+ .flatMap(relay => typeof relay === 'string' && relay.trim()
145
+ ? [relay.trim().replace(/\/$/, '')]
146
+ : []
147
+ ))]
148
+ log(`Found ${writeRelays.length} outbox relays:\n${writeRelays.join(', ')}`)
149
+ if (!writeRelays.length) {
150
+ throw new NappupError(NAPPUP_ERROR_CODES.NO_OUTBOX_RELAYS, 'No outbox relays found')
151
+ }
152
+
98
153
  const fileMetadata = []
99
154
  const nappJsonFile = fileList.find(file => file.webkitRelativePath.split('/').slice(1).join('/') === '.well-known/napp.json')
100
155
  let nappJson = {}
@@ -124,10 +179,16 @@ export async function toApp (fileList, nostrSigner, {
124
179
 
125
180
  const faviconFile = await findAppIcon(fileList, indexHtml, indexFile, file => streamToText(file.stream()))
126
181
  let iconMetadata
182
+ let isExplicitIcon = false
127
183
  let pause = 1000
128
184
 
129
185
  log('Checking for blossom servers...')
130
- const blossomServerUrls = await getBlossomServers(nostrSigner, writeRelays)
186
+ let blossomServerUrls = []
187
+ try {
188
+ blossomServerUrls = await getBlossomServers(nostrSigner, writeRelays)
189
+ } catch (error) {
190
+ log('Could not read Blossom server preferences; using relay-based upload instead', error)
191
+ }
131
192
  let healthyBlossomServers = []
132
193
  if (blossomServerUrls.length) {
133
194
  log(`Found ${blossomServerUrls.length} blossom servers: ${blossomServerUrls.join(', ')}`)
@@ -182,6 +243,7 @@ export async function toApp (fileList, nostrSigner, {
182
243
  try {
183
244
  log('Uploading icon from napp.json')
184
245
  iconMetadata = await uploadMediaFromDataUrl(nappJson.icon[0][0], 'icon')
246
+ isExplicitIcon = Boolean(iconMetadata)
185
247
  } catch (error) {
186
248
  log('Failed to upload icon from napp.json', error)
187
249
  }
@@ -236,7 +298,18 @@ export async function toApp (fileList, nostrSigner, {
236
298
  shouldReupload,
237
299
  log
238
300
  })
239
- if (failedFiles.length) throw new Error(`${failedFiles.length} file(s) failed to upload to blossom`)
301
+ if (failedFiles.length) {
302
+ throw new NappupError(
303
+ NAPPUP_ERROR_CODES.BLOSSOM_UPLOAD_FAILED,
304
+ `${failedFiles.length} file(s) failed to upload to Blossom`,
305
+ {
306
+ details: {
307
+ failedFileCount: failedFiles.length,
308
+ filenames: failedFiles.map(failed => failed.filename).filter(Boolean)
309
+ }
310
+ }
311
+ )
312
+ }
240
313
 
241
314
  for (const uploaded of uploadedFiles) {
242
315
  const metadata = {
@@ -246,73 +319,93 @@ export async function toApp (fileList, nostrSigner, {
246
319
  size: uploaded.size
247
320
  }
248
321
  fileMetadata.push(metadata)
249
- if (faviconFile && uploaded.file === faviconFile) iconMetadata = { ...metadata }
322
+ if (!iconMetadata && faviconFile && uploaded.file === faviconFile) iconMetadata = { ...metadata }
250
323
  steps++
251
324
  emit({ type: 'file-uploaded', filename: uploaded.filename, service: 'blossom' })
252
325
  }
253
326
  } else {
254
327
  for (const file of fileList) {
255
- const nmmr = new NMMR()
256
- let chunkLength = 0
257
- for await (const chunk of streamToChunks(file.stream(), 51000)) {
258
- chunkLength++
259
- await nmmr.append(chunk)
260
- }
261
- // Empty IRFS blobs deliberately have no chunks and no manifest reference.
262
- if (!chunkLength) {
263
- steps++
264
- emit({ type: 'file-uploaded', filename: file.webkitRelativePath.split('/').slice(1).join('/'), service: 'irfs' })
265
- continue
266
- }
267
-
268
328
  const filename = file.webkitRelativePath.split('/').slice(1).join('/')
269
- log(`Uploading ${chunkLength} file parts of ${filename}`)
270
- ;({ pause } = await uploadBinaryDataChunks({
271
- nmmr, signer: nostrSigner, filename, chunkLength, log, pause, shouldReupload
272
- }))
273
- const metadata = {
274
- rootHash: nmmr.getRoot(),
275
- filename,
276
- mimeType: file.type || 'application/octet-stream',
277
- size: file.size
329
+ try {
330
+ const nmmr = new NMMR()
331
+ let chunkLength = 0
332
+ for await (const chunk of streamToChunks(file.stream(), 51000)) {
333
+ chunkLength++
334
+ await nmmr.append(chunk)
335
+ }
336
+ // Empty IRFS blobs deliberately have no chunks and no manifest reference.
337
+ if (!chunkLength) {
338
+ steps++
339
+ emit({ type: 'file-uploaded', filename, service: 'irfs' })
340
+ continue
341
+ }
342
+
343
+ log(`Uploading ${chunkLength} file parts of ${filename}`)
344
+ ;({ pause } = await uploadBinaryDataChunks({
345
+ nmmr, signer: nostrSigner, filename, chunkLength, log, pause, shouldReupload
346
+ }))
347
+ const metadata = {
348
+ rootHash: nmmr.getRoot(),
349
+ filename,
350
+ mimeType: file.type || 'application/octet-stream',
351
+ size: file.size
352
+ }
353
+ fileMetadata.push(metadata)
354
+ if (!iconMetadata && faviconFile && file === faviconFile) iconMetadata = { ...metadata }
355
+ steps++
356
+ emit({ type: 'file-uploaded', filename, service: 'irfs' })
357
+ } catch (error) {
358
+ throw new NappupError(
359
+ NAPPUP_ERROR_CODES.IRFS_UPLOAD_FAILED,
360
+ `Failed to upload "${filename}" to Nostr relays`,
361
+ { cause: error, details: { filename } }
362
+ )
278
363
  }
279
- fileMetadata.push(metadata)
280
- if (faviconFile && file === faviconFile) iconMetadata = { ...metadata }
281
- steps++
282
- emit({ type: 'file-uploaded', filename, service: 'irfs' })
283
364
  }
284
365
  }
285
366
 
286
367
  log(`Uploading unified site manifest ${dTag}`)
287
- const manifest = await uploadSiteManifest({
288
- dTag,
289
- channel,
290
- fileMetadata,
291
- name: manifestName,
292
- nameLang: nappJson.name?.[0]?.[1],
293
- isNameAuto: !nappJson.name?.[0]?.[0],
294
- summary: manifestSummary,
295
- summaryLang: nappJson.summary?.[0]?.[1],
296
- isSummaryAuto: !nappJson.summary?.[0]?.[0],
297
- icon: iconMetadata,
298
- isIconAuto: !nappJson.icon?.[0]?.[0],
299
- descriptions: nappJson.description,
300
- keyArt: keyArtMetadata,
301
- screenshots: screenshotMetadata,
302
- uploadService,
303
- signer: nostrSigner,
304
- log,
305
- pause,
306
- shouldReupload,
307
- self: nappJson.self?.[0]?.[0],
308
- countries: nappJson.country,
309
- categories: nappJson.category,
310
- hashtags: nappJson.hashtag
311
- })
368
+ let manifest
369
+ try {
370
+ manifest = await uploadSiteManifest({
371
+ dTag,
372
+ channel,
373
+ fileMetadata,
374
+ name: manifestName,
375
+ nameLang: nappJson.name?.[0]?.[1],
376
+ isNameAuto: !nappJson.name?.[0]?.[0],
377
+ summary: manifestSummary,
378
+ summaryLang: nappJson.summary?.[0]?.[1],
379
+ isSummaryAuto: !nappJson.summary?.[0]?.[0],
380
+ icon: iconMetadata,
381
+ isIconAuto: !isExplicitIcon,
382
+ descriptions: nappJson.description,
383
+ keyArt: keyArtMetadata,
384
+ screenshots: screenshotMetadata,
385
+ uploadService,
386
+ sourceRelays: writeRelays,
387
+ blossomServers: healthyBlossomServers,
388
+ signer: nostrSigner,
389
+ log,
390
+ pause,
391
+ shouldReupload,
392
+ self: nappJson.self?.[0]?.[0],
393
+ countries: nappJson.country,
394
+ categories: nappJson.category,
395
+ hashtags: nappJson.hashtag
396
+ })
397
+ } catch (error) {
398
+ throw new NappupError(
399
+ NAPPUP_ERROR_CODES.MANIFEST_UPLOAD_FAILED,
400
+ 'Failed to publish the app manifest to Nostr relays',
401
+ { cause: error }
402
+ )
403
+ }
312
404
 
313
405
  const appEntity = appEncode({
314
406
  dTag: manifest.tags.find(tag => tag[0] === 'd')[1],
315
407
  pubkey: manifest.pubkey,
408
+ // Keep empty array to generate the shorter app entity.
316
409
  relays: [],
317
410
  kind: manifest.kind
318
411
  })
@@ -1,11 +1,7 @@
1
1
  import { sha256 } from '@noble/hashes/sha2.js'
2
2
  import nostrRelays from '#services/nostr-relays.js'
3
3
  import { bytesToBase16 } from '#helpers/base16.js'
4
-
5
- function normalizeServerUrl (url) {
6
- if (!url.startsWith('http')) url = 'https://' + url
7
- return url.replace(/\/$/, '') + '/'
8
- }
4
+ import { normalizeBlossomServerUrl } from 'libp2r2p/url'
9
5
 
10
6
  async function createAuthHeader (signer, modify) {
11
7
  const now = Math.floor(Date.now() / 1000)
@@ -37,10 +33,11 @@ export async function getBlossomServers (signer, writeRelays) {
37
33
  events.sort((a, b) => b.created_at - a.created_at)
38
34
  const best = events[0]
39
35
 
40
- return (best.tags ?? [])
41
- .filter(t => Array.isArray(t) && t[0] === 'server' && /^https?:\/\//.test(t[1]))
42
- .map(t => t[1].trim().replace(/\/$/, ''))
43
- .filter(Boolean)
36
+ return [...new Set((best.tags ?? [])
37
+ .filter(t => Array.isArray(t) && t[0] === 'server')
38
+ .flatMap(tag => {
39
+ try { return [normalizeBlossomServerUrl(tag[1])] } catch (_) { return [] }
40
+ }))]
44
41
  }
45
42
 
46
43
  /**
@@ -51,8 +48,9 @@ export async function getBlossomServers (signer, writeRelays) {
51
48
  export async function healthCheckServers (servers, signer, { log = () => {} } = {}) {
52
49
  const results = await Promise.allSettled(
53
50
  servers.map(async (serverUrl) => {
54
- await fetch(normalizeServerUrl(serverUrl), { method: 'HEAD' })
55
- return serverUrl
51
+ const normalized = normalizeBlossomServerUrl(serverUrl)
52
+ await fetch(normalized, { method: 'HEAD' })
53
+ return normalized
56
54
  })
57
55
  )
58
56
 
@@ -88,7 +86,7 @@ export async function computeFileHash (file) {
88
86
  async function uploadFileToServer (serverUrl, signer, file, fileHash, mimeType, { shouldReupload, log, maxRetries = 5 }) {
89
87
  // Check if already uploaded
90
88
  if (!shouldReupload) {
91
- const checkResponse = await fetch(serverUrl + fileHash, { method: 'HEAD' })
89
+ const checkResponse = await fetch(`${serverUrl}/${fileHash}`, { method: 'HEAD' })
92
90
  if (checkResponse.ok) {
93
91
  return { success: true, alreadyExists: true }
94
92
  }
@@ -106,7 +104,7 @@ async function uploadFileToServer (serverUrl, signer, file, fileHash, mimeType,
106
104
  evt.tags.push(['t', 'upload'])
107
105
  evt.tags.push(['x', fileHash])
108
106
  })
109
- const response = await fetch(serverUrl + 'upload', {
107
+ const response = await fetch(`${serverUrl}/upload`, {
110
108
  method: 'PUT',
111
109
  headers: { 'Content-Type': mimeType, Authorization: authorization },
112
110
  body: file.stream(),
@@ -145,7 +143,12 @@ export async function uploadFilesToBlossom ({
145
143
  maxRetries = 5,
146
144
  log = () => {}
147
145
  }) {
148
- if (servers.length === 0) return { uploadedFiles: [], failedFiles: [...fileList.map(f => ({ file: f }))] }
146
+ const normalizedServers = [...new Set(servers.flatMap(server => {
147
+ try { return [normalizeBlossomServerUrl(server)] } catch (_) { return [] }
148
+ }))]
149
+ if (normalizedServers.length === 0) {
150
+ return { uploadedFiles: [], failedFiles: [...fileList.map(f => ({ file: f }))] }
151
+ }
149
152
 
150
153
  // Pre-compute file info
151
154
  const fileInfos = await Promise.all(
@@ -161,24 +164,22 @@ export async function uploadFilesToBlossom ({
161
164
  const fileServerResults = fileInfos.map(() => ({ successCount: 0, errors: [] }))
162
165
 
163
166
  // Upload to each server in parallel, but within a server, upload files sequentially
164
- const serverTasks = servers.map(async (server) => {
165
- const serverUrl = normalizeServerUrl(server)
166
-
167
+ const serverTasks = normalizedServers.map(async (serverUrl) => {
167
168
  for (let i = 0; i < fileInfos.length; i++) {
168
169
  const info = fileInfos[i]
169
- log(`Uploading ${info.filename} to ${server}`)
170
+ log(`Uploading ${info.filename} to ${serverUrl}`)
170
171
  const result = await uploadFileToServer(serverUrl, signer, info.file, info.sha256, info.mimeType, { shouldReupload, log, maxRetries })
171
172
 
172
173
  if (result.success) {
173
174
  fileServerResults[i].successCount++
174
175
  if (result.alreadyExists) {
175
- log(`${info.filename}: Already exists on ${server}`)
176
+ log(`${info.filename}: Already exists on ${serverUrl}`)
176
177
  } else {
177
- log(`${info.filename}: Uploaded to ${server}`)
178
+ log(`${info.filename}: Uploaded to ${serverUrl}`)
178
179
  }
179
180
  } else {
180
- fileServerResults[i].errors.push({ server, error: result.error })
181
- log(`${info.filename}: Failed to upload to ${server}: ${result.error?.message ?? result.error}`)
181
+ fileServerResults[i].errors.push({ server: serverUrl, error: result.error })
182
+ log(`${info.filename}: Failed to upload to ${serverUrl}: ${result.error?.message ?? result.error}`)
182
183
  }
183
184
  }
184
185
  })
@@ -3,10 +3,12 @@ import nostrRelays, { nappRelays } from '#services/nostr-relays.js'
3
3
  import { throttledSendEvent } from '#services/irfs-upload.js'
4
4
  import { sha256 } from '@noble/hashes/sha2.js'
5
5
  import { bytesToBase16 } from '#helpers/base16.js'
6
+ import { normalizeBlossomServerUrl, normalizeRelayUrl } from 'libp2r2p/url'
6
7
 
7
8
  const MANAGED_MANIFEST_TAGS = new Set([
8
9
  'd', 'service', 'path', 'r', 'name', 'summary', 'description', 'self',
9
- 'c', 'l', 't', 'auto', 'icon', 'key_art', 'screenshot', 'x', 'published_at'
10
+ 'c', 'l', 't', 'auto', 'icon', 'key_art', 'screenshot', 'relay', 'server',
11
+ 'x', 'published_at'
10
12
  ])
11
13
 
12
14
  export function normalizeManifestPath (value) {
@@ -169,9 +171,29 @@ function buildMetadataTags ({
169
171
  return tags
170
172
  }
171
173
 
174
+ function normalizeServiceUrls (values, normalizer) {
175
+ const urls = []
176
+ for (const value of Array.isArray(values) ? values : []) {
177
+ try {
178
+ const normalized = normalizer(value)
179
+ if (!urls.includes(normalized)) urls.push(normalized)
180
+ } catch (_) {}
181
+ }
182
+ return urls
183
+ }
184
+
185
+ function buildSourceHintTags (sourceRelays, blossomServers) {
186
+ return [
187
+ ...normalizeServiceUrls(sourceRelays, normalizeRelayUrl)
188
+ .map(relay => ['relay', relay]),
189
+ ...normalizeServiceUrls(blossomServers, normalizeBlossomServerUrl)
190
+ .map(server => ['server', server])
191
+ ]
192
+ }
193
+
172
194
  export function buildManifestTags ({
173
195
  dTag, uploadService, fileMetadata = [], icon, keyArt = [], screenshots = [],
174
- previousTags = [], publishedAt, ...metadata
196
+ previousTags = [], publishedAt, sourceRelays = [], blossomServers = [], ...metadata
175
197
  }) {
176
198
  if (uploadService !== 'irfs' && uploadService !== 'blossom') {
177
199
  throw new Error('Unknown upload service')
@@ -199,6 +221,7 @@ export function buildManifestTags ({
199
221
  ['d', dTag],
200
222
  ...referenceTags,
201
223
  ['service', uploadService],
224
+ ...buildSourceHintTags(sourceRelays, blossomServers),
202
225
  ['x', aggregateHash, 'aggregate'],
203
226
  ['published_at', String(publishedAt)],
204
227
  ...buildMetadataTags({ ...metadata, hasIcon: Boolean(icon) }),