nappup 2.3.4 → 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.4",
9
+ "version": "2.3.5",
10
10
  "description": "Nostr App Uploader",
11
11
  "type": "module",
12
12
  "scripts": {
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
+ }
@@ -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 = {}
@@ -128,7 +183,12 @@ export async function toApp (fileList, nostrSigner, {
128
183
  let pause = 1000
129
184
 
130
185
  log('Checking for blossom servers...')
131
- 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
+ }
132
192
  let healthyBlossomServers = []
133
193
  if (blossomServerUrls.length) {
134
194
  log(`Found ${blossomServerUrls.length} blossom servers: ${blossomServerUrls.join(', ')}`)
@@ -238,7 +298,18 @@ export async function toApp (fileList, nostrSigner, {
238
298
  shouldReupload,
239
299
  log
240
300
  })
241
- 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
+ }
242
313
 
243
314
  for (const uploaded of uploadedFiles) {
244
315
  const metadata = {
@@ -254,65 +325,82 @@ export async function toApp (fileList, nostrSigner, {
254
325
  }
255
326
  } else {
256
327
  for (const file of fileList) {
257
- const nmmr = new NMMR()
258
- let chunkLength = 0
259
- for await (const chunk of streamToChunks(file.stream(), 51000)) {
260
- chunkLength++
261
- await nmmr.append(chunk)
262
- }
263
- // Empty IRFS blobs deliberately have no chunks and no manifest reference.
264
- if (!chunkLength) {
265
- steps++
266
- emit({ type: 'file-uploaded', filename: file.webkitRelativePath.split('/').slice(1).join('/'), service: 'irfs' })
267
- continue
268
- }
269
-
270
328
  const filename = file.webkitRelativePath.split('/').slice(1).join('/')
271
- log(`Uploading ${chunkLength} file parts of ${filename}`)
272
- ;({ pause } = await uploadBinaryDataChunks({
273
- nmmr, signer: nostrSigner, filename, chunkLength, log, pause, shouldReupload
274
- }))
275
- const metadata = {
276
- rootHash: nmmr.getRoot(),
277
- filename,
278
- mimeType: file.type || 'application/octet-stream',
279
- 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
+ )
280
363
  }
281
- fileMetadata.push(metadata)
282
- if (!iconMetadata && faviconFile && file === faviconFile) iconMetadata = { ...metadata }
283
- steps++
284
- emit({ type: 'file-uploaded', filename, service: 'irfs' })
285
364
  }
286
365
  }
287
366
 
288
367
  log(`Uploading unified site manifest ${dTag}`)
289
- const manifest = await uploadSiteManifest({
290
- dTag,
291
- channel,
292
- fileMetadata,
293
- name: manifestName,
294
- nameLang: nappJson.name?.[0]?.[1],
295
- isNameAuto: !nappJson.name?.[0]?.[0],
296
- summary: manifestSummary,
297
- summaryLang: nappJson.summary?.[0]?.[1],
298
- isSummaryAuto: !nappJson.summary?.[0]?.[0],
299
- icon: iconMetadata,
300
- isIconAuto: !isExplicitIcon,
301
- descriptions: nappJson.description,
302
- keyArt: keyArtMetadata,
303
- screenshots: screenshotMetadata,
304
- uploadService,
305
- sourceRelays: writeRelays,
306
- blossomServers: healthyBlossomServers,
307
- signer: nostrSigner,
308
- log,
309
- pause,
310
- shouldReupload,
311
- self: nappJson.self?.[0]?.[0],
312
- countries: nappJson.country,
313
- categories: nappJson.category,
314
- hashtags: nappJson.hashtag
315
- })
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
+ }
316
404
 
317
405
  const appEntity = appEncode({
318
406
  dTag: manifest.tags.find(tag => tag[0] === 'd')[1],