nappup 2.3.4 → 2.3.6

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
@@ -137,4 +137,21 @@ await publishApp(fileList, signer, {
137
137
 
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
- - **`onEvent`** — optional callback that receives progress events with a `type` (`'init'`, `'file-uploaded'`, `'complete'`, `'error'`, …) and `progress` (0–100).
140
+ - **`onEvent`** — optional callback that receives progress events with a `type` (`'services-checking'`, `'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.6",
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
  }
@@ -53,14 +43,41 @@ export default async function (fileList, nostrSigner, opts = {}) {
53
43
  * Every event has `type` (string) and `progress` (0–100 integer).
54
44
  *
55
45
  * Event types:
46
+ * 'services-checking' — Blossom preferences and server health are being checked
56
47
  * 'init' — { totalFiles, totalSteps, dTag, relayCount, blossomCount }
57
48
  * 'media-uploaded' — { mediaType: 'icon'|'key_art'|'screenshot', service: 'blossom'|'irfs'|null }
58
49
  * 'file-uploaded' — { filename, service: 'blossom'|'irfs' }
59
50
  * 'manifest-published' — unified site manifest and app metadata published
60
51
  * 'complete' — { napp } (terminal, progress === 100)
61
52
  * 'error' — { error } (terminal, error is rethrown)
53
+ *
54
+ * Every rejected error has a stable `NAPPUP_*` code and may retain its cause.
62
55
  */
63
- export async function toApp (fileList, nostrSigner, {
56
+ export async function toApp (fileList, nostrSigner, opts = {}) {
57
+ const onEvent = typeof opts.onEvent === 'function' ? opts.onEvent : null
58
+ let lastProgress = 0
59
+ const publishOptions = onEvent
60
+ ? {
61
+ ...opts,
62
+ onEvent (event) {
63
+ lastProgress = event.progress ?? lastProgress
64
+ onEvent(event)
65
+ }
66
+ }
67
+ : opts
68
+ try {
69
+ return await publishApp(fileList, nostrSigner, publishOptions)
70
+ } catch (error) {
71
+ const normalized = normalizeNappupError(error)
72
+ if (onEvent) {
73
+ try { onEvent({ type: 'error', error: normalized, progress: lastProgress }) } catch (_) {}
74
+ }
75
+ throw normalized
76
+ }
77
+ }
78
+
79
+ // Implements publishing after the public boundary has installed error normalization.
80
+ async function publishApp (fileList, nostrSigner, {
64
81
  log = () => {}, onEvent = () => {}, dTag, channel = 'main', shouldReupload = false
65
82
  } = {}) {
66
83
  let steps = 0
@@ -74,27 +91,66 @@ export async function toApp (fileList, nostrSigner, {
74
91
  } catch (_) {}
75
92
  }
76
93
  if (!nostrSigner && typeof window !== 'undefined') nostrSigner = window.nostr
77
- if (!nostrSigner) throw new Error('No Nostr signer found')
94
+ if (!nostrSigner) {
95
+ throw new NappupError(NAPPUP_ERROR_CODES.NO_SIGNER, 'No Nostr signer found')
96
+ }
78
97
  if (typeof window !== 'undefined' && nostrSigner === window.nostr) nostrSigner.getRelays = getRelays
79
98
 
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')
99
+ fileList = Array.from(fileList || [])
100
+ if (!fileList.length) {
101
+ throw new NappupError(NAPPUP_ERROR_CODES.EMPTY_FILE_LIST, 'No app files were provided')
102
+ }
84
103
 
85
- if (typeof dTag === 'string') {
86
- if (!isNostrAppDTagSafe(dTag)) throw new Error('dTag must be a non-empty string with at most 260 characters')
104
+ if (dTag !== undefined && dTag !== null) {
105
+ if (!isNostrAppDTagSafe(dTag)) {
106
+ throw new NappupError(
107
+ NAPPUP_ERROR_CODES.INVALID_D_TAG,
108
+ 'dTag must be a non-empty string with at most 260 characters',
109
+ { details: { dTag } }
110
+ )
111
+ }
87
112
  } else {
88
- const folderName = fileList[0].webkitRelativePath.split('/')[0].trim()
113
+ const relativePath = typeof fileList[0]?.webkitRelativePath === 'string'
114
+ ? fileList[0].webkitRelativePath
115
+ : ''
116
+ const folderName = relativePath.split('/')[0].trim()
89
117
  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.`)
118
+ throw new NappupError(
119
+ NAPPUP_ERROR_CODES.GENERIC_FOLDER_NAME,
120
+ `Folder name "${folderName}" is a generic build folder. Please provide a d tag with the -d flag.`,
121
+ { details: { folderName } }
122
+ )
91
123
  }
92
124
  dTag = folderName
93
125
  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.')
126
+ throw new NappupError(
127
+ NAPPUP_ERROR_CODES.INVALID_FOLDER_NAME,
128
+ 'Could not derive a valid d tag from the folder name. Please provide one with the -d flag.',
129
+ { details: { folderName } }
130
+ )
95
131
  }
