nappup 2.3.1 → 2.3.3
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 +9 -5
- package/bin/nappup/index.js +9 -3
- package/package.json +2 -2
- package/src/helpers/app-metadata.js +197 -60
- package/src/index.js +8 -7
- package/src/services/nostr-relays.js +1 -15
- package/src/services/nostr-signer.js +22 -5
package/bin/nappup/helpers.js
CHANGED
|
@@ -153,7 +153,7 @@ export async function readEnvSetValue (args, {
|
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
export async function confirmArgs (args) {
|
|
156
|
-
if (args.yes) return
|
|
156
|
+
if (args.yes) return true
|
|
157
157
|
const rl = readline.createInterface({
|
|
158
158
|
input: process.stdin,
|
|
159
159
|
output: process.stdout
|
|
@@ -162,14 +162,18 @@ export async function confirmArgs (args) {
|
|
|
162
162
|
return new Promise(resolve => rl.question(query, resolve))
|
|
163
163
|
}
|
|
164
164
|
const answer = await askQuestion(
|
|
165
|
-
|
|
165
|
+
confirmationPrompt(args)
|
|
166
166
|
)
|
|
167
|
+
rl.close()
|
|
167
168
|
if (answer.toLowerCase() !== 'y') {
|
|
168
169
|
console.log('Operation cancelled by user.')
|
|
169
|
-
|
|
170
|
-
process.exit(0)
|
|
170
|
+
return false
|
|
171
171
|
}
|
|
172
|
-
|
|
172
|
+
return true
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function confirmationPrompt (args) {
|
|
176
|
+
return `Publish app from '${args.dir}' as '${args.dTag}' to the ${args.channel} release channel using ${args.npub}? (y/n) `
|
|
173
177
|
}
|
|
174
178
|
|
|
175
179
|
export async function * getFiles (dir) {
|
package/bin/nappup/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from 'node:path'
|
|
3
|
+
import { npubEncode } from 'libp2r2p/nip19'
|
|
3
4
|
import { GENERIC_BUILD_FOLDER_NAMES } from '#helpers/app.js'
|
|
4
5
|
import {
|
|
5
6
|
parseArgs,
|
|
@@ -57,8 +58,6 @@ async function upload (args, dotenvState) {
|
|
|
57
58
|
}
|
|
58
59
|
args.dTag = dTag
|
|
59
60
|
|
|
60
|
-
await confirmArgs(args)
|
|
61
|
-
const fileList = await toFileList(getFiles(dir), dir)
|
|
62
61
|
const bunkerUrl = sk?.startsWith('bunker://')
|
|
63
62
|
? sk
|
|
64
63
|
: !sk && process.env.NOSTR_SECRET_KEY?.startsWith('bunker://')
|
|
@@ -73,10 +72,17 @@ async function upload (args, dotenvState) {
|
|
|
73
72
|
})
|
|
74
73
|
} else {
|
|
75
74
|
const { default: NostrSigner } = await import('#services/nostr-signer.js')
|
|
76
|
-
signer = await NostrSigner.create(sk
|
|
75
|
+
signer = await NostrSigner.create(sk, {
|
|
76
|
+
deferInitialization: true,
|
|
77
|
+
dotenvFilePath: dotenvState.filePath
|
|
78
|
+
})
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
try {
|
|
82
|
+
args.npub = npubEncode(await signer.getPublicKey())
|
|
83
|
+
if (!await confirmArgs(args)) return
|
|
84
|
+
await signer.initialize?.()
|
|
85
|
+
const fileList = await toFileList(getFiles(dir), dir)
|
|
80
86
|
const { default: toApp } = await import('#index.js')
|
|
81
87
|
await toApp(fileList, signer, { log: console.log.bind(console), dTag, channel, shouldReupload })
|
|
82
88
|
} finally {
|
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.3",
|
|
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.2",
|
|
26
26
|
"mime-types": "^3.0.1",
|
|
27
27
|
"nmmr": "^2.0.0"
|
|
28
28
|
},
|
|
@@ -1,82 +1,219 @@
|
|
|
1
|
-
|
|
2
|
-
let name
|
|
3
|
-
let description
|
|
1
|
+
const IMAGE_EXTENSIONS = /\.(?:ico|svg|webp|png|jpe?g|gif|avif)(?:[?#].*)?$/i
|
|
4
2
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
// Decodes the character references commonly used in metadata attributes.
|
|
4
|
+
function decodeHtml (value) {
|
|
5
|
+
return String(value || '').replace(/&(#x[0-9a-f]+|#\d+|amp|quot|apos|lt|gt);/gi, (_, entity) => {
|
|
6
|
+
const lower = entity.toLowerCase()
|
|
7
|
+
if (lower === 'amp') return '&'
|
|
8
|
+
if (lower === 'quot') return '"'
|
|
9
|
+
if (lower === 'apos') return "'"
|
|
10
|
+
if (lower === 'lt') return '<'
|
|
11
|
+
if (lower === 'gt') return '>'
|
|
12
|
+
const codePoint = lower.startsWith('#x')
|
|
13
|
+
? Number.parseInt(lower.slice(2), 16)
|
|
14
|
+
: Number.parseInt(lower.slice(1), 10)
|
|
15
|
+
return Number.isSafeInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff
|
|
16
|
+
? String.fromCodePoint(codePoint)
|
|
17
|
+
: _
|
|
18
|
+
})
|
|
19
|
+
}
|
|
11
20
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
// Parses attributes without depending on a browser DOM implementation.
|
|
22
|
+
function parseAttributes (tag) {
|
|
23
|
+
const attributes = {}
|
|
24
|
+
const source = tag.replace(/^<\s*[^\s>]+/, '').replace(/\/?\s*>$/, '')
|
|
25
|
+
const pattern = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g
|
|
26
|
+
for (const match of source.matchAll(pattern)) {
|
|
27
|
+
const name = match[1].toLowerCase()
|
|
28
|
+
if (!(name in attributes)) attributes[name] = decodeHtml(match[2] ?? match[3] ?? match[4] ?? '')
|
|
29
|
+
}
|
|
30
|
+
return attributes
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Scans selected HTML start tags while respecting quoted greater-than signs.
|
|
34
|
+
function scanTags (htmlContent, names) {
|
|
35
|
+
const tags = []
|
|
36
|
+
const start = new RegExp(`<\\s*(${[...names].join('|')})\\b`, 'ig')
|
|
37
|
+
for (const match of htmlContent.matchAll(start)) {
|
|
38
|
+
let quote = null
|
|
39
|
+
let end = match.index + match[0].length
|
|
40
|
+
for (; end < htmlContent.length; end++) {
|
|
41
|
+
const char = htmlContent[end]
|
|
42
|
+
if (quote) {
|
|
43
|
+
if (char === quote) quote = null
|
|
44
|
+
} else if (char === '"' || char === "'") {
|
|
45
|
+
quote = char
|
|
46
|
+
} else if (char === '>') {
|
|
47
|
+
break
|
|
23
48
|
}
|
|
24
49
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const metaDescMatch = htmlContent.match(metaDescRegex)
|
|
28
|
-
if (metaDescMatch && metaDescMatch[1]) {
|
|
29
|
-
description = metaDescMatch[1].trim()
|
|
50
|
+
if (end < htmlContent.length) {
|
|
51
|
+
tags.push({ name: match[1].toLowerCase(), attributes: parseAttributes(htmlContent.slice(match.index, end + 1)), index: match.index })
|
|
30
52
|
}
|
|
53
|
+
}
|
|
54
|
+
return tags
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Adds a unique icon source with a stable priority and document order.
|
|
58
|
+
function addIconSource (sources, seen, href, kind, priority, index) {
|
|
59
|
+
const value = typeof href === 'string' ? href.trim() : ''
|
|
60
|
+
if (!value || seen.has(value)) return
|
|
61
|
+
seen.add(value)
|
|
62
|
+
sources.push({ href: value, kind, priority, index })
|
|
63
|
+
}
|
|
31
64
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
65
|
+
function metadataMarkup (htmlContent) {
|
|
66
|
+
return String(htmlContent || '')
|
|
67
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
68
|
+
.replace(/<(script|style|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Extracts listing text plus browser, platform and social icon declarations.
|
|
72
|
+
export function extractHtmlMetadata (htmlContent) {
|
|
73
|
+
const html = metadataMarkup(htmlContent)
|
|
74
|
+
let name = decodeHtml(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]).trim() || undefined
|
|
75
|
+
let namePriority = name ? 0 : Infinity
|
|
76
|
+
let description
|
|
77
|
+
let descriptionPriority = Infinity
|
|
78
|
+
let baseHref
|
|
79
|
+
const iconSources = []
|
|
80
|
+
const seenIcons = new Set()
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const tags = scanTags(html, new Set(['base', 'link', 'meta']))
|
|
84
|
+
for (const { name: tagName, attributes, index } of tags) {
|
|
85
|
+
if (tagName === 'base' && !baseHref && attributes.href) {
|
|
86
|
+
baseHref = attributes.href.trim()
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (tagName === 'link') {
|
|
90
|
+
const rels = new Set((attributes.rel || '').toLowerCase().split(/\s+/).filter(Boolean))
|
|
91
|
+
if (rels.has('icon')) addIconSource(iconSources, seenIcons, attributes.href, 'icon', 10, index)
|
|
92
|
+
else if (rels.has('apple-touch-icon')) addIconSource(iconSources, seenIcons, attributes.href, 'apple-touch-icon', 20, index)
|
|
93
|
+
else if (rels.has('apple-touch-icon-precomposed')) addIconSource(iconSources, seenIcons, attributes.href, 'apple-touch-icon', 21, index)
|
|
94
|
+
else if (rels.has('mask-icon') || rels.has('fluid-icon')) addIconSource(iconSources, seenIcons, attributes.href, 'mask-icon', 30, index)
|
|
95
|
+
else if (rels.has('manifest')) addIconSource(iconSources, seenIcons, attributes.href, 'manifest', 40, index)
|
|
96
|
+
else if (rels.has('image_src')) addIconSource(iconSources, seenIcons, attributes.href, 'social-image', 70, index)
|
|
97
|
+
continue
|
|
37
98
|
}
|
|
38
|
-
}
|
|
39
99
|
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
100
|
+
const key = (attributes.property || attributes.name || attributes.itemprop || '').toLowerCase()
|
|
101
|
+
const content = attributes.content?.trim()
|
|
102
|
+
if (!content) continue
|
|
103
|
+
const nextNamePriority = key === 'application-name'
|
|
104
|
+
? 10
|
|
105
|
+
: key === 'apple-mobile-web-app-title'
|
|
106
|
+
? 20
|
|
107
|
+
: key === 'og:title' ? 30 : Infinity
|
|
108
|
+
if (nextNamePriority < namePriority) {
|
|
109
|
+
name = content
|
|
110
|
+
namePriority = nextNamePriority
|
|
111
|
+
}
|
|
112
|
+
const priority = key === 'description' ? 10 : key === 'og:description' ? 20 : Infinity
|
|
113
|
+
if (priority < descriptionPriority) {
|
|
114
|
+
description = content
|
|
115
|
+
descriptionPriority = priority
|
|
51
116
|
}
|
|
117
|
+
if (key === 'msapplication-tileimage') addIconSource(iconSources, seenIcons, content, 'tile-image', 60, index)
|
|
118
|
+
else if (key === 'og:image:secure_url') addIconSource(iconSources, seenIcons, content, 'social-image', 71, index)
|
|
119
|
+
else if (key === 'og:image' || key === 'og:image:url') addIconSource(iconSources, seenIcons, content, 'social-image', 72, index)
|
|
120
|
+
else if (key === 'image') addIconSource(iconSources, seenIcons, content, 'social-image', 75, index)
|
|
121
|
+
else if (key === 'twitter:image' || key === 'twitter:image:src') addIconSource(iconSources, seenIcons, content, 'social-image', 80, index)
|
|
52
122
|
}
|
|
123
|
+
} catch (_) {}
|
|
124
|
+
|
|
125
|
+
iconSources.sort((left, right) => left.priority - right.priority || left.index - right.index)
|
|
126
|
+
return {
|
|
127
|
+
name,
|
|
128
|
+
description,
|
|
129
|
+
baseHref,
|
|
130
|
+
iconSources: iconSources.map(({ href, kind }) => ({ href, kind }))
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Extracts ordered icon references from a parsed Web App Manifest.
|
|
135
|
+
export function extractWebManifestIcons (manifest) {
|
|
136
|
+
try {
|
|
137
|
+
const parsed = typeof manifest === 'string' ? JSON.parse(manifest) : manifest
|
|
138
|
+
return (Array.isArray(parsed?.icons) ? parsed.icons : [])
|
|
139
|
+
.map((icon, index) => ({
|
|
140
|
+
href: typeof icon?.src === 'string' ? icon.src.trim() : '',
|
|
141
|
+
kind: 'web-app-manifest',
|
|
142
|
+
purpose: typeof icon?.purpose === 'string' ? icon.purpose.toLowerCase().split(/\s+/) : ['any'],
|
|
143
|
+
index
|
|
144
|
+
}))
|
|
145
|
+
.filter(icon => icon.href)
|
|
146
|
+
.sort((left, right) => {
|
|
147
|
+
const rank = icon => icon.purpose.includes('any') ? 0 : icon.purpose.includes('maskable') ? 1 : 2
|
|
148
|
+
return rank(left) - rank(right) || left.index - right.index
|
|
149
|
+
})
|
|
150
|
+
.map(({ href, kind }) => ({ href, kind }))
|
|
53
151
|
} catch (_) {
|
|
54
|
-
|
|
152
|
+
return []
|
|
55
153
|
}
|
|
154
|
+
}
|
|
56
155
|
|
|
57
|
-
|
|
156
|
+
// Returns the path portion of a reference as it would resolve inside the app.
|
|
157
|
+
export function resolveAppPath (reference, basePath = 'index.html', baseHref) {
|
|
158
|
+
try {
|
|
159
|
+
const documentUrl = new URL(basePath, 'https://napp.invalid/')
|
|
160
|
+
const effectiveBase = baseHref ? new URL(baseHref, documentUrl) : documentUrl
|
|
161
|
+
const url = new URL(reference, effectiveBase)
|
|
162
|
+
if (!['http:', 'https:'].includes(url.protocol)) return null
|
|
163
|
+
return decodeURIComponent(url.pathname).replace(/^\/+/, '') || null
|
|
164
|
+
} catch (_) {
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
58
167
|
}
|
|
59
168
|
|
|
169
|
+
// Returns the bundle-relative path represented by a File-like object.
|
|
170
|
+
function filePath (file) {
|
|
171
|
+
const path = file?.webkitRelativePath || file?.name || ''
|
|
172
|
+
return file?.webkitRelativePath ? path.split('/').slice(1).join('/') : path
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Finds a conventional favicon file in a bundle.
|
|
60
176
|
export function findFavicon (fileList) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
177
|
+
return fileList.find(file => {
|
|
178
|
+
const filename = filePath(file).split('/').pop().toLowerCase()
|
|
179
|
+
return filename.startsWith('favicon.') && IMAGE_EXTENSIONS.test(filename)
|
|
180
|
+
}) || null
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Finds the best local icon referenced by HTML or its Web App Manifest.
|
|
184
|
+
export async function findAppIcon (fileList, htmlContent, indexFile, readFileText) {
|
|
185
|
+
const metadata = extractHtmlMetadata(htmlContent)
|
|
186
|
+
const indexPath = filePath(indexFile) || 'index.html'
|
|
187
|
+
const byPath = new Map(fileList.map(file => [filePath(file), file]))
|
|
188
|
+
const findFromSources = async sources => {
|
|
189
|
+
for (const source of sources) {
|
|
190
|
+
const path = resolveAppPath(source.href, indexPath, metadata.baseHref)
|
|
191
|
+
const file = path && byPath.get(path)
|
|
192
|
+
if (!file) continue
|
|
193
|
+
if (source.kind !== 'manifest') return file
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const manifestText = readFileText ? await readFileText(file) : await file.text()
|
|
197
|
+
for (const icon of extractWebManifestIcons(manifestText)) {
|
|
198
|
+
const iconPath = resolveAppPath(icon.href, path)
|
|
199
|
+
if (iconPath && byPath.has(iconPath)) return byPath.get(iconPath)
|
|
200
|
+
}
|
|
201
|
+
} catch (_) {}
|
|
69
202
|
}
|
|
203
|
+
return null
|
|
70
204
|
}
|
|
71
|
-
|
|
205
|
+
|
|
206
|
+
const specificSources = metadata.iconSources.filter(source => !['tile-image', 'social-image'].includes(source.kind))
|
|
207
|
+
const socialSources = metadata.iconSources.filter(source => ['tile-image', 'social-image'].includes(source.kind))
|
|
208
|
+
return (await findFromSources(specificSources)) ||
|
|
209
|
+
findFavicon(fileList) ||
|
|
210
|
+
(await findFromSources(socialSources))
|
|
72
211
|
}
|
|
73
212
|
|
|
213
|
+
// Finds the app entry HTML file.
|
|
74
214
|
export function findIndexFile (fileList) {
|
|
75
|
-
|
|
76
|
-
const filename = (file
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
return null
|
|
215
|
+
return fileList.find(file => {
|
|
216
|
+
const filename = filePath(file).split('/').pop().toLowerCase()
|
|
217
|
+
return filename === 'index.html' || filename === 'index.htm'
|
|
218
|
+
}) || null
|
|
82
219
|
}
|
package/src/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import NMMR from 'nmmr'
|
|
2
|
-
import { appEncode
|
|
2
|
+
import { appEncode } from 'libp2r2p/nip19'
|
|
3
3
|
import nostrRelays from '#services/nostr-relays.js'
|
|
4
4
|
import { getRelays } from '#helpers/signer.js'
|
|
5
5
|
import { streamToChunks, streamToText } from '#helpers/stream.js'
|
|
6
6
|
import { isNostrAppDTagSafe, GENERIC_BUILD_FOLDER_NAMES } from '#helpers/app.js'
|
|
7
|
-
import { extractHtmlMetadata,
|
|
7
|
+
import { extractHtmlMetadata, findAppIcon, findIndexFile } from '#helpers/app-metadata.js'
|
|
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'
|
|
@@ -79,8 +79,7 @@ export async function toApp (fileList, nostrSigner, {
|
|
|
79
79
|
|
|
80
80
|
const writeRelays = [...new Set((await nostrSigner.getRelays()).write
|
|
81
81
|
.map(relay => relay.trim().replace(/\/$/, '')))]
|
|
82
|
-
|
|
83
|
-
log(`Found ${writeRelays.length} outbox relays for pubkey ${npub}:\n${writeRelays.join(', ')}`)
|
|
82
|
+
log(`Found ${writeRelays.length} outbox relays:\n${writeRelays.join(', ')}`)
|
|
84
83
|
if (!writeRelays.length) throw new Error('No outbox relays found')
|
|
85
84
|
|
|
86
85
|
if (typeof dTag === 'string') {
|
|
@@ -111,9 +110,11 @@ export async function toApp (fileList, nostrSigner, {
|
|
|
111
110
|
const indexFile = findIndexFile(fileList)
|
|
112
111
|
let manifestName = nappJson.name?.[0]?.[0]
|
|
113
112
|
let manifestSummary = nappJson.summary?.[0]?.[0]
|
|
114
|
-
|
|
113
|
+
let indexHtml = ''
|
|
114
|
+
if (indexFile) {
|
|
115
115
|
try {
|
|
116
|
-
|
|
116
|
+
indexHtml = await streamToText(indexFile.stream())
|
|
117
|
+
const { name, description } = extractHtmlMetadata(indexHtml)
|
|
117
118
|
if (!manifestName) manifestName = name
|
|
118
119
|
if (!manifestSummary) manifestSummary = description
|
|
119
120
|
} catch (error) {
|
|
@@ -121,7 +122,7 @@ export async function toApp (fileList, nostrSigner, {
|
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
const faviconFile =
|
|
125
|
+
const faviconFile = await findAppIcon(fileList, indexHtml, indexFile, file => streamToText(file.stream()))
|
|
125
126
|
let iconMetadata
|
|
126
127
|
let pause = 1000
|
|
127
128
|
|
|
@@ -1,19 +1,5 @@
|
|
|
1
1
|
import { relayPool } from 'libp2r2p/relay'
|
|
2
|
-
|
|
3
|
-
export const seedRelays = [
|
|
4
|
-
'wss://relay.44billion.net',
|
|
5
|
-
'wss://purplepag.es',
|
|
6
|
-
'wss://user.kindpag.es',
|
|
7
|
-
'wss://relay.nos.social',
|
|
8
|
-
'wss://nostr.land',
|
|
9
|
-
'wss://indexer.coracle.social'
|
|
10
|
-
]
|
|
11
|
-
export const freeRelays = [
|
|
12
|
-
'wss://relay.primal.net',
|
|
13
|
-
'wss://nos.lol',
|
|
14
|
-
'wss://relay.damus.io'
|
|
15
|
-
]
|
|
16
|
-
export const nappRelays = ['wss://relay.44billion.net']
|
|
2
|
+
export { freeRelays, nappRelays, seedRelays } from 'libp2r2p/relay'
|
|
17
3
|
|
|
18
4
|
// sendEvent returns quickly after the first successful publish. Upload flows
|
|
19
5
|
// need the terminal per-relay report so retries and replication stay correct.
|
|
@@ -12,15 +12,19 @@ const createToken = Symbol('createToken')
|
|
|
12
12
|
export default class NostrSigner {
|
|
13
13
|
#secretKey // bytes
|
|
14
14
|
#publicKey // hex
|
|
15
|
+
#needsInitialization
|
|
16
|
+
#dotenvFilePath
|
|
15
17
|
|
|
16
|
-
constructor (token, skBytes) {
|
|
18
|
+
constructor (token, skBytes, { needsInitialization = false, dotenvFilePath } = {}) {
|
|
17
19
|
if (token !== createToken) throw new Error('Use NostrSigner.create(?sk) to instantiate this class.')
|
|
18
20
|
if (!skBytes) throw new Error('Secret key missing.')
|
|
19
21
|
|
|
20
22
|
this.#secretKey = skBytes
|
|
23
|
+
this.#needsInitialization = needsInitialization
|
|
24
|
+
this.#dotenvFilePath = dotenvFilePath
|
|
21
25
|
}
|
|
22
26
|
|
|
23
|
-
static async create (sk) {
|
|
27
|
+
static async create (sk, { deferInitialization = false, dotenvFilePath } = {}) {
|
|
24
28
|
if (sk) {
|
|
25
29
|
if (sk.startsWith('nsec')) sk = nsecDecode(sk)
|
|
26
30
|
return new this(createToken, base16ToBytes(sk))
|
|
@@ -37,13 +41,26 @@ export default class NostrSigner {
|
|
|
37
41
|
} else {
|
|
38
42
|
isNewSk = true
|
|
39
43
|
skBytes = generateSecretKey()
|
|
40
|
-
setEncryptedDotenvValue('NOSTR_SECRET_KEY', nsecEncode(bytesToBase16(skBytes)))
|
|
41
44
|
}
|
|
42
|
-
const ret = new this(createToken, skBytes
|
|
43
|
-
|
|
45
|
+
const ret = new this(createToken, skBytes, {
|
|
46
|
+
needsInitialization: isNewSk,
|
|
47
|
+
dotenvFilePath
|
|
48
|
+
})
|
|
49
|
+
if (isNewSk && !deferInitialization) await ret.initialize()
|
|
44
50
|
return ret
|
|
45
51
|
}
|
|
46
52
|
|
|
53
|
+
async initialize () {
|
|
54
|
+
if (!this.#needsInitialization) return
|
|
55
|
+
setEncryptedDotenvValue(
|
|
56
|
+
'NOSTR_SECRET_KEY',
|
|
57
|
+
nsecEncode(bytesToBase16(this.#secretKey)),
|
|
58
|
+
this.#dotenvFilePath ? { filePath: this.#dotenvFilePath } : undefined
|
|
59
|
+
)
|
|
60
|
+
this.#needsInitialization = false
|
|
61
|
+
await this.#initSk()
|
|
62
|
+
}
|
|
63
|
+
|
|
47
64
|
async getRelays () {
|
|
48
65
|
return getRelays.call(this)
|
|
49
66
|
}
|