nappup 2.3.9 → 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 +2 -2
- package/src/config/napp-categories.js +1 -1
- package/src/errors.js +52 -13
- package/src/helpers/event.js +18 -0
- package/src/helpers/relay-error.js +25 -0
- package/src/index.js +3 -17
- package/src/services/blossom-upload.js +65 -31
- package/src/services/irfs-upload.js +27 -18
- package/src/services/site-manifest.js +5 -7
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
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"url": "git+https://github.com/44billion/nappup.git"
|
|
7
7
|
},
|
|
8
8
|
"license": "MIT",
|
|
9
|
-
"version": "2.3.
|
|
9
|
+
"version": "2.3.11",
|
|
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.
|
|
25
|
+
"libp2r2p": "^0.10.12",
|
|
26
26
|
"mime-types": "^3.0.1",
|
|
27
27
|
"nmmr": "^2.0.0"
|
|
28
28
|
},
|
|
@@ -18,6 +18,6 @@ export const NAPP_CATEGORIES = {
|
|
|
18
18
|
'other', 'podcast', 'music', 'video', 'news'
|
|
19
19
|
],
|
|
20
20
|
utilities: [
|
|
21
|
-
'other', 'weather', 'office', 'finances', 'learning', 'text editor', 'image editor', 'audio editor', 'video editor', 'ar', 'vr', 'ai'
|
|
21
|
+
'other', 'widget', 'weather', 'office', 'finances', 'learning', 'text editor', 'image editor', 'audio editor', 'video editor', 'ar', 'vr', 'ai'
|
|
22
22
|
]
|
|
23
23
|
}
|
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/helpers/event.js
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
// Groups copies of the same signed event while retaining every observed origin.
|
|
2
|
+
export function aggregateEventRelays (events) {
|
|
3
|
+
const byId = new Map()
|
|
4
|
+
for (const event of events) {
|
|
5
|
+
let entry = byId.get(event.id)
|
|
6
|
+
if (!entry) {
|
|
7
|
+
entry = { event: { ...event, meta: { relays: [] } }, relays: new Set() }
|
|
8
|
+
byId.set(event.id, entry)
|
|
9
|
+
}
|
|
10
|
+
const relay = event.meta?.relay
|
|
11
|
+
if (typeof relay === 'string' && relay && !entry.relays.has(relay)) {
|
|
12
|
+
entry.relays.add(relay)
|
|
13
|
+
entry.event.meta.relays.push(relay)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return [...byId.values()].map(entry => entry.event)
|
|
17
|
+
}
|
|
18
|
+
|
|
1
19
|
export function stringifyEvent (event) {
|
|
2
20
|
event = { ...event }
|
|
3
21
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const MAX_ERROR_DEPTH = 6
|
|
2
|
+
|
|
3
|
+
// Formats only diagnostic fields, retaining nested native errors without cycles.
|
|
4
|
+
function formatError (error, seen = new Set(), depth = 0) {
|
|
5
|
+
if (depth >= MAX_ERROR_DEPTH) return '[maximum error depth reached]'
|
|
6
|
+
if (error === null || typeof error !== 'object') return String(error || 'No error message provided')
|
|
7
|
+
if (seen.has(error)) return '[circular error reference]'
|
|
8
|
+
seen.add(error)
|
|
9
|
+
const fields = ['code', 'closeCode', 'closeReason', 'wasClean']
|
|
10
|
+
.filter(key => error[key] !== undefined)
|
|
11
|
+
.map(key => `${key}=${String(error[key])}`)
|
|
12
|
+
let text = `${error.name || 'Error'}: ${error.message || 'No error message provided'}`
|
|
13
|
+
if (fields.length) text += ` (${fields.join(', ')})`
|
|
14
|
+
if (error.cause !== undefined) text += `; cause: ${formatError(error.cause, seen, depth + 1)}`
|
|
15
|
+
if (Array.isArray(error.errors)) {
|
|
16
|
+
text += `; errors: [${error.errors.map(child => formatError(child, seen, depth + 1)).join('; ')}]`
|
|
17
|
+
}
|
|
18
|
+
seen.delete(error)
|
|
19
|
+
return text
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Keeps the destination relay separate from any event provenance metadata.
|
|
23
|
+
export function formatRelayFailure ({ relay, reason }) {
|
|
24
|
+
return `${relay} [${reason?.category || 'unclassified'}]: ${formatError(reason)}`
|
|
25
|
+
}
|
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
|
})
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import nostrRelays, { nappRelays, sendEventReport } from '#services/nostr-relays.js'
|
|
2
2
|
import NMMR from 'nmmr'
|
|
3
3
|
import { decode as base93Decode, encode as base93Encode } from 'libp2r2p/base93'
|
|
4
|
-
import {
|
|
4
|
+
import { aggregateEventRelays } from '#helpers/event.js'
|
|
5
|
+
import { formatRelayFailure } from '#helpers/relay-error.js'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Uploads binary data chunks for a file to Nostr relays using the InterRelay File System (IRFS).
|
|
@@ -37,7 +38,7 @@ export async function uploadBinaryDataChunks ({ nmmr, signer, filename, chunkLen
|
|
|
37
38
|
const validEvents = storedEvents.filter(event => isExpectedChunkEvent(event, { rootHash, index: chunk.index, total: chunk.total, dTag }))
|
|
38
39
|
.sort(compareEventsNewestFirst)
|
|
39
40
|
const foundEvent = validEvents[0]
|
|
40
|
-
const coveredRelays = new Set(
|
|
41
|
+
const coveredRelays = new Set(foundEvent?.meta.relays ?? [])
|
|
41
42
|
const missingRelays = relays.filter(relay => !coveredRelays.has(relay))
|
|
42
43
|
|
|
43
44
|
if (!shouldReupload && foundEvent) {
|
|
@@ -45,7 +46,7 @@ export async function uploadBinaryDataChunks ({ nmmr, signer, filename, chunkLen
|
|
|
45
46
|
log(`${filename}: Skipping chunk ${++chunkIndex} of ${chunkLength} (already uploaded)`)
|
|
46
47
|
continue
|
|
47
48
|
}
|
|
48
|
-
log(`${filename}: Re-uploading chunk ${++chunkIndex} of ${chunkLength} to ${missingRelays.length}
|
|
49
|
+
log(`${filename}: Re-uploading chunk ${++chunkIndex} of ${chunkLength} to ${missingRelays.length} relays without a confirmed copy (out of ${relays.length})`)
|
|
49
50
|
;({ pause } = (await throttledSendEvent(foundEvent, missingRelays, { pause, log, trailingPause: true, minSuccessfulRelays: 0 })))
|
|
50
51
|
continue
|
|
51
52
|
}
|
|
@@ -114,7 +115,7 @@ function isExpectedChunkEvent (event, expected) {
|
|
|
114
115
|
* Handles three error categories:
|
|
115
116
|
* - Rate-limit errors: retries with increasing pause (+2000ms per retry)
|
|
116
117
|
* - Timeout errors: one-time immediate retry
|
|
117
|
-
* -
|
|
118
|
+
* - Other errors: no further automatic retry in this operation; counted against the success threshold
|
|
118
119
|
*
|
|
119
120
|
* @param {object} event - Signed Nostr event to send
|
|
120
121
|
* @param {string[]} relays - Array of relay URLs
|
|
@@ -143,7 +144,7 @@ export async function throttledSendEvent (event, relays, {
|
|
|
143
144
|
return { pause }
|
|
144
145
|
}
|
|
145
146
|
|
|
146
|
-
const [rateLimitErrors,
|
|
147
|
+
const [rateLimitErrors, timeoutErrors, noRetryErrors] =
|
|
147
148
|
errors.reduce((r, v) => {
|
|
148
149
|
const message = v.reason?.message ?? ''
|
|
149
150
|
if (message.startsWith('rate-limited:')) r[0].push(v)
|
|
@@ -153,26 +154,34 @@ export async function throttledSendEvent (event, relays, {
|
|
|
153
154
|
}, [[], [], []])
|
|
154
155
|
|
|
155
156
|
// One-time special retry
|
|
156
|
-
if (
|
|
157
|
-
const timedOutRelays =
|
|
158
|
-
log(`${
|
|
157
|
+
if (timeoutErrors.length > 0) {
|
|
158
|
+
const timedOutRelays = timeoutErrors.map(v => v.relay)
|
|
159
|
+
log(`${timeoutErrors.length} timeout errors, retrying once after ${pause}ms:\n${timeoutErrors.map(formatRelayFailure).join('\n')}`)
|
|
159
160
|
if (pause) await new Promise(resolve => setTimeout(resolve, pause))
|
|
160
161
|
const { errors: timeoutRetryErrors } = await sendEventReport(event, timedOutRelays, { timeout: 15000, timeoutUntilFirstFulfillment: null })
|
|
161
|
-
|
|
162
|
+
noRetryErrors.push(...timeoutRetryErrors)
|
|
162
163
|
}
|
|
163
164
|
|
|
164
|
-
if (
|
|
165
|
-
log(`${
|
|
166
|
-
|
|
165
|
+
if (noRetryErrors.length > 0) {
|
|
166
|
+
log(`${noRetryErrors.length} failures with no further automatic retry in this operation:\n${noRetryErrors.map(formatRelayFailure).join('\n')}`)
|
|
167
|
+
log(`Event: id=${event.id ?? 'unknown'} kind=${event.kind ?? 'unknown'}`)
|
|
167
168
|
}
|
|
168
|
-
const maybeSuccessfulRelays = relays.length -
|
|
169
|
+
const maybeSuccessfulRelays = relays.length - noRetryErrors.length
|
|
169
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
|
+
}
|
|
170
177
|
if (
|
|
171
178
|
hasReachedMaxRetries ||
|
|
172
179
|
maybeSuccessfulRelays < minSuccessfulRelays
|
|
173
180
|
) {
|
|
174
|
-
const finalErrors = [...rateLimitErrors, ...
|
|
175
|
-
|
|
181
|
+
const finalErrors = [...rateLimitErrors, ...noRetryErrors]
|
|
182
|
+
const error = new AggregateError(finalErrors.map(item => item.reason), finalErrors.map(formatRelayFailure).join('\n'))
|
|
183
|
+
error.failures = finalErrors
|
|
184
|
+
throw error
|
|
176
185
|
}
|
|
177
186
|
|
|
178
187
|
if (rateLimitErrors.length === 0) {
|
|
@@ -185,7 +194,7 @@ export async function throttledSendEvent (event, relays, {
|
|
|
185
194
|
await new Promise(resolve => setTimeout(resolve, (pause += 2000)))
|
|
186
195
|
|
|
187
196
|
// Subtracts the successful publishes from the original minSuccessfulRelays goal
|
|
188
|
-
minSuccessfulRelays = Math.max(0, minSuccessfulRelays - (relays.length - erroedRelays.length -
|
|
197
|
+
minSuccessfulRelays = Math.max(0, minSuccessfulRelays - (relays.length - erroedRelays.length - noRetryErrors.length))
|
|
189
198
|
return await throttledSendEvent(event, erroedRelays, {
|
|
190
199
|
pause, log, retries: ++retries, maxRetries, minSuccessfulRelays, leadingPause: false, trailingPause
|
|
191
200
|
})
|
|
@@ -198,12 +207,12 @@ export async function getPreviousChunks (dTagValues, relays, signer) {
|
|
|
198
207
|
|
|
199
208
|
for (let offset = 0; offset < dTagValues.length; offset += 100) {
|
|
200
209
|
const batch = dTagValues.slice(offset, offset + 100)
|
|
201
|
-
const storedEvents = (await nostrRelays.getEvents({
|
|
210
|
+
const storedEvents = aggregateEventRelays((await nostrRelays.getEvents({
|
|
202
211
|
kinds: [34601],
|
|
203
212
|
authors: [pubkey],
|
|
204
213
|
'#d': batch,
|
|
205
214
|
limit: batch.length
|
|
206
|
-
}, targetRelays, { timeoutAfterFirstEose: null })).result
|
|
215
|
+
}, targetRelays, { timeoutAfterFirstEose: null, deduplicateAcrossRelays: false })).result)
|
|
207
216
|
|
|
208
217
|
for (const event of storedEvents) {
|
|
209
218
|
const dTag = event.tags?.find(tag => tag[0] === 'd')?.[1]
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { aggregateEventRelays } from '#helpers/event.js'
|
|
1
2
|
import { NAPP_CATEGORIES } from '#config/napp-categories.js'
|
|
2
3
|
import nostrRelays, { nappRelays } from '#services/nostr-relays.js'
|
|
3
4
|
import { throttledSendEvent } from '#services/irfs-upload.js'
|
|
@@ -249,12 +250,12 @@ export async function uploadSiteManifest ({
|
|
|
249
250
|
const kind = manifestKind(channel)
|
|
250
251
|
const relays = [...new Set([...(await signer.getRelays()).write, ...nappRelays]
|
|
251
252
|
.map(relay => relay.trim().replace(/\/$/, '')))]
|
|
252
|
-
const events = (await nostrRelays.getEvents({
|
|
253
|
+
const events = aggregateEventRelays((await nostrRelays.getEvents({
|
|
253
254
|
kinds: [kind],
|
|
254
255
|
authors: [await signer.getPublicKey()],
|
|
255
256
|
'#d': [dTag],
|
|
256
257
|
limit: 1
|
|
257
|
-
}, relays, { timeoutAfterFirstEose: null })).result
|
|
258
|
+
}, relays, { timeoutAfterFirstEose: null, deduplicateAcrossRelays: false })).result)
|
|
258
259
|
events.sort(newestFirst)
|
|
259
260
|
const previous = events[0]
|
|
260
261
|
|
|
@@ -288,13 +289,10 @@ export async function uploadSiteManifest ({
|
|
|
288
289
|
})
|
|
289
290
|
|
|
290
291
|
if (!shouldReupload && previous && previous.content === '' && JSON.stringify(previous.tags) === JSON.stringify(tags)) {
|
|
291
|
-
const coveredRelays = new Set(
|
|
292
|
-
.filter(event => event.id === previous.id)
|
|
293
|
-
.map(event => event.meta?.relay)
|
|
294
|
-
.filter(Boolean))
|
|
292
|
+
const coveredRelays = new Set(previous.meta.relays)
|
|
295
293
|
const missingRelays = relays.filter(relay => !coveredRelays.has(relay))
|
|
296
294
|
if (!missingRelays.length) return previous
|
|
297
|
-
log(`Re-uploading existing site manifest to ${missingRelays.length}
|
|
295
|
+
log(`Re-uploading existing site manifest to ${missingRelays.length} relays without a confirmed copy`)
|
|
298
296
|
await throttledSendEvent(previous, missingRelays, {
|
|
299
297
|
pause, trailingPause: true, log, minSuccessfulRelays: 0
|
|
300
298
|
})
|