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