nappup 2.3.10 → 2.3.11
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 +53 -2
- package/bin/nappup/helpers.js +3 -2
- package/package.json +1 -1
- package/src/errors.js +52 -13
- package/src/index.js +3 -17
- package/src/services/blossom-upload.js +65 -31
- package/src/services/irfs-upload.js +6 -0
package/README.md
CHANGED
|
@@ -76,7 +76,10 @@ export DOTENV_PRIVATE_KEY_NAPPUP="$(nappup env keygen)"
|
|
|
76
76
|
|
|
77
77
|
`env keygen` writes only the generated 64-character lowercase hex key to standard output, writes a reminder to standard error, and never reads or modifies `.env`. Store the result in a secret manager; nappup cannot recover it.
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
Encrypting a replacement requires only `DOTENV_PUBLIC_KEY_NAPPUP`. The command
|
|
80
|
+
below receives the **new Nostr credential** (a secret key or bunker URL), but does
|
|
81
|
+
not require `DOTENV_PRIVATE_KEY_NAPPUP`, the separate **dotenv decryption key**.
|
|
82
|
+
It does not need to decrypt or know the previous Nostr credential:
|
|
80
83
|
|
|
81
84
|
```bash
|
|
82
85
|
nappup env set NOSTR_SECRET_KEY
|
|
@@ -89,6 +92,39 @@ Existing plaintext values are encrypted automatically when used. If an explicit
|
|
|
89
92
|
|
|
90
93
|
Use `DOTENV_CONFIG_PATH` to select a different dotenv file. The private key must come from the process environment or CLI and is rejected if stored inside that file.
|
|
91
94
|
|
|
95
|
+
### Local development and shared credentials
|
|
96
|
+
|
|
97
|
+
To use a checkout instead of the registry package:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
cd /path/to/nappup
|
|
101
|
+
npm link
|
|
102
|
+
cd /path/to/app
|
|
103
|
+
npm link nappup --no-save --package-lock=false
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The second command links the app dependency and its local CLI to the global link.
|
|
107
|
+
Projects that use nappup at runtime can keep a registry dependency for reproducible
|
|
108
|
+
installs; local CLI-only projects may rely entirely on the link. `npm ci` removes
|
|
109
|
+
local links, so repeat the second command afterward. When switching Node/npm
|
|
110
|
+
installations, repeat both commands to register the checkout under the new global
|
|
111
|
+
prefix as well.
|
|
112
|
+
|
|
113
|
+
A link shares code, not credentials: `.env` still defaults to the current working
|
|
114
|
+
directory. To use one existing encrypted identity across projects, export an
|
|
115
|
+
absolute path to its dotenv file in the shells running nappup:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
export DOTENV_CONFIG_PATH="/absolute/path/to/shared/nappup.env"
|
|
119
|
+
nappup env set NOSTR_SECRET_KEY
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Choose an existing file to retain its identity, or set the desired credential once
|
|
123
|
+
in a new file. Supply the matching `DOTENV_PRIVATE_KEY_NAPPUP` if that file uses a
|
|
124
|
+
custom encryption key. Changing credentials in a shared file affects every project
|
|
125
|
+
using it; existing project `.env` files are not automatically merged or migrated.
|
|
126
|
+
A process-level `NOSTR_SECRET_KEY` still takes precedence over the file.
|
|
127
|
+
|
|
92
128
|
### Examples
|
|
93
129
|
|
|
94
130
|
Upload the current directory to the main channel:
|
|
@@ -142,7 +178,22 @@ await publishApp(fileList, signer, {
|
|
|
142
178
|
Rejected uploads use `NappupError`, with a stable code from
|
|
143
179
|
`NAPPUP_ERROR_CODES`. The original error is retained as `cause`, and some
|
|
144
180
|
errors include structured `details`, so applications can show their own
|
|
145
|
-
recovery instructions without matching CLI-oriented message text
|
|
181
|
+
recovery instructions without matching CLI-oriented message text.
|
|
182
|
+
|
|
183
|
+
For terminal destination failures, `error.details.failures` contains
|
|
184
|
+
`{ destination, filename?, reason }` entries. `reason` retains the original error,
|
|
185
|
+
including HTTP `status`, `retryable`, `retryAfterMs`, or relay `category` when
|
|
186
|
+
available. Native causes and aggregated errors remain available for diagnostics.
|
|
187
|
+
Signer failures are normalized to `NAPPUP_SIGNER_LOCKED` or `NAPPUP_SIGNER_DENIED`,
|
|
188
|
+
including when nested inside an aggregate.
|
|
189
|
+
|
|
190
|
+
A file succeeds when at least one destination confirms a copy (each chunk for
|
|
191
|
+
IRFS). Failures of extra replicas are logged without emitting a terminal error.
|
|
192
|
+
Publishing the app manifest still requires a confirmed copy. Display recovery
|
|
193
|
+
instructions for rejected operations; use specific destination guidance only
|
|
194
|
+
when it applies to all blocking failures. Different files may succeed on
|
|
195
|
+
different servers.
|
|
196
|
+
|
|
146
197
|
|
|
147
198
|
```js
|
|
148
199
|
import publishApp, { NAPPUP_ERROR_CODES } from 'nappup'
|
package/bin/nappup/helpers.js
CHANGED
|
@@ -90,13 +90,14 @@ function parseEnvArgs (args) {
|
|
|
90
90
|
function hiddenQuestion (query, { input, output }) {
|
|
91
91
|
return new Promise((resolve, reject) => {
|
|
92
92
|
const wasRaw = Boolean(input.isRaw)
|
|
93
|
-
const
|
|
93
|
+
const wasFlowing = input.readableFlowing === true
|
|
94
94
|
let value = ''
|
|
95
95
|
|
|
96
96
|
function cleanup () {
|
|
97
97
|
input.off('keypress', onKeypress)
|
|
98
98
|
input.setRawMode(wasRaw)
|
|
99
|
-
|
|
99
|
+
// An untouched TTY has readableFlowing === null, not isPaused() === true.
|
|
100
|
+
if (!wasFlowing) input.pause()
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
function onKeypress (text, key = {}) {
|
package/package.json
CHANGED
package/src/errors.js
CHANGED
|
@@ -75,14 +75,27 @@ const SIGNER_DENIED_PATTERNS = [
|
|
|
75
75
|
/sign(?:ing)? request (?:rejected|denied)/i
|
|
76
76
|
]
|
|
77
77
|
|
|
78
|
-
//
|
|
79
|
-
|
|
78
|
+
// Traverses native error trees without looping through cycles or unbounded causes.
|
|
79
|
+
function * errorTree (error, seen = new Set(), depth = 0) {
|
|
80
|
+
if (!error || typeof error !== 'object' || seen.has(error) || depth >= 12) return
|
|
81
|
+
seen.add(error)
|
|
82
|
+
yield error
|
|
83
|
+
yield * errorTree(error.cause, seen, depth + 1)
|
|
84
|
+
if (Array.isArray(error.errors)) {
|
|
85
|
+
for (const child of error.errors) yield * errorTree(child, seen, depth + 1)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Classifies original signer failures, not diagnostic text from servers or aggregates.
|
|
80
90
|
export function classifySignerError (error) {
|
|
81
|
-
|
|
82
|
-
for (let depth = 0; current && depth < 6; depth++) {
|
|
91
|
+
for (const current of errorTree(error)) {
|
|
83
92
|
if (current.name === 'NotAllowedError' || current.code === 'DENIED_BY_USER') {
|
|
84
93
|
return NAPPUP_ERROR_CODES.SIGNER_DENIED
|
|
85
94
|
}
|
|
95
|
+
if (current.code === NAPPUP_ERROR_CODES.SIGNER_LOCKED || current.code === NAPPUP_ERROR_CODES.SIGNER_DENIED) {
|
|
96
|
+
return current.code
|
|
97
|
+
}
|
|
98
|
+
if (Array.isArray(current.errors) || current.category || current.code === 'BLOSSOM_HTTP_ERROR') continue
|
|
86
99
|
const message = typeof current.message === 'string' ? current.message : ''
|
|
87
100
|
if (SIGNER_LOCKED_PATTERNS.some(pattern => pattern.test(message))) {
|
|
88
101
|
return NAPPUP_ERROR_CODES.SIGNER_LOCKED
|
|
@@ -90,20 +103,46 @@ export function classifySignerError (error) {
|
|
|
90
103
|
if (SIGNER_DENIED_PATTERNS.some(pattern => pattern.test(message))) {
|
|
91
104
|
return NAPPUP_ERROR_CODES.SIGNER_DENIED
|
|
92
105
|
}
|
|
93
|
-
current = current.cause
|
|
94
106
|
}
|
|
95
107
|
return null
|
|
96
108
|
}
|
|
97
109
|
|
|
110
|
+
// Retains only failures of files with no confirmed Blossom copy.
|
|
111
|
+
export function blossomUploadError (failedFiles) {
|
|
112
|
+
const failures = failedFiles.flatMap(file => (file.errors ?? []).map(({ server, error }) => ({
|
|
113
|
+
filename: file.filename, destination: server, reason: error
|
|
114
|
+
})))
|
|
115
|
+
return new NappupError(NAPPUP_ERROR_CODES.BLOSSOM_UPLOAD_FAILED,
|
|
116
|
+
`${failedFiles.length} file(s) failed to upload to Blossom`, {
|
|
117
|
+
cause: new AggregateError(failures.map(failure => failure.reason), 'Blossom destinations failed'),
|
|
118
|
+
details: {
|
|
119
|
+
failedFileCount: failedFiles.length,
|
|
120
|
+
filenames: failedFiles.map(file => file.filename).filter(Boolean),
|
|
121
|
+
failures
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Exposes destination failures consistently across Blossom and relay publication.
|
|
127
|
+
function failureDetails (error) {
|
|
128
|
+
if (error?.details?.failures) return error.details
|
|
129
|
+
const failures = []
|
|
130
|
+
for (const current of errorTree(error)) {
|
|
131
|
+
if (!Array.isArray(current.failures)) continue
|
|
132
|
+
for (const { relay, reason } of current.failures) {
|
|
133
|
+
failures.push({ destination: relay, reason, ...(error?.details?.filename ? { filename: error.details.filename } : {}) })
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return failures.length ? { ...error.details, failures } : error?.details
|
|
137
|
+
}
|
|
138
|
+
|
|
98
139
|
// Ensures every error crossing nappup's public API has a documented code.
|
|
99
140
|
export function normalizeNappupError (error) {
|
|
141
|
+
const details = failureDetails(error)
|
|
100
142
|
if (typeof error?.code === 'string' && error.code.startsWith('NAPPUP_')) {
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
103
|
-
return new NappupError(
|
|
104
|
-
cause: error.cause,
|
|
105
|
-
details: error.details
|
|
106
|
-
})
|
|
143
|
+
const code = classifySignerError(error) ?? error.code
|
|
144
|
+
if (code !== error.code || details !== error.details) {
|
|
145
|
+
return new NappupError(code, error.message, { cause: error.cause, details })
|
|
107
146
|
}
|
|
108
147
|
return error
|
|
109
148
|
}
|
|
@@ -111,12 +150,12 @@ export function normalizeNappupError (error) {
|
|
|
111
150
|
return new NappupError(
|
|
112
151
|
NAPPUP_ERROR_CODES.UPLOAD_CANCELLED,
|
|
113
152
|
error?.message || 'Upload cancelled',
|
|
114
|
-
{ cause: error }
|
|
153
|
+
{ cause: error, details }
|
|
115
154
|
)
|
|
116
155
|
}
|
|
117
156
|
return new NappupError(
|
|
118
157
|
classifySignerError(error) ?? NAPPUP_ERROR_CODES.UPLOAD_FAILED,
|
|
119
158
|
error?.message || 'Upload failed',
|
|
120
|
-
{ cause: error }
|
|
159
|
+
{ cause: error, details }
|
|
121
160
|
)
|
|
122
161
|
}
|
package/src/index.js
CHANGED
|
@@ -8,7 +8,7 @@ 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,
|
|
11
|
+
import { NappupError, NAPPUP_ERROR_CODES, normalizeNappupError, blossomUploadError } from '#errors.js'
|
|
12
12
|
|
|
13
13
|
export { NappupError, NAPPUP_ERROR_CODES } from '#errors.js'
|
|
14
14
|
|
|
@@ -215,7 +215,7 @@ async function publishApp (fileList, nostrSigner, {
|
|
|
215
215
|
shouldReupload,
|
|
216
216
|
log
|
|
217
217
|
})
|
|
218
|
-
if (failedFiles.length) throw
|
|
218
|
+
if (failedFiles.length) throw blossomUploadError(failedFiles)
|
|
219
219
|
return { rootHash: uploadedFiles[0].sha256, mimeType, size: blob.size }
|
|
220
220
|
}
|
|
221
221
|
|
|
@@ -300,21 +300,7 @@ async function publishApp (fileList, nostrSigner, {
|
|
|
300
300
|
shouldReupload,
|
|
301
301
|
log
|
|
302
302
|
})
|
|
303
|
-
if (failedFiles.length)
|
|
304
|
-
const signerError = failedFiles
|
|
305
|
-
.flatMap(failed => failed.errors ?? [])
|
|
306
|
-
.map(failed => failed.error)
|
|
307
|
-
.find(error => classifySignerError(error))
|
|
308
|
-
const details = {
|
|
309
|
-
failedFileCount: failedFiles.length,
|
|
310
|
-
filenames: failedFiles.map(failed => failed.filename).filter(Boolean)
|
|
311
|
-
}
|
|
312
|
-
throw new NappupError(
|
|
313
|
-
NAPPUP_ERROR_CODES.BLOSSOM_UPLOAD_FAILED,
|
|
314
|
-
`${failedFiles.length} file(s) failed to upload to Blossom`,
|
|
315
|
-
signerError ? { cause: signerError, details } : { details }
|
|
316
|
-
)
|
|
317
|
-
}
|
|
303
|
+
if (failedFiles.length) throw blossomUploadError(failedFiles)
|
|
318
304
|
|
|
319
305
|
for (const uploaded of uploadedFiles) {
|
|
320
306
|
const metadata = {
|
|
@@ -6,9 +6,11 @@ import { classifySignerError } from '#errors.js'
|
|
|
6
6
|
|
|
7
7
|
const DEFAULT_HEALTH_CHECK_TIMEOUT_MS = 5000
|
|
8
8
|
const DEFAULT_EXISTENCE_CHECK_TIMEOUT_MS = 5000
|
|
9
|
+
const DEFAULT_UPLOAD_TIMEOUT_MS = 60000
|
|
10
|
+
const MAX_RETRY_WAIT_MS = 60000
|
|
9
11
|
|
|
10
12
|
// Bounds browser fetches whose native network timeout can take minutes.
|
|
11
|
-
async function fetchWithTimeout (url, options, timeoutMs) {
|
|
13
|
+
async function fetchWithTimeout (url, options, timeoutMs, consume = response => response) {
|
|
12
14
|
const controller = new AbortController()
|
|
13
15
|
let timedOut = false
|
|
14
16
|
const timeoutId = setTimeout(() => {
|
|
@@ -16,9 +18,12 @@ async function fetchWithTimeout (url, options, timeoutMs) {
|
|
|
16
18
|
controller.abort()
|
|
17
19
|
}, timeoutMs)
|
|
18
20
|
try {
|
|
19
|
-
|
|
21
|
+
const response = await fetch(url, { ...options, signal: controller.signal })
|
|
22
|
+
return await consume(response)
|
|
20
23
|
} catch (error) {
|
|
21
|
-
if (timedOut)
|
|
24
|
+
if (timedOut) {
|
|
25
|
+
throw Object.assign(new Error(`request timed out after ${timeoutMs}ms`, { cause: error }), { category: 'timeout' })
|
|
26
|
+
}
|
|
22
27
|
throw error
|
|
23
28
|
} finally {
|
|
24
29
|
clearTimeout(timeoutId)
|
|
@@ -104,12 +109,42 @@ export async function computeFileHash (file) {
|
|
|
104
109
|
return bytesToBase16(hash.digest())
|
|
105
110
|
}
|
|
106
111
|
|
|
112
|
+
// Retry-After is advisory timing; X-Reason is diagnostic text, never policy.
|
|
113
|
+
function retryAfterMs (value) {
|
|
114
|
+
if (!value) return 0
|
|
115
|
+
const text = value.trim()
|
|
116
|
+
if (/^\d+$/.test(text)) return Number(text) * 1000
|
|
117
|
+
const date = Date.parse(text)
|
|
118
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function readUploadResponse (response) {
|
|
122
|
+
if (response.status !== 200 && response.status !== 201) {
|
|
123
|
+
const status = response.status
|
|
124
|
+
const reason = response.headers.get('X-Reason') || response.statusText || 'No error message provided'
|
|
125
|
+
const error = new Error(`upload returned an error (${status}): ${reason}`)
|
|
126
|
+
error.code = 'BLOSSOM_HTTP_ERROR'
|
|
127
|
+
error.status = status
|
|
128
|
+
error.retryable = [408, 425, 429].includes(status) || (status >= 500 && status <= 599 && ![501, 505].includes(status))
|
|
129
|
+
error.retryAfterMs = retryAfterMs(response.headers.get('Retry-After'))
|
|
130
|
+
await response.body?.cancel().catch(() => {})
|
|
131
|
+
throw error
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
return await response.json()
|
|
135
|
+
} catch (cause) {
|
|
136
|
+
if (!(cause instanceof SyntaxError)) throw cause
|
|
137
|
+
const error = new Error('upload returned an invalid JSON blob descriptor', { cause })
|
|
138
|
+
error.retryable = false
|
|
139
|
+
throw error
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
107
143
|
/**
|
|
108
|
-
* Uploads a single file to a single blossom server with retry+backoff.
|
|
144
|
+
* Uploads a single file to a single blossom server with bounded retry+backoff.
|
|
109
145
|
* Returns { success: true, descriptor } or { success: false, error }.
|
|
110
146
|
*/
|
|
111
|
-
async function uploadFileToServer (serverUrl, signer, file, fileHash, mimeType, { shouldReupload, log, maxRetries = 5 }) {
|
|
112
|
-
// Check if already uploaded
|
|
147
|
+
async function uploadFileToServer (serverUrl, signer, file, fileHash, mimeType, { shouldReupload, log, maxRetries = 5, uploadTimeoutMs }) {
|
|
113
148
|
if (!shouldReupload) {
|
|
114
149
|
try {
|
|
115
150
|
const checkResponse = await fetchWithTimeout(
|
|
@@ -117,9 +152,7 @@ async function uploadFileToServer (serverUrl, signer, file, fileHash, mimeType,
|
|
|
117
152
|
{ method: 'HEAD' },
|
|
118
153
|
DEFAULT_EXISTENCE_CHECK_TIMEOUT_MS
|
|
119
154
|
)
|
|
120
|
-
if (checkResponse.ok) {
|
|
121
|
-
return { success: true, alreadyExists: true }
|
|
122
|
-
}
|
|
155
|
+
if (checkResponse.ok) return { success: true, alreadyExists: true }
|
|
123
156
|
} catch (error) {
|
|
124
157
|
log(`Could not check whether ${fileHash} exists on ${serverUrl}; uploading it anyway: ${error?.message ?? error}`)
|
|
125
158
|
}
|
|
@@ -129,36 +162,36 @@ async function uploadFileToServer (serverUrl, signer, file, fileHash, mimeType,
|
|
|
129
162
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
130
163
|
try {
|
|
131
164
|
if (attempt > 0) {
|
|
132
|
-
log(`Retrying upload to ${serverUrl} (attempt ${attempt + 1}/${maxRetries + 1})`)
|
|
165
|
+
log(`Retrying upload to ${serverUrl} in ${pause}ms (attempt ${attempt + 1}/${maxRetries + 1})`)
|
|
133
166
|
await new Promise(resolve => setTimeout(resolve, pause))
|
|
134
|
-
pause += 2000
|
|
135
167
|
}
|
|
136
168
|
const authorization = await createAuthHeader(signer, (evt) => {
|
|
137
169
|
evt.tags.push(['t', 'upload'])
|
|
138
170
|
evt.tags.push(['x', fileHash])
|
|
139
171
|
})
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
})
|
|
146
|
-
if (response.status >= 300) {
|
|
147
|
-
const reason = response.headers.get('X-Reason') || response.statusText
|
|
148
|
-
throw new Error(`upload returned an error (${response.status}): ${reason}`)
|
|
172
|
+
const headers = { 'Content-Type': mimeType, 'X-SHA-256': fileHash, Authorization: authorization }
|
|
173
|
+
// Browsers own Content-Length. A native Blob/File gives fetch its size;
|
|
174
|
+
// Node's file-like streaming adapter needs the known length explicitly.
|
|
175
|
+
if (globalThis.process?.versions?.node && Number.isSafeInteger(file.size) && file.size >= 0) {
|
|
176
|
+
headers['Content-Length'] = String(file.size)
|
|
149
177
|
}
|
|
150
|
-
const
|
|
178
|
+
const isBlob = typeof Blob !== 'undefined' && file instanceof Blob
|
|
179
|
+
const descriptor = await fetchWithTimeout(`${serverUrl}/upload`, {
|
|
180
|
+
method: 'PUT', headers,
|
|
181
|
+
body: isBlob ? file : file.stream(),
|
|
182
|
+
...(isBlob ? {} : { duplex: 'half' }),
|
|
183
|
+
redirect: 'manual'
|
|
184
|
+
}, uploadTimeoutMs, readUploadResponse)
|
|
151
185
|
return { success: true, descriptor }
|
|
152
|
-
} catch (
|
|
153
|
-
if (classifySignerError(
|
|
154
|
-
|
|
155
|
-
// transient upload errors: retrying would just re-prompt or fail
|
|
156
|
-
// again, so surface them immediately.
|
|
157
|
-
return { success: false, error: err }
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (classifySignerError(error) || error.name === 'AbortError' || error.retryable === false || attempt === maxRetries) {
|
|
188
|
+
return { success: false, error }
|
|
158
189
|
}
|
|
159
|
-
if (
|
|
160
|
-
|
|
190
|
+
if (error.retryAfterMs > MAX_RETRY_WAIT_MS) {
|
|
191
|
+
log(`${serverUrl}: Retry-After exceeds the ${MAX_RETRY_WAIT_MS}ms automatic wait budget; retry in a later operation`)
|
|
192
|
+
return { success: false, error }
|
|
161
193
|
}
|
|
194
|
+
pause = Math.max(1000 + attempt * 2000, error.retryAfterMs || 0)
|
|
162
195
|
}
|
|
163
196
|
}
|
|
164
197
|
return { success: false, error: new Error('Max retries exceeded') }
|
|
@@ -180,6 +213,7 @@ export async function uploadFilesToBlossom ({
|
|
|
180
213
|
signer,
|
|
181
214
|
shouldReupload = false,
|
|
182
215
|
maxRetries = 5,
|
|
216
|
+
uploadTimeoutMs = DEFAULT_UPLOAD_TIMEOUT_MS,
|
|
183
217
|
log = () => {}
|
|
184
218
|
}) {
|
|
185
219
|
const normalizedServers = [...new Set(servers.flatMap(server => {
|
|
@@ -207,7 +241,7 @@ export async function uploadFilesToBlossom ({
|
|
|
207
241
|
for (let i = 0; i < fileInfos.length; i++) {
|
|
208
242
|
const info = fileInfos[i]
|
|
209
243
|
log(`Uploading ${info.filename} to ${serverUrl}`)
|
|
210
|
-
const result = await uploadFileToServer(serverUrl, signer, info.file, info.sha256, info.mimeType, { shouldReupload, log, maxRetries })
|
|
244
|
+
const result = await uploadFileToServer(serverUrl, signer, info.file, info.sha256, info.mimeType, { shouldReupload, log, maxRetries, uploadTimeoutMs })
|
|
211
245
|
|
|
212
246
|
if (result.success) {
|
|
213
247
|
fileServerResults[i].successCount++
|
|
@@ -218,7 +252,7 @@ export async function uploadFilesToBlossom ({
|
|
|
218
252
|
}
|
|
219
253
|
} else {
|
|
220
254
|
fileServerResults[i].errors.push({ server: serverUrl, error: result.error })
|
|
221
|
-
log(`${info.filename}: Failed to upload to ${serverUrl}: ${result.error?.message ?? result.error}`)
|
|
255
|
+
log(`${info.filename}: Failed to upload to ${serverUrl} (${info.mimeType}, ${info.file.size} bytes): ${result.error?.message ?? result.error}`)
|
|
222
256
|
}
|
|
223
257
|
}
|
|
224
258
|
})
|
|
@@ -168,6 +168,12 @@ export async function throttledSendEvent (event, relays, {
|
|
|
168
168
|
}
|
|
169
169
|
const maybeSuccessfulRelays = relays.length - noRetryErrors.length
|
|
170
170
|
const hasReachedMaxRetries = retries > maxRetries
|
|
171
|
+
// Exhausted replication attempts cannot invalidate an already confirmed copy.
|
|
172
|
+
const confirmedRelays = relays.length - noRetryErrors.length - rateLimitErrors.length
|
|
173
|
+
if (hasReachedMaxRetries && confirmedRelays >= minSuccessfulRelays) {
|
|
174
|
+
log(`Replication retries exhausted; the required confirmations are already satisfied:\n${rateLimitErrors.map(formatRelayFailure).join('\n')}`)
|
|
175
|
+
return { pause }
|
|
176
|
+
}
|
|
171
177
|
if (
|
|
172
178
|
hasReachedMaxRetries ||
|
|
173
179
|
maybeSuccessfulRelays < minSuccessfulRelays
|