nappup 1.7.2 → 1.8.0
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/bin/nappup/helpers.js +0 -5
- package/bin/nappup/index.js +20 -2
- package/package.json +1 -1
- package/src/helpers/app.js +5 -37
- package/src/helpers/nip19.js +3 -3
- package/src/index.js +276 -171
package/bin/nappup/helpers.js
CHANGED
|
@@ -9,7 +9,6 @@ export function parseArgs (args) {
|
|
|
9
9
|
let dir = null
|
|
10
10
|
let sk = null
|
|
11
11
|
let dTag = null
|
|
12
|
-
let dTagRaw = null
|
|
13
12
|
let channel = null
|
|
14
13
|
let shouldReupload = false
|
|
15
14
|
let yes = false
|
|
@@ -21,9 +20,6 @@ export function parseArgs (args) {
|
|
|
21
20
|
} else if (args[i] === '-d' && args[i + 1]) {
|
|
22
21
|
dTag = args[i + 1]
|
|
23
22
|
i++
|
|
24
|
-
} else if (args[i] === '-D' && args[i + 1]) {
|
|
25
|
-
dTagRaw = args[i + 1]
|
|
26
|
-
i++
|
|
27
23
|
} else if (args[i] === '--main' && channel === null) {
|
|
28
24
|
channel = 'main'
|
|
29
25
|
} else if (args[i] === '--next' && channel === null) {
|
|
@@ -43,7 +39,6 @@ export function parseArgs (args) {
|
|
|
43
39
|
dir: path.resolve(dir ?? '.'),
|
|
44
40
|
sk,
|
|
45
41
|
dTag,
|
|
46
|
-
dTagRaw,
|
|
47
42
|
channel: channel || 'main',
|
|
48
43
|
shouldReupload,
|
|
49
44
|
yes
|
package/bin/nappup/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path'
|
|
2
3
|
import NostrSigner from '#services/nostr-signer.js'
|
|
4
|
+
import { GENERIC_BUILD_FOLDER_NAMES } from '#helpers/app.js'
|
|
3
5
|
import {
|
|
4
6
|
parseArgs,
|
|
5
7
|
confirmArgs,
|
|
@@ -11,7 +13,23 @@ import toApp from '#index.js'
|
|
|
11
13
|
const args = parseArgs(process.argv.slice(2))
|
|
12
14
|
await confirmArgs(args)
|
|
13
15
|
|
|
14
|
-
const { dir, sk,
|
|
16
|
+
const { dir, sk, channel, shouldReupload } = args
|
|
17
|
+
let { dTag } = args
|
|
18
|
+
|
|
19
|
+
if (!dTag) {
|
|
20
|
+
let folderName = path.basename(dir)
|
|
21
|
+
if (GENERIC_BUILD_FOLDER_NAMES.has(folderName.toLowerCase())) {
|
|
22
|
+
const parentName = path.basename(path.dirname(dir))
|
|
23
|
+
if (parentName && parentName !== '.' && parentName !== '/' && !GENERIC_BUILD_FOLDER_NAMES.has(parentName.toLowerCase())) {
|
|
24
|
+
folderName = parentName
|
|
25
|
+
} else {
|
|
26
|
+
console.error(`Directory name "${folderName}" is a generic build folder. Please provide a d tag with -d.`)
|
|
27
|
+
process.exit(1)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
dTag = folderName
|
|
31
|
+
}
|
|
32
|
+
|
|
15
33
|
const fileList = await toFileList(getFiles(dir), dir)
|
|
16
34
|
|
|
17
|
-
await toApp(fileList, await NostrSigner.create(sk), { log: console.log.bind(console), dTag,
|
|
35
|
+
await toApp(fileList, await NostrSigner.create(sk), { log: console.log.bind(console), dTag, channel, shouldReupload })
|
package/package.json
CHANGED
package/src/helpers/app.js
CHANGED
|
@@ -1,41 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
export const NOSTR_APP_D_TAG_MAX_LENGTH = 260
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export const NOSTR_APP_D_TAG_MAX_LENGTH = 7
|
|
3
|
+
export const GENERIC_BUILD_FOLDER_NAMES = new Set([
|
|
4
|
+
'build', 'dist', 'out', 'output', 'public', 'www', '_site', '.next', '.output', '.nuxt'
|
|
5
|
+
])
|
|
7
6
|
|
|
8
7
|
export function isNostrAppDTagSafe (string) {
|
|
9
|
-
return string
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function isSubdomainSafe (string) {
|
|
13
|
-
return /(?:^[a-z0-9]$)|(?:^(?!.*--)[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$)/.test(string)
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function deriveNostrAppDTag (string) {
|
|
17
|
-
return toSubdomainSafe(string, NOSTR_APP_D_TAG_MAX_LENGTH)
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
async function toSubdomainSafe (string, maxStringLength) {
|
|
21
|
-
const byteLength = baseMaxLengthToMaxSourceByteLength(maxStringLength, 36)
|
|
22
|
-
const bytes = (await toSha1(string)).slice(0, byteLength)
|
|
23
|
-
return bytesToBase36(bytes, maxStringLength)
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async function toSha1 (string) {
|
|
27
|
-
const bytes = new TextEncoder().encode(string)
|
|
28
|
-
return new Uint8Array(await crypto.subtle.digest('SHA-1', bytes))
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// baseMaxLengthToMaxSourceByteLength(19, 62) === 14 byte length
|
|
32
|
-
// baseMaxLengthToMaxSourceByteLength(7, 36) === 4 byte length
|
|
33
|
-
function baseMaxLengthToMaxSourceByteLength (maxStringLength, base) {
|
|
34
|
-
if (!base) throw new Error('Which base?')
|
|
35
|
-
const baseLog = Math.log(base)
|
|
36
|
-
const log256 = Math.log(256)
|
|
37
|
-
|
|
38
|
-
const maxByteLength = (maxStringLength * baseLog) / log256
|
|
39
|
-
|
|
40
|
-
return Math.floor(maxByteLength)
|
|
8
|
+
return typeof string === 'string' && string.length <= NOSTR_APP_D_TAG_MAX_LENGTH
|
|
41
9
|
}
|
package/src/helpers/nip19.js
CHANGED
|
@@ -9,9 +9,9 @@ const textEncoder = new TextEncoder()
|
|
|
9
9
|
const textDecoder = new TextDecoder()
|
|
10
10
|
|
|
11
11
|
const kindByChannel = {
|
|
12
|
-
main:
|
|
13
|
-
next:
|
|
14
|
-
draft:
|
|
12
|
+
main: 35128,
|
|
13
|
+
next: 35129,
|
|
14
|
+
draft: 35130
|
|
15
15
|
}
|
|
16
16
|
const channelByKind = Object.fromEntries(
|
|
17
17
|
Object.entries(kindByChannel).map(([k, v]) => [v, k])
|
package/src/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { appEncode } from '#helpers/nip19.js'
|
|
|
3
3
|
import nostrRelays, { nappRelays } from '#services/nostr-relays.js'
|
|
4
4
|
import { getRelays } from '#helpers/signer.js'
|
|
5
5
|
import { streamToChunks, streamToText } from '#helpers/stream.js'
|
|
6
|
-
import { isNostrAppDTagSafe,
|
|
6
|
+
import { isNostrAppDTagSafe, GENERIC_BUILD_FOLDER_NAMES } from '#helpers/app.js'
|
|
7
7
|
import { extractHtmlMetadata, findFavicon, findIndexFile } from '#helpers/app-metadata.js'
|
|
8
8
|
import { NAPP_CATEGORIES } from '#config/napp-categories.js'
|
|
9
9
|
import { getBlossomServers, healthCheckServers, uploadFilesToBlossom } from '#services/blossom-upload.js'
|
|
@@ -14,14 +14,14 @@ import { uploadBinaryDataChunks, throttledSendEvent } from '#services/irfs-uploa
|
|
|
14
14
|
// await publishApp(
|
|
15
15
|
// fileList,
|
|
16
16
|
// window.nostr,
|
|
17
|
-
// {
|
|
17
|
+
// { dTag: 'My app identifier unique to this nsec', onEvent: ({ progress }) => console.log(progress) }
|
|
18
18
|
// )
|
|
19
19
|
//
|
|
20
20
|
// Simple usage -> onEvent: ({ progress, error }) => { if (error) { throw error } else { progressBar.style.width = `${progress}%` } }
|
|
21
21
|
// Geek usage ->
|
|
22
22
|
// onEvent: (event) => {
|
|
23
23
|
// if (event.type === 'file-uploaded') console.log(`Uploaded ${event.filename} via ${event.service}`)
|
|
24
|
-
// if (event.type === 'complete') console.log(`Done! Access at https://44billion.net/${event.
|
|
24
|
+
// if (event.type === 'complete') console.log(`Done! Access at https://44billion.net/${event.napp}`)
|
|
25
25
|
// if (event.type === 'error') console.error('Error during publishing:', event.error)
|
|
26
26
|
// }
|
|
27
27
|
//
|
|
@@ -47,25 +47,25 @@ export default async function (fileList, nostrSigner, opts = {}) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* Publishes
|
|
50
|
+
* Publishes a site to Nostr relays and/or blossom servers.
|
|
51
51
|
*
|
|
52
52
|
* The optional `onEvent` callback receives structured progress events.
|
|
53
53
|
* Every event has `type` (string) and `progress` (0–100 integer).
|
|
54
54
|
*
|
|
55
55
|
* Event types:
|
|
56
|
-
* 'init'
|
|
57
|
-
* '
|
|
58
|
-
* 'file-uploaded'
|
|
59
|
-
* '
|
|
60
|
-
* '
|
|
61
|
-
* 'complete'
|
|
62
|
-
* 'error'
|
|
56
|
+
* 'init' — { totalFiles, totalSteps, dTag, relayCount, blossomCount }
|
|
57
|
+
* 'media-uploaded' — { mediaType: 'icon'|'key_art'|'screenshot', service: 'blossom'|'irfs'|null }
|
|
58
|
+
* 'file-uploaded' — { filename, service: 'blossom'|'irfs' }
|
|
59
|
+
* 'listing-published' — app listing metadata published
|
|
60
|
+
* 'manifest-published' — site manifest published
|
|
61
|
+
* 'complete' — { napp } (terminal, progress === 100)
|
|
62
|
+
* 'error' — { error } (terminal, error is rethrown)
|
|
63
63
|
*
|
|
64
64
|
* Terminal events ('complete' or 'error') signal that no more events will follow.
|
|
65
65
|
* The 'error' event is only emitted when using the default export wrapper.
|
|
66
66
|
* Direct `toApp` callers receive the thrown error via normal async/await.
|
|
67
67
|
*/
|
|
68
|
-
export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent = () => {}, dTag,
|
|
68
|
+
export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent = () => {}, dTag, channel = 'main', shouldReupload = false } = {}) {
|
|
69
69
|
let _steps = 0
|
|
70
70
|
let _totalSteps = 1
|
|
71
71
|
const emit = (event) => { try { onEvent({ ...event, progress: event.type === 'complete' ? 100 : Math.round((_steps / _totalSteps) * 100) }) } catch (_) {} }
|
|
@@ -79,12 +79,15 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent =
|
|
|
79
79
|
if (writeRelays.length === 0) throw new Error('No outbox relays found')
|
|
80
80
|
|
|
81
81
|
if (typeof dTag === 'string') {
|
|
82
|
-
if (!isNostrAppDTagSafe(dTag)) throw new Error('dTag
|
|
82
|
+
if (!isNostrAppDTagSafe(dTag)) throw new Error('dTag must be a non-empty string with at most 260 characters')
|
|
83
83
|
} else {
|
|
84
|
-
|
|
85
|
-
if (
|
|
84
|
+
const folderName = fileList[0].webkitRelativePath.split('/')[0].trim()
|
|
85
|
+
if (GENERIC_BUILD_FOLDER_NAMES.has(folderName.toLowerCase())) {
|
|
86
|
+
throw new Error(`Folder name "${folderName}" is a generic build folder. Please provide a d tag with the -d flag.`)
|
|
87
|
+
}
|
|
88
|
+
dTag = folderName
|
|
89
|
+
if (!isNostrAppDTagSafe(dTag)) throw new Error('Could not derive a valid d tag from the folder name. Please provide one with the -d flag.')
|
|
86
90
|
}
|
|
87
|
-
let nmmr
|
|
88
91
|
const fileMetadata = []
|
|
89
92
|
|
|
90
93
|
// Check for .well-known/napp.json
|
|
@@ -101,15 +104,15 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent =
|
|
|
101
104
|
}
|
|
102
105
|
|
|
103
106
|
const indexFile = findIndexFile(fileList)
|
|
104
|
-
let
|
|
105
|
-
let
|
|
107
|
+
let listingName = nappJson.name?.[0]?.[0]
|
|
108
|
+
let listingSummary = nappJson.summary?.[0]?.[0]
|
|
106
109
|
|
|
107
|
-
if (indexFile && (!
|
|
110
|
+
if (indexFile && (!listingName || !listingSummary)) {
|
|
108
111
|
try {
|
|
109
112
|
const htmlContent = await streamToText(indexFile.stream())
|
|
110
113
|
const { name, description } = extractHtmlMetadata(htmlContent)
|
|
111
|
-
if (!
|
|
112
|
-
if (!
|
|
114
|
+
if (!listingName) listingName = name
|
|
115
|
+
if (!listingSummary) listingSummary = description
|
|
113
116
|
} catch (err) {
|
|
114
117
|
log('Error extracting HTML metadata:', err)
|
|
115
118
|
}
|
|
@@ -131,77 +134,97 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent =
|
|
|
131
134
|
log('No blossom servers configured, will use relay-based file upload (irfs)')
|
|
132
135
|
}
|
|
133
136
|
|
|
134
|
-
const
|
|
137
|
+
const uploadService = healthyBlossomServers.length > 0 ? 'blossom' : 'irfs'
|
|
138
|
+
|
|
139
|
+
// Helper: upload a data URL to the chosen service, returns { rootHash, mimeType }
|
|
140
|
+
const uploadMediaFromDataUrl = async (dataUrl, mediaName) => {
|
|
141
|
+
const res = await fetch(dataUrl)
|
|
142
|
+
const blob = await res.blob()
|
|
143
|
+
const mimeType = blob.type
|
|
144
|
+
const extension = mimeType.split('/')[1] || 'bin'
|
|
145
|
+
const filename = `${mediaName}.${extension}`
|
|
146
|
+
|
|
147
|
+
if (uploadService === 'blossom') {
|
|
148
|
+
const { uploadedFiles, failedFiles } = await uploadFilesToBlossom({
|
|
149
|
+
fileList: [Object.assign(blob, { webkitRelativePath: `_/${filename}` })],
|
|
150
|
+
servers: healthyBlossomServers,
|
|
151
|
+
signer: nostrSigner,
|
|
152
|
+
shouldReupload,
|
|
153
|
+
log
|
|
154
|
+
})
|
|
155
|
+
if (failedFiles.length > 0) throw new Error(`Blossom upload failed for ${mediaName}`)
|
|
156
|
+
return { rootHash: uploadedFiles[0].sha256, mimeType }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const nmmr = new NMMR()
|
|
160
|
+
const stream = blob.stream()
|
|
161
|
+
let chunkLength = 0
|
|
162
|
+
for await (const chunk of streamToChunks(stream, 51000)) {
|
|
163
|
+
chunkLength++
|
|
164
|
+
await nmmr.append(chunk)
|
|
165
|
+
}
|
|
166
|
+
if (!chunkLength) return null
|
|
167
|
+
;({ pause } = (await uploadBinaryDataChunks({ nmmr, signer: nostrSigner, filename, chunkLength, log, pause, mimeType, shouldReupload })))
|
|
168
|
+
return { rootHash: nmmr.getRoot(), mimeType }
|
|
169
|
+
}
|
|
135
170
|
|
|
136
|
-
|
|
137
|
-
|
|
171
|
+
// Count media uploads for progress tracking
|
|
172
|
+
const hasIconUpload = Boolean(nappJson.icon?.[0]?.[0])
|
|
173
|
+
const keyArtEntries = nappJson.keyArt || []
|
|
174
|
+
const screenshotEntries = nappJson.screenshot || []
|
|
175
|
+
const mediaUploadCount = (hasIconUpload ? 1 : 0) + keyArtEntries.length + screenshotEntries.length
|
|
176
|
+
_totalSteps = fileList.length + mediaUploadCount + 2
|
|
138
177
|
emit({ type: 'init', totalFiles: fileList.length, totalSteps: _totalSteps, dTag, relayCount: writeRelays.length, blossomCount: healthyBlossomServers.length })
|
|
139
178
|
|
|
140
179
|
// Upload icon from napp.json if present
|
|
141
|
-
if (nappJson.
|
|
180
|
+
if (nappJson.icon?.[0]?.[0]) {
|
|
142
181
|
try {
|
|
143
|
-
const dataUrl = nappJson.stallIcon[0][0]
|
|
144
|
-
const res = await fetch(dataUrl)
|
|
145
|
-
const blob = await res.blob()
|
|
146
|
-
const mimeType = blob.type
|
|
147
|
-
const extension = mimeType.split('/')[1] || 'bin'
|
|
148
|
-
const filename = `icon.${extension}`
|
|
149
|
-
|
|
150
182
|
log('Uploading icon from napp.json')
|
|
151
|
-
|
|
152
|
-
if (useBlossom) {
|
|
153
|
-
const { uploadedFiles, failedFiles } = await uploadFilesToBlossom({
|
|
154
|
-
fileList: [Object.assign(blob, { webkitRelativePath: `_/${filename}` })],
|
|
155
|
-
servers: healthyBlossomServers,
|
|
156
|
-
signer: nostrSigner,
|
|
157
|
-
shouldReupload,
|
|
158
|
-
log
|
|
159
|
-
})
|
|
160
|
-
if (uploadedFiles.length > 0) {
|
|
161
|
-
iconMetadata = {
|
|
162
|
-
rootHash: uploadedFiles[0].sha256,
|
|
163
|
-
mimeType,
|
|
164
|
-
service: 'b' // blossom
|
|
165
|
-
}
|
|
166
|
-
} else if (failedFiles.length > 0) {
|
|
167
|
-
log('Blossom icon upload failed, falling back to relay upload')
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
if (!iconMetadata) {
|
|
172
|
-
nmmr = new NMMR()
|
|
173
|
-
const stream = blob.stream()
|
|
174
|
-
let chunkLength = 0
|
|
175
|
-
for await (const chunk of streamToChunks(stream, 51000)) {
|
|
176
|
-
chunkLength++
|
|
177
|
-
await nmmr.append(chunk)
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
if (chunkLength) {
|
|
181
|
-
;({ pause } = (await uploadBinaryDataChunks({ nmmr, signer: nostrSigner, filename, chunkLength, log, pause, mimeType, shouldReupload })))
|
|
182
|
-
iconMetadata = {
|
|
183
|
-
rootHash: nmmr.getRoot(),
|
|
184
|
-
mimeType,
|
|
185
|
-
service: 'i' // relay (irfs)
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
183
|
+
iconMetadata = await uploadMediaFromDataUrl(nappJson.icon[0][0], 'icon')
|
|
189
184
|
} catch (e) {
|
|
190
185
|
log('Failed to upload icon from napp.json', e)
|
|
191
186
|
}
|
|
187
|
+
_steps++
|
|
188
|
+
emit({ type: 'media-uploaded', mediaType: 'icon', service: iconMetadata ? uploadService : null })
|
|
192
189
|
}
|
|
193
190
|
|
|
194
|
-
|
|
191
|
+
// Upload key art from napp.json
|
|
192
|
+
const keyArtMetadata = []
|
|
193
|
+
for (const entry of keyArtEntries) {
|
|
194
|
+
const dataUrl = entry[0]
|
|
195
|
+
const country = entry[1]
|
|
196
|
+
if (!dataUrl) { _steps++; emit({ type: 'media-uploaded', mediaType: 'key_art', service: null }); continue }
|
|
197
|
+
try {
|
|
198
|
+
log(`Uploading key art from napp.json${country ? ` (${country})` : ''}`)
|
|
199
|
+
const uploaded = await uploadMediaFromDataUrl(dataUrl, 'key_art')
|
|
200
|
+
if (uploaded) keyArtMetadata.push({ ...uploaded, country })
|
|
201
|
+
} catch (e) {
|
|
202
|
+
log('Failed to upload key art from napp.json', e)
|
|
203
|
+
}
|
|
195
204
|
_steps++
|
|
196
|
-
emit({ type: '
|
|
205
|
+
emit({ type: 'media-uploaded', mediaType: 'key_art', service: keyArtMetadata.length > 0 ? uploadService : null })
|
|
197
206
|
}
|
|
198
207
|
|
|
199
|
-
|
|
208
|
+
// Upload screenshots from napp.json
|
|
209
|
+
const screenshotMetadata = []
|
|
210
|
+
for (const entry of screenshotEntries) {
|
|
211
|
+
const dataUrl = entry[0]
|
|
212
|
+
const country = entry[1]
|
|
213
|
+
if (!dataUrl) { _steps++; emit({ type: 'media-uploaded', mediaType: 'screenshot', service: null }); continue }
|
|
214
|
+
try {
|
|
215
|
+
log(`Uploading screenshot from napp.json${country ? ` (${country})` : ''}`)
|
|
216
|
+
const uploaded = await uploadMediaFromDataUrl(dataUrl, 'screenshot')
|
|
217
|
+
if (uploaded) screenshotMetadata.push({ ...uploaded, country })
|
|
218
|
+
} catch (e) {
|
|
219
|
+
log('Failed to upload screenshot from napp.json', e)
|
|
220
|
+
}
|
|
221
|
+
_steps++
|
|
222
|
+
emit({ type: 'media-uploaded', mediaType: 'screenshot', service: screenshotMetadata.length > 0 ? uploadService : null })
|
|
223
|
+
}
|
|
200
224
|
|
|
201
|
-
|
|
202
|
-
let irfsFileList = fileList
|
|
225
|
+
log(`Processing ${fileList.length} files`)
|
|
203
226
|
|
|
204
|
-
if (
|
|
227
|
+
if (uploadService === 'blossom') {
|
|
205
228
|
const { uploadedFiles, failedFiles } = await uploadFilesToBlossom({
|
|
206
229
|
fileList,
|
|
207
230
|
servers: healthyBlossomServers,
|
|
@@ -210,80 +233,77 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent =
|
|
|
210
233
|
log
|
|
211
234
|
})
|
|
212
235
|
|
|
236
|
+
if (failedFiles.length > 0) {
|
|
237
|
+
throw new Error(`${failedFiles.length} file(s) failed to upload to blossom`)
|
|
238
|
+
}
|
|
239
|
+
|
|
213
240
|
for (const uploaded of uploadedFiles) {
|
|
214
241
|
fileMetadata.push({
|
|
215
242
|
rootHash: uploaded.sha256,
|
|
216
243
|
filename: uploaded.filename,
|
|
217
|
-
mimeType: uploaded.mimeType
|
|
218
|
-
service: 'b'
|
|
244
|
+
mimeType: uploaded.mimeType
|
|
219
245
|
})
|
|
220
246
|
|
|
221
247
|
if (faviconFile && uploaded.file === faviconFile) {
|
|
222
248
|
iconMetadata = {
|
|
223
249
|
rootHash: uploaded.sha256,
|
|
224
|
-
mimeType: uploaded.mimeType
|
|
225
|
-
service: 'b'
|
|
250
|
+
mimeType: uploaded.mimeType
|
|
226
251
|
}
|
|
227
252
|
}
|
|
228
253
|
|
|
229
254
|
_steps++
|
|
230
255
|
emit({ type: 'file-uploaded', filename: uploaded.filename, service: 'blossom' })
|
|
231
256
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
await nmmr.append(chunk)
|
|
249
|
-
}
|
|
250
|
-
if (chunkLength) {
|
|
251
|
-
// remove root dir
|
|
252
|
-
const filename = file.webkitRelativePath.split('/').slice(1).join('/')
|
|
253
|
-
log(`Uploading ${chunkLength} file parts of ${filename}`)
|
|
254
|
-
;({ pause } = (await uploadBinaryDataChunks({ nmmr, signer: nostrSigner, filename, chunkLength, log, pause, mimeType: file.type || 'application/octet-stream', shouldReupload })))
|
|
255
|
-
fileMetadata.push({
|
|
256
|
-
rootHash: nmmr.getRoot(),
|
|
257
|
-
filename,
|
|
258
|
-
mimeType: file.type || 'application/octet-stream',
|
|
259
|
-
service: 'i'
|
|
260
|
-
})
|
|
261
|
-
|
|
262
|
-
if (faviconFile && file === faviconFile) {
|
|
263
|
-
iconMetadata = {
|
|
257
|
+
} else {
|
|
258
|
+
for (const file of fileList) {
|
|
259
|
+
const nmmr = new NMMR()
|
|
260
|
+
const stream = file.stream()
|
|
261
|
+
|
|
262
|
+
let chunkLength = 0
|
|
263
|
+
for await (const chunk of streamToChunks(stream, 51000)) {
|
|
264
|
+
chunkLength++
|
|
265
|
+
await nmmr.append(chunk)
|
|
266
|
+
}
|
|
267
|
+
if (chunkLength) {
|
|
268
|
+
// remove root dir
|
|
269
|
+
const filename = file.webkitRelativePath.split('/').slice(1).join('/')
|
|
270
|
+
log(`Uploading ${chunkLength} file parts of ${filename}`)
|
|
271
|
+
;({ pause } = (await uploadBinaryDataChunks({ nmmr, signer: nostrSigner, filename, chunkLength, log, pause, mimeType: file.type || 'application/octet-stream', shouldReupload })))
|
|
272
|
+
fileMetadata.push({
|
|
264
273
|
rootHash: nmmr.getRoot(),
|
|
265
|
-
|
|
266
|
-
|
|
274
|
+
filename,
|
|
275
|
+
mimeType: file.type || 'application/octet-stream'
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
if (faviconFile && file === faviconFile) {
|
|
279
|
+
iconMetadata = {
|
|
280
|
+
rootHash: nmmr.getRoot(),
|
|
281
|
+
mimeType: file.type || 'application/octet-stream'
|
|
282
|
+
}
|
|
267
283
|
}
|
|
268
|
-
}
|
|
269
284
|
|
|
270
|
-
|
|
271
|
-
|
|
285
|
+
_steps++
|
|
286
|
+
emit({ type: 'file-uploaded', filename, service: 'irfs' })
|
|
287
|
+
}
|
|
272
288
|
}
|
|
273
289
|
}
|
|
274
290
|
|
|
275
|
-
log(`Uploading
|
|
276
|
-
;({ pause } = (await
|
|
291
|
+
log(`Uploading app listing event for ${dTag}`)
|
|
292
|
+
;({ pause } = (await maybeUploadAppListing({
|
|
277
293
|
dTag,
|
|
278
294
|
channel,
|
|
279
|
-
name:
|
|
280
|
-
nameLang: nappJson.
|
|
281
|
-
isNameAuto: !nappJson.
|
|
282
|
-
summary:
|
|
283
|
-
summaryLang: nappJson.
|
|
284
|
-
isSummaryAuto: !nappJson.
|
|
295
|
+
name: listingName,
|
|
296
|
+
nameLang: nappJson.name?.[0]?.[1],
|
|
297
|
+
isNameAuto: !nappJson.name?.[0]?.[0],
|
|
298
|
+
summary: listingSummary,
|
|
299
|
+
summaryLang: nappJson.summary?.[0]?.[1],
|
|
300
|
+
isSummaryAuto: !nappJson.summary?.[0]?.[0],
|
|
285
301
|
icon: iconMetadata,
|
|
286
|
-
isIconAuto: !nappJson.
|
|
302
|
+
isIconAuto: !nappJson.icon?.[0]?.[0],
|
|
303
|
+
descriptions: nappJson.description,
|
|
304
|
+
keyArt: keyArtMetadata,
|
|
305
|
+
screenshots: screenshotMetadata,
|
|
306
|
+
uploadService,
|
|
287
307
|
signer: nostrSigner,
|
|
288
308
|
writeRelays,
|
|
289
309
|
log,
|
|
@@ -295,35 +315,36 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, onEvent =
|
|
|
295
315
|
hashtags: nappJson.hashtag
|
|
296
316
|
})))
|
|
297
317
|
_steps++
|
|
298
|
-
emit({ type: '
|
|
318
|
+
emit({ type: 'listing-published' })
|
|
299
319
|
|
|
300
|
-
log(`Uploading
|
|
301
|
-
const
|
|
320
|
+
log(`Uploading site manifest ${dTag}`)
|
|
321
|
+
const manifest = await uploadSiteManifest({ dTag, channel, fileMetadata, uploadService, signer: nostrSigner, pause, shouldReupload, log })
|
|
302
322
|
|
|
303
323
|
const appEntity = appEncode({
|
|
304
|
-
dTag:
|
|
305
|
-
pubkey:
|
|
324
|
+
dTag: manifest.tags.find(v => v[0] === 'd')[1],
|
|
325
|
+
pubkey: manifest.pubkey,
|
|
306
326
|
relays: [],
|
|
307
|
-
kind:
|
|
327
|
+
kind: manifest.kind
|
|
308
328
|
})
|
|
309
329
|
_steps++
|
|
310
|
-
emit({ type: '
|
|
330
|
+
emit({ type: 'manifest-published' })
|
|
311
331
|
|
|
312
332
|
log(`Visit at https://44billion.net/${appEntity}`)
|
|
313
333
|
emit({ type: 'complete', napp: appEntity })
|
|
314
334
|
}
|
|
315
335
|
|
|
316
|
-
async function
|
|
336
|
+
async function uploadSiteManifest ({ dTag, channel, fileMetadata, uploadService, signer, pause = 0, shouldReupload = false, log = () => {} }) {
|
|
317
337
|
const kind = {
|
|
318
|
-
main:
|
|
319
|
-
next:
|
|
320
|
-
draft:
|
|
321
|
-
}[channel] ??
|
|
338
|
+
main: 35128, // stable
|
|
339
|
+
next: 35129, // insider
|
|
340
|
+
draft: 35130 // vibe coded preview
|
|
341
|
+
}[channel] ?? 35128
|
|
322
342
|
|
|
323
|
-
const
|
|
343
|
+
const pathTags = fileMetadata.map(v => ['path', v.rootHash, v.filename, v.mimeType])
|
|
324
344
|
const tags = [
|
|
325
345
|
['d', dTag],
|
|
326
|
-
...
|
|
346
|
+
...pathTags,
|
|
347
|
+
['service', uploadService]
|
|
327
348
|
]
|
|
328
349
|
|
|
329
350
|
const writeRelays = [...new Set([...(await signer.getRelays()).write, ...nappRelays].map(r => r.trim().replace(/\/$/, '')))]
|
|
@@ -347,21 +368,24 @@ async function uploadBundle ({ dTag, channel, fileMetadata, signer, pause = 0, s
|
|
|
347
368
|
}
|
|
348
369
|
|
|
349
370
|
if (!shouldReupload && mostRecentEvent) {
|
|
350
|
-
const
|
|
351
|
-
.filter(t => t[0] === '
|
|
371
|
+
const recentPathTags = mostRecentEvent.tags
|
|
372
|
+
.filter(t => t[0] === 'path' && t[2] !== '.well-known/napp.json')
|
|
352
373
|
.sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
|
|
353
374
|
|
|
354
|
-
const
|
|
375
|
+
const currentPathTags = pathTags
|
|
355
376
|
.filter(t => t[2] !== '.well-known/napp.json')
|
|
356
377
|
.sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
|
|
357
378
|
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
379
|
+
const recentServiceTag = mostRecentEvent.tags.find(t => t[0] === 'service')
|
|
380
|
+
const serviceChanged = recentServiceTag?.[1] !== uploadService
|
|
381
|
+
|
|
382
|
+
const isSame = !serviceChanged && currentPathTags.length === recentPathTags.length && currentPathTags.every((t, i) => {
|
|
383
|
+
const rt = recentPathTags[i]
|
|
384
|
+
return rt.length >= 4 && rt[1] === t[1] && rt[2] === t[2] && rt[3] === t[3]
|
|
361
385
|
})
|
|
362
386
|
|
|
363
387
|
if (isSame) {
|
|
364
|
-
log(`
|
|
388
|
+
log(`Site manifest based on ${pathTags.length} files is up-to-date (id: ${mostRecentEvent.id} - created_at: ${new Date(mostRecentEvent.created_at * 1000).toISOString()})`)
|
|
365
389
|
|
|
366
390
|
const matchingEvents = events.filter(e => e.id === mostRecentEvent.id)
|
|
367
391
|
const coveredRelays = new Set(matchingEvents.map(e => e.meta?.relay).filter(Boolean))
|
|
@@ -369,9 +393,7 @@ async function uploadBundle ({ dTag, channel, fileMetadata, signer, pause = 0, s
|
|
|
369
393
|
|
|
370
394
|
if (missingRelays.length === 0) return mostRecentEvent
|
|
371
395
|
|
|
372
|
-
|
|
373
|
-
// so we re-upload to all relays to ensure consistency
|
|
374
|
-
log(`Re-uploading existing bundle event to ${missingRelays.length} missing relays (out of ${writeRelays.length})`)
|
|
396
|
+
log(`Re-uploading existing site manifest event to ${missingRelays.length} missing relays (out of ${writeRelays.length})`)
|
|
375
397
|
await throttledSendEvent(mostRecentEvent, missingRelays, { pause, trailingPause: true, log, minSuccessfulRelays: 0 })
|
|
376
398
|
return mostRecentEvent
|
|
377
399
|
}
|
|
@@ -382,18 +404,18 @@ async function uploadBundle ({ dTag, channel, fileMetadata, signer, pause = 0, s
|
|
|
382
404
|
const maxCreatedAt = createdAt + 172800 // 2 days ahead
|
|
383
405
|
if (effectiveCreatedAt > maxCreatedAt) effectiveCreatedAt = maxCreatedAt
|
|
384
406
|
|
|
385
|
-
const
|
|
407
|
+
const siteManifest = {
|
|
386
408
|
kind,
|
|
387
409
|
tags,
|
|
388
410
|
content: '',
|
|
389
411
|
created_at: effectiveCreatedAt
|
|
390
412
|
}
|
|
391
|
-
const event = await signer.signEvent(
|
|
413
|
+
const event = await signer.signEvent(siteManifest)
|
|
392
414
|
await throttledSendEvent(event, writeRelays, { pause, trailingPause: true, log })
|
|
393
415
|
return event
|
|
394
416
|
}
|
|
395
417
|
|
|
396
|
-
async function
|
|
418
|
+
async function maybeUploadAppListing ({
|
|
397
419
|
dTag,
|
|
398
420
|
channel,
|
|
399
421
|
name,
|
|
@@ -404,6 +426,10 @@ async function maybeUploadStall ({
|
|
|
404
426
|
isSummaryAuto,
|
|
405
427
|
icon,
|
|
406
428
|
isIconAuto,
|
|
429
|
+
descriptions,
|
|
430
|
+
keyArt,
|
|
431
|
+
screenshots,
|
|
432
|
+
uploadService,
|
|
407
433
|
signer,
|
|
408
434
|
writeRelays,
|
|
409
435
|
log,
|
|
@@ -418,20 +444,20 @@ async function maybeUploadStall ({
|
|
|
418
444
|
const trimmedSummary = typeof summary === 'string' ? summary.trim() : ''
|
|
419
445
|
const iconRootHash = icon?.rootHash
|
|
420
446
|
const iconMimeType = icon?.mimeType
|
|
421
|
-
const iconService = icon?.service || 'i'
|
|
422
447
|
const hasMetadata = Boolean(trimmedName) || Boolean(trimmedSummary) || Boolean(iconRootHash) ||
|
|
423
|
-
Boolean(self) || (countries && countries.length > 0) || (categories && categories.length > 0) || (hashtags && hashtags.length > 0)
|
|
448
|
+
Boolean(self) || (countries && countries.length > 0) || (categories && categories.length > 0) || (hashtags && hashtags.length > 0) ||
|
|
449
|
+
(descriptions && descriptions.length > 0) || (keyArt && keyArt.length > 0) || (screenshots && screenshots.length > 0)
|
|
424
450
|
|
|
425
451
|
const relays = [...new Set([...writeRelays, ...nappRelays].map(r => r.trim().replace(/\/$/, '')))]
|
|
426
452
|
|
|
427
|
-
const previousResult = await
|
|
453
|
+
const previousResult = await getPreviousAppListing(dTag, relays, signer, channel)
|
|
428
454
|
const previous = previousResult?.previous
|
|
429
455
|
if (!previous && !hasMetadata) {
|
|
430
|
-
if (shouldReupload) log('Skipping
|
|
456
|
+
if (shouldReupload) log('Skipping app listing event upload: No previous event found and no metadata provided.')
|
|
431
457
|
return { pause }
|
|
432
458
|
}
|
|
433
459
|
|
|
434
|
-
const
|
|
460
|
+
const publishListing = async (event) => {
|
|
435
461
|
const signedEvent = await signer.signEvent(event)
|
|
436
462
|
return await throttledSendEvent(signedEvent, relays, { pause, log, trailingPause: true })
|
|
437
463
|
}
|
|
@@ -443,6 +469,39 @@ async function maybeUploadStall ({
|
|
|
443
469
|
draft: 37350
|
|
444
470
|
}[channel] ?? 37348
|
|
445
471
|
|
|
472
|
+
// Helper to push media-related tags (icon, key_art, screenshot, service)
|
|
473
|
+
const pushMediaTags = (tags) => {
|
|
474
|
+
let hasMedia = false
|
|
475
|
+
|
|
476
|
+
if (iconRootHash && iconMimeType) {
|
|
477
|
+
tags.push(['icon', iconRootHash, iconMimeType])
|
|
478
|
+
if (isIconAuto) tags.push(['auto', 'icon'])
|
|
479
|
+
hasMedia = true
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (keyArt && keyArt.length > 0) {
|
|
483
|
+
for (const ka of keyArt) {
|
|
484
|
+
const row = ['key_art', ka.rootHash, ka.mimeType]
|
|
485
|
+
if (ka.country) row.push(ka.country)
|
|
486
|
+
tags.push(row)
|
|
487
|
+
}
|
|
488
|
+
hasMedia = true
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (screenshots && screenshots.length > 0) {
|
|
492
|
+
for (const ss of screenshots) {
|
|
493
|
+
const row = ['screenshot', ss.rootHash, ss.mimeType]
|
|
494
|
+
if (ss.country) row.push(ss.country)
|
|
495
|
+
tags.push(row)
|
|
496
|
+
}
|
|
497
|
+
hasMedia = true
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (hasMedia) tags.push(['service', uploadService])
|
|
501
|
+
|
|
502
|
+
return { hasIcon: Boolean(iconRootHash && iconMimeType) }
|
|
503
|
+
}
|
|
504
|
+
|
|
446
505
|
if (!previous) {
|
|
447
506
|
const tags = [
|
|
448
507
|
['d', dTag]
|
|
@@ -481,14 +540,9 @@ async function maybeUploadStall ({
|
|
|
481
540
|
})
|
|
482
541
|
}
|
|
483
542
|
|
|
484
|
-
|
|
485
|
-
let hasName = false
|
|
486
|
-
if (iconRootHash && iconMimeType) {
|
|
487
|
-
hasIcon = true
|
|
488
|
-
tags.push(['icon', iconRootHash, iconMimeType, iconService])
|
|
489
|
-
if (isIconAuto) tags.push(['auto', 'icon'])
|
|
490
|
-
}
|
|
543
|
+
const { hasIcon } = pushMediaTags(tags)
|
|
491
544
|
|
|
545
|
+
let hasName = false
|
|
492
546
|
if (trimmedName) {
|
|
493
547
|
hasName = true
|
|
494
548
|
const row = ['name', trimmedName]
|
|
@@ -504,12 +558,22 @@ async function maybeUploadStall ({
|
|
|
504
558
|
if (isSummaryAuto) tags.push(['auto', 'summary'])
|
|
505
559
|
}
|
|
506
560
|
|
|
561
|
+
if (descriptions) {
|
|
562
|
+
for (const [text, lang] of descriptions) {
|
|
563
|
+
if (text) {
|
|
564
|
+
const row = ['description', text]
|
|
565
|
+
if (lang) row.push(lang)
|
|
566
|
+
tags.push(row)
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
507
571
|
if (!hasIcon || !hasName) {
|
|
508
|
-
log(`Skipping
|
|
572
|
+
log(`Skipping app listing event creation: Missing required metadata.${!hasName ? ' Name is missing.' : ''}${!hasIcon ? ' Icon is missing.' : ''}`)
|
|
509
573
|
return { pause }
|
|
510
574
|
}
|
|
511
575
|
|
|
512
|
-
return await
|
|
576
|
+
return await publishListing({
|
|
513
577
|
kind,
|
|
514
578
|
tags,
|
|
515
579
|
content: '',
|
|
@@ -589,6 +653,41 @@ async function maybeUploadStall ({
|
|
|
589
653
|
changed = true
|
|
590
654
|
}
|
|
591
655
|
|
|
656
|
+
// Update descriptions
|
|
657
|
+
if (descriptions) {
|
|
658
|
+
removeTags('description')
|
|
659
|
+
for (const [text, lang] of descriptions) {
|
|
660
|
+
if (text) {
|
|
661
|
+
const row = ['description', text]
|
|
662
|
+
if (lang) row.push(lang)
|
|
663
|
+
tags.push(row)
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
changed = true
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Update key art
|
|
670
|
+
if (keyArt && keyArt.length > 0) {
|
|
671
|
+
removeTags('key_art')
|
|
672
|
+
for (const ka of keyArt) {
|
|
673
|
+
const row = ['key_art', ka.rootHash, ka.mimeType]
|
|
674
|
+
if (ka.country) row.push(ka.country)
|
|
675
|
+
tags.push(row)
|
|
676
|
+
}
|
|
677
|
+
changed = true
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Update screenshots
|
|
681
|
+
if (screenshots && screenshots.length > 0) {
|
|
682
|
+
removeTags('screenshot')
|
|
683
|
+
for (const ss of screenshots) {
|
|
684
|
+
const row = ['screenshot', ss.rootHash, ss.mimeType]
|
|
685
|
+
if (ss.country) row.push(ss.country)
|
|
686
|
+
tags.push(row)
|
|
687
|
+
}
|
|
688
|
+
changed = true
|
|
689
|
+
}
|
|
690
|
+
|
|
592
691
|
const ensureTagValue = (key, updater) => {
|
|
593
692
|
const index = tags.findIndex(tag => Array.isArray(tag) && tag[0] === key)
|
|
594
693
|
if (index === -1) {
|
|
@@ -655,12 +754,18 @@ async function maybeUploadStall ({
|
|
|
655
754
|
if (iconRootHash && iconMimeType) {
|
|
656
755
|
if (!isIconAuto || hasAuto('icon')) {
|
|
657
756
|
ensureTagValue('icon', (_) => {
|
|
658
|
-
return ['icon', iconRootHash, iconMimeType
|
|
757
|
+
return ['icon', iconRootHash, iconMimeType]
|
|
659
758
|
})
|
|
660
759
|
if (!isIconAuto) removeAuto('icon')
|
|
661
760
|
}
|
|
662
761
|
}
|
|
663
762
|
|
|
763
|
+
// Update service tag if any media exists
|
|
764
|
+
const hasMedia = Boolean(iconRootHash) || (keyArt && keyArt.length > 0) || (screenshots && screenshots.length > 0)
|
|
765
|
+
if (hasMedia) {
|
|
766
|
+
ensureTagValue('service', () => ['service', uploadService])
|
|
767
|
+
}
|
|
768
|
+
|
|
664
769
|
if (!changed && !shouldReupload) {
|
|
665
770
|
const { storedEvents } = previousResult
|
|
666
771
|
|
|
@@ -670,7 +775,7 @@ async function maybeUploadStall ({
|
|
|
670
775
|
|
|
671
776
|
if (missingRelays.length === 0) return { pause }
|
|
672
777
|
|
|
673
|
-
log(`Re-uploading existing
|
|
778
|
+
log(`Re-uploading existing app listing event to ${missingRelays.length} missing relays (out of ${relays.length})`)
|
|
674
779
|
return await throttledSendEvent(previous, missingRelays, { pause, log, trailingPause: true, minSuccessfulRelays: 0 })
|
|
675
780
|
}
|
|
676
781
|
|
|
@@ -678,7 +783,7 @@ async function maybeUploadStall ({
|
|
|
678
783
|
const maxCreatedAt = createdAt + 172800 // 2 days ahead
|
|
679
784
|
if (effectiveCreatedAt > maxCreatedAt) effectiveCreatedAt = maxCreatedAt
|
|
680
785
|
|
|
681
|
-
return await
|
|
786
|
+
return await publishListing({
|
|
682
787
|
kind,
|
|
683
788
|
tags,
|
|
684
789
|
content: typeof previous.content === 'string' ? previous.content : '',
|
|
@@ -686,7 +791,7 @@ async function maybeUploadStall ({
|
|
|
686
791
|
})
|
|
687
792
|
}
|
|
688
793
|
|
|
689
|
-
async function
|
|
794
|
+
async function getPreviousAppListing (dTagValue, relays, signer, channel) {
|
|
690
795
|
const kind = {
|
|
691
796
|
main: 37348,
|
|
692
797
|
next: 37349,
|