96
132
  }
97
133
 
134
+ let signerRelays
135
+ try {
136
+ signerRelays = await nostrSigner.getRelays()
137
+ } catch (error) {
138
+ throw new NappupError(
139
+ NAPPUP_ERROR_CODES.RELAY_LOOKUP_FAILED,
140
+ 'Could not read the signer outbox relays',
141
+ { cause: error }
142
+ )
143
+ }
144
+ const writeRelays = [...new Set((Array.isArray(signerRelays?.write) ? signerRelays.write : [])
145
+ .flatMap(relay => typeof relay === 'string' && relay.trim()
146
+ ? [relay.trim().replace(/\/$/, '')]
147
+ : []
148
+ ))]
149
+ log(`Found ${writeRelays.length} outbox relays:\n${writeRelays.join(', ')}`)
150
+ if (!writeRelays.length) {
151
+ throw new NappupError(NAPPUP_ERROR_CODES.NO_OUTBOX_RELAYS, 'No outbox relays found')
152
+ }
153
+
98
154
  const fileMetadata = []
99
155
  const nappJsonFile = fileList.find(file => file.webkitRelativePath.split('/').slice(1).join('/') === '.well-known/napp.json')
100
156
  let nappJson = {}
@@ -127,8 +183,14 @@ export async function toApp (fileList, nostrSigner, {
127
183
  let isExplicitIcon = false
128
184
  let pause = 1000
129
185
 
186
+ emit({ type: 'services-checking' })
130
187
  log('Checking for blossom servers...')
131
- const blossomServerUrls = await getBlossomServers(nostrSigner, writeRelays)
188
+ let blossomServerUrls = []
189
+ try {
190
+ blossomServerUrls = await getBlossomServers(nostrSigner, writeRelays)
191
+ } catch (error) {
192
+ log('Could not read Blossom server preferences; using relay-based upload instead', error)
193
+ }
132
194
  let healthyBlossomServers = []
133
195
  if (blossomServerUrls.length) {
134
196
  log(`Found ${blossomServerUrls.length} blossom servers: ${blossomServerUrls.join(', ')}`)
@@ -238,7 +300,18 @@ export async function toApp (fileList, nostrSigner, {
238
300
  shouldReupload,
239
301
  log
240
302
  })
241
- if (failedFiles.length) throw new Error(`${failedFiles.length} file(s) failed to upload to blossom`)
303
+ if (failedFiles.length) {
304
+ throw new NappupError(
305
+ NAPPUP_ERROR_CODES.BLOSSOM_UPLOAD_FAILED,
306
+ `${failedFiles.length} file(s) failed to upload to Blossom`,
307
+ {
308
+ details: {
309
+ failedFileCount: failedFiles.length,
310
+ filenames: failedFiles.map(failed => failed.filename).filter(Boolean)
311
+ }
312
+ }
313
+ )
314
+ }
242
315
 
243
316
  for (const uploaded of uploadedFiles) {
244
317
  const metadata = {
@@ -254,65 +327,82 @@ export async function toApp (fileList, nostrSigner, {
254
327
  }
255
328
  } else {
256
329
  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
330
  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
331
+ try {
332
+ const nmmr = new NMMR()
333
+ let chunkLength = 0
334
+ for await (const chunk of streamToChunks(file.stream(), 51000)) {
335
+ chunkLength++
336
+ await nmmr.append(chunk)
337
+ }
338
+ // Empty IRFS blobs deliberately have no chunks and no manifest reference.
339
+ if (!chunkLength) {
340
+ steps++
341
+ emit({ type: 'file-uploaded', filename, service: 'irfs' })
342
+ continue
343
+ }
344
+
345
+ log(`Uploading ${chunkLength} file parts of ${filename}`)
346
+ ;({ pause } = await uploadBinaryDataChunks({
347
+ nmmr, signer: nostrSigner, filename, chunkLength, log, pause, shouldReupload
348
+ }))
349
+ const metadata = {
350
+ rootHash: nmmr.getRoot(),
351
+ filename,
352
+ mimeType: file.type || 'application/octet-stream',
353
+ size: file.size
354
+ }
355
+ fileMetadata.push(metadata)
356
+ if (!iconMetadata && faviconFile && file === faviconFile) iconMetadata = { ...metadata }
357
+ steps++
358
+ emit({ type: 'file-uploaded', filename, service: 'irfs' })
359
+ } catch (error) {
360
+ throw new NappupError(
361
+ NAPPUP_ERROR_CODES.IRFS_UPLOAD_FAILED,
362
+ `Failed to upload "${filename}" to Nostr relays`,
363
+ { cause: error, details: { filename } }
364
+ )
280
365
  }
281
- fileMetadata.push(metadata)
282
- if (!iconMetadata && faviconFile && file === faviconFile) iconMetadata = { ...metadata }
283
- steps++
284
- emit({ type: 'file-uploaded', filename, service: 'irfs' })
285
366
  }
286
367
  }
287
368
 
288
369
  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
- })
370
+ let manifest
371
+ try {
372
+ manifest = await uploadSiteManifest({
373
+ dTag,
374
+ channel,
375
+ fileMetadata,
376
+ name: manifestName,
377
+ nameLang: nappJson.name?.[0]?.[1],
378
+ isNameAuto: !nappJson.name?.[0]?.[0],
379
+ summary: manifestSummary,
380
+ summaryLang: nappJson.summary?.[0]?.[1],
381
+ isSummaryAuto: !nappJson.summary?.[0]?.[0],
382
+ icon: iconMetadata,
383
+ isIconAuto: !isExplicitIcon,
384
+ descriptions: nappJson.description,
385
+ keyArt: keyArtMetadata,
386
+ screenshots: screenshotMetadata,
387
+ uploadService,
388
+ sourceRelays: writeRelays,
389
+ blossomServers: healthyBlossomServers,
390
+ signer: nostrSigner,
391
+ log,
392
+ pause,
393
+ shouldReupload,
394
+ self: nappJson.self?.[0]?.[0],
395
+ countries: nappJson.country,
396
+ categories: nappJson.category,
397
+ hashtags: nappJson.hashtag
398
+ })
399
+ } catch (error) {
400
+ throw new NappupError(
401
+ NAPPUP_ERROR_CODES.MANIFEST_UPLOAD_FAILED,
402
+ 'Failed to publish the app manifest to Nostr relays',
403
+ { cause: error }
404
+ )
405
+ }
316
406
 
317
407
  const appEntity = appEncode({
318
408
  dTag: manifest.tags.find(tag => tag[0] === 'd')[1],
@@ -3,6 +3,27 @@ import nostrRelays from '#services/nostr-relays.js'
3
3
  import { bytesToBase16 } from '#helpers/base16.js'
4
4
  import { normalizeBlossomServerUrl } from 'libp2r2p/url'
5
5
 
6
+ const DEFAULT_HEALTH_CHECK_TIMEOUT_MS = 5000
7
+ const DEFAULT_EXISTENCE_CHECK_TIMEOUT_MS = 5000
8
+
9
+ // Bounds browser fetches whose native network timeout can take minutes.
10
+ async function fetchWithTimeout (url, options, timeoutMs) {
11
+ const controller = new AbortController()
12
+ let timedOut = false
13
+ const timeoutId = setTimeout(() => {
14
+ timedOut = true
15
+ controller.abort()
16
+ }, timeoutMs)
17
+ try {
18
+ return await fetch(url, { ...options, signal: controller.signal })
19
+ } catch (error) {
20
+ if (timedOut) throw new Error(`request timed out after ${timeoutMs}ms`)
21
+ throw error
22
+ } finally {
23
+ clearTimeout(timeoutId)
24
+ }
25
+ }
26
+
6
27
  async function createAuthHeader (signer, modify) {
7
28
  const now = Math.floor(Date.now() / 1000)
8
29
  const event = {
@@ -43,13 +64,16 @@ export async function getBlossomServers (signer, writeRelays) {
43
64
  /**
44
65
  * Health-checks blossom servers with a simple HEAD request.
45
66
  * A server is considered healthy if fetch resolves (any HTTP status).
46
- * Only network-level errors mark a server as unreachable.
67
+ * Network errors and timeouts mark a server as unreachable.
47
68
  */
48
- export async function healthCheckServers (servers, signer, { log = () => {} } = {}) {
69
+ export async function healthCheckServers (servers, signer, {
70
+ log = () => {},
71
+ timeoutMs = DEFAULT_HEALTH_CHECK_TIMEOUT_MS
72
+ } = {}) {
49
73
  const results = await Promise.allSettled(
50
74
  servers.map(async (serverUrl) => {
51
75
  const normalized = normalizeBlossomServerUrl(serverUrl)
52
- await fetch(normalized, { method: 'HEAD' })
76
+ await fetchWithTimeout(normalized, { method: 'HEAD', mode: 'no-cors' }, timeoutMs)
53
77
  return normalized
54
78
  })
55
79
  )
@@ -86,9 +110,17 @@ export async function computeFileHash (file) {
86
110
  async function uploadFileToServer (serverUrl, signer, file, fileHash, mimeType, { shouldReupload, log, maxRetries = 5 }) {
87
111
  // Check if already uploaded
88
112
  if (!shouldReupload) {
89
- const checkResponse = await fetch(`${serverUrl}/${fileHash}`, { method: 'HEAD' })
90
- if (checkResponse.ok) {
91
- return { success: true, alreadyExists: true }
113
+ try {
114
+ const checkResponse = await fetchWithTimeout(
115
+ `${serverUrl}/${fileHash}`,
116
+ { method: 'HEAD' },
117
+ DEFAULT_EXISTENCE_CHECK_TIMEOUT_MS
118
+ )
119
+ if (checkResponse.ok) {
120
+ return { success: true, alreadyExists: true }
121
+ }
122
+ } catch (error) {
123
+ log(`Could not check whether ${fileHash} exists on ${serverUrl}; uploading it anyway: ${error?.message ?? error}`)
92
124
  }
93
125
  }
94
126
 
@@ -184,7 +216,8 @@ export async function uploadFilesToBlossom ({
184
216
  }
185
217
  })
186
218
 
187
- await Promise.allSettled(serverTasks)
219
+ // Unexpected task errors indicate a programming failure and must reach the caller.
220
+ await Promise.all(serverTasks)
188
221
 
189
222
  const uploadedFiles = []
190
223
  const failedFiles = []