nappup 1.0.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/GEMINI.md ADDED
@@ -0,0 +1,6 @@
1
+ # bin folder
2
+
3
+ Place one-off scripts used by package.json's "bin" field.
4
+
5
+ Each script lives on its own subdirectory and may have its own helpers.js file
6
+ with helper functions.
@@ -0,0 +1,83 @@
1
+ import readline from 'node:readline'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { Readable } from 'node:stream'
5
+ import mime from 'mime-types'
6
+ import { fileTypeFromFile } from 'file-type'
7
+
8
+ export function parseArgs (args) {
9
+ let dir = null
10
+ let sk = null
11
+ let appId = null
12
+ let channel = null
13
+
14
+ for (let i = 0; i < args.length; i++) {
15
+ if (args[i] === '-s' && args[i + 1]) {
16
+ sk = args[i + 1]
17
+ i++ // Skip the next argument as it's part of -k
18
+ } else if (args[i] === '-i' && args[i + 1]) {
19
+ appId = args[i + 1]
20
+ i++
21
+ } else if (args[i] === '--main' && channel === null) {
22
+ channel = 'main'
23
+ } else if (args[i] === '--next' && channel === null) {
24
+ channel = 'next'
25
+ } else if (args[i] === '--draft' && channel === null) {
26
+ channel = 'draft'
27
+ } else if (!args[i].startsWith('-') && dir === null) {
28
+ dir = args[i]
29
+ }
30
+ }
31
+
32
+ return {
33
+ dir: path.resolve(dir ?? '.'),
34
+ sk,
35
+ appId,
36
+ channel: channel || 'main'
37
+ }
38
+ }
39
+
40
+ export async function confirmArgs (args) {
41
+ const rl = readline.createInterface({
42
+ input: process.stdin,
43
+ output: process.stdout
44
+ })
45
+ function askQuestion (query) {
46
+ return new Promise(resolve => rl.question(query, resolve))
47
+ }
48
+ const answer = await askQuestion(
49
+ `Publish app from '${args.dir}' to the ${args.channel} release channel? (y/n) `
50
+ )
51
+ if (answer.toLowerCase() !== 'y') {
52
+ console.log('Operation cancelled by user.')
53
+ rl.close()
54
+ process.exit(0)
55
+ }
56
+ rl.close()
57
+ }
58
+
59
+ export async function * getFiles (dir) {
60
+ const dirents = await fs.promises.readdir(dir, { withFileTypes: true })
61
+ for (const dirent of dirents) {
62
+ const res = path.resolve(dir, dirent.name)
63
+ if (dirent.isDirectory()) {
64
+ yield * getFiles(res)
65
+ } else {
66
+ yield res
67
+ }
68
+ }
69
+ }
70
+
71
+ export async function toFileList (filesIterator, dir) {
72
+ const fileList = []
73
+ for await (const f of filesIterator) {
74
+ const fileType = mime.lookup(f)
75
+ const file = {
76
+ stream: () => Readable.toWeb(fs.createReadStream(f)),
77
+ webkitRelativePath: path.relative(dir.replace(/\/[^/]*$/, ''), f),
78
+ type: fileType || (await fileTypeFromFile(f))?.mime || ''
79
+ }
80
+ fileList.push(file)
81
+ }
82
+ return fileList
83
+ }
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ import NostrSigner from '#services/nostr-signer.js'
3
+ import {
4
+ parseArgs,
5
+ confirmArgs,
6
+ toFileList,
7
+ getFiles
8
+ } from './helpers.js'
9
+ import toApp from '#index.js'
10
+
11
+ const args = parseArgs(process.argv.slice(2))
12
+ await confirmArgs(args)
13
+
14
+ const { dir, sk, appId, channel } = args
15
+ const fileList = await toFileList(getFiles(dir), dir)
16
+
17
+ await toApp(fileList, await NostrSigner.create(sk), { log: console.log.bind(console), appId, channel })
package/lib/GEMINI.md ADDED
@@ -0,0 +1,4 @@
1
+ # lib folder
2
+
3
+ This is the place to add third-party libraries that don't have a
4
+ published npm package.
package/lib/base122.js ADDED
@@ -0,0 +1,171 @@
1
+ // https://github.com/kevinAlbs/Base122/commit/b62945c2733fa4da8792a1071e40a8b326e8dd1b
2
+ // Provides functions for encoding/decoding data to and from base-122.
3
+
4
+ const kString = 0
5
+ const kUint8Array = 1
6
+ const kDebug = false
7
+ const kIllegals = [
8
+ 0, // null
9
+ 10, // newline
10
+ 13, // carriage return
11
+ 34, // double quote
12
+ 38, // ampersand
13
+ 92 // backslash
14
+ ]
15
+ const kShortened = 0b111 // Uses the illegal index to signify the last two-byte char encodes <= 7 bits.
16
+
17
+ /**
18
+ * Encodes raw data into base-122.
19
+ * @param {Uint8Array|Buffer|Array|String} rawData - The data to be encoded. This can be an array
20
+ * or Buffer with raw data bytes or a string of bytes (i.e. the type of argument to btoa())
21
+ * @returns {Array} The base-122 encoded data as a regular array of UTF-8 character byte values.
22
+ */
23
+ function encode (rawData) {
24
+ const dataType = typeof (rawData) === 'string' ? kString : kUint8Array
25
+ let curIndex = 0
26
+ let curBit = 0 // Points to current bit needed
27
+ // const curMask = 0b10000000
28
+ const outData = []
29
+ let getByte = i => rawData[i]
30
+
31
+ if (dataType === kString) {
32
+ getByte = (i) => {
33
+ const val = rawData.codePointAt(i)
34
+ if (val > 255) {
35
+ throw new Error('Unexpected code point at position: ' + i + '. Expected value [0,255]. Got: ' + val)
36
+ }
37
+ return val
38
+ }
39
+ }
40
+
41
+ // Get seven bits of input data. Returns false if there is no input left.
42
+ function get7 () {
43
+ if (curIndex >= rawData.length) return false
44
+ // Shift, mask, unshift to get first part.
45
+ const firstByte = getByte(curIndex)
46
+ let firstPart = ((0b11111110 >>> curBit) & firstByte) << curBit
47
+ // Align it to a seven bit chunk.
48
+ firstPart >>= 1
49
+ // Check if we need to go to the next byte for more bits.
50
+ curBit += 7
51
+ if (curBit < 8) return firstPart // Do not need next byte.
52
+ curBit -= 8
53
+ curIndex++
54
+ // Now we want bits [0..curBit] of the next byte if it exists.
55
+ if (curIndex >= rawData.length) return firstPart
56
+ const secondByte = getByte(curIndex)
57
+ let secondPart = ((0xFF00 >>> curBit) & secondByte) & 0xFF
58
+ // Align it.
59
+ secondPart >>= 8 - curBit
60
+ return firstPart | secondPart
61
+ }
62
+
63
+ while (true) {
64
+ // Grab 7 bits.
65
+ const bits = get7()
66
+ if (bits === false) break
67
+ debugLog('Seven input bits', print7Bits(bits), bits)
68
+
69
+ const illegalIndex = kIllegals.indexOf(bits)
70
+ if (illegalIndex !== -1) {
71
+ // Since this will be a two-byte character, get the next chunk of seven bits.
72
+ let nextBits = get7()
73
+ debugLog('Handle illegal sequence', print7Bits(bits), print7Bits(nextBits))
74
+
75
+ let b1 = 0b11000010; let b2 = 0b10000000
76
+ if (nextBits === false) {
77
+ debugLog('Last seven bits are an illegal sequence.')
78
+ b1 |= (0b111 & kShortened) << 2
79
+ nextBits = bits // Encode these bits after the shortened signifier.
80
+ } else {
81
+ b1 |= (0b111 & illegalIndex) << 2
82
+ }
83
+
84
+ // Push first bit onto first byte, remaining 6 onto second.
85
+ const firstBit = (nextBits & 0b01000000) > 0 ? 1 : 0
86
+ b1 |= firstBit
87
+ b2 |= nextBits & 0b00111111
88
+ outData.push(b1)
89
+ outData.push(b2)
90
+ } else {
91
+ outData.push(bits)
92
+ }
93
+ }
94
+ return outData
95
+ }
96
+
97
+ /**
98
+ * Decodes base-122 encoded data back to the original data.
99
+ * @param {Uint8Array|Buffer|String} rawData - The data to be decoded. This can be a Uint8Array
100
+ * or Buffer with raw data bytes or a string of bytes (i.e. the type of argument to btoa())
101
+ * @returns {Array} The data in a regular array representing byte values.
102
+ */
103
+ function decode (base122Data) {
104
+ const strData = typeof (base122Data) === 'string' ? base122Data : utf8DataToString(base122Data)
105
+ const decoded = []
106
+ // const decodedIndex = 0
107
+ let curByte = 0
108
+ let bitOfByte = 0
109
+
110
+ function push7 (byte) {
111
+ byte <<= 1
112
+ // Align this byte to offset for current byte.
113
+ curByte |= (byte >>> bitOfByte)
114
+ bitOfByte += 7
115
+ if (bitOfByte >= 8) {
116
+ decoded.push(curByte)
117
+ bitOfByte -= 8
118
+ // Now, take the remainder, left shift by what has been taken.
119
+ curByte = (byte << (7 - bitOfByte)) & 255
120
+ }
121
+ }
122
+
123
+ for (let i = 0; i < strData.length; i++) {
124
+ const c = strData.charCodeAt(i)
125
+ // Check if this is a two-byte character.
126
+ if (c > 127) {
127
+ // Note, the charCodeAt will give the codePoint, thus
128
+ // 0b110xxxxx 0b10yyyyyy will give => xxxxxyyyyyy
129
+ const illegalIndex = (c >>> 8) & 7 // 7 = 0b111.
130
+ // We have to first check if this is a shortened two-byte character, i.e. if it only
131
+ // encodes <= 7 bits.
132
+ if (illegalIndex !== kShortened) push7(kIllegals[illegalIndex])
133
+ // Always push the rest.
134
+ push7(c & 127)
135
+ } else {
136
+ // One byte characters can be pushed directly.
137
+ push7(c)
138
+ }
139
+ }
140
+ return decoded
141
+ }
142
+
143
+ /**
144
+ * Converts a sequence of UTF-8 bytes to a string.
145
+ * @param {Uint8Array|Buffer} data - The UTF-8 data.
146
+ * @returns {String} A string with each character representing a code point.
147
+ */
148
+ function utf8DataToString (data) {
149
+ return Buffer.from(data).toString('utf-8')
150
+ }
151
+
152
+ // For debugging.
153
+ function debugLog () {
154
+ if (kDebug) console.log(...arguments)
155
+ }
156
+
157
+ // For debugging.
158
+ function print7Bits (num) {
159
+ return '0000000'.substring(num.toString(2).length) + num.toString(2)
160
+ }
161
+
162
+ // For debugging.
163
+ // eslint-disable-next-line no-unused-vars
164
+ function print8Bits (num) {
165
+ return '00000000'.substring(num.toString(2).length) + num.toString(2)
166
+ }
167
+
168
+ export {
169
+ encode,
170
+ decode
171
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "nappup",
3
+ "author": "Arthur França",
4
+ "repository": "github:44billion/nappup",
5
+ "version": "1.0.0",
6
+ "description": "Nostr App Uploader",
7
+ "main": "index.js",
8
+ "type": "module",
9
+ "scripts": {
10
+ "test": "node --test 'test/**/*.test.js'"
11
+ },
12
+ "bin": {
13
+ "nappup": "./bin/nappup/index.js"
14
+ },
15
+ "dependencies": {
16
+ "dotenv": "^17.2.0",
17
+ "file-type": "^21.0.0",
18
+ "mime-types": "^3.0.1",
19
+ "nmmr": "^1.0.3",
20
+ "nostr-tools": "^2.15.0"
21
+ },
22
+ "devDependencies": {
23
+ "eslint": "^9.22.0",
24
+ "eslint-plugin-html": "^8.1.2",
25
+ "globals": "^15.12.0",
26
+ "neostandard": "^0.12.1"
27
+ },
28
+ "imports": {
29
+ "#bin/*": "./bin/*",
30
+ "#lib/*": "./lib/*",
31
+ "#*": [
32
+ "./src/*"
33
+ ],
34
+ "#helpers/*": "./src/helpers/*",
35
+ "#services/*": "./src/services/*"
36
+ },
37
+ "exports": {
38
+ ".": "./src/index.js"
39
+ },
40
+ "keywords": [
41
+ "nostr",
42
+ "app",
43
+ "uploader",
44
+ "upload"
45
+ ],
46
+ "files": [
47
+ "src",
48
+ "src/helpers",
49
+ "src/services",
50
+ "lib",
51
+ "bin",
52
+ "bin/nappup"
53
+ ]
54
+ }
File without changes
@@ -0,0 +1,12 @@
1
+ # helpers folder
2
+
3
+ - Place helper functions. A helper function is a single-step functionality to be re-used through out the code base.
4
+ - Each functionality is exported as a single function.
5
+ - These functions are not meant to be used within the test folder or sub-folders, except for being unit-tested themselves.
6
+
7
+ ## General Instructions:
8
+
9
+ - Prefer grouping functions that deal with the same type of variable within the
10
+ same file, preferably named after the type name. E.g.: use `helpers/string.js` for
11
+ exporting helper functions that handle strings, or e.g.: use helpers/stream.js` to
12
+ deal with streams.
@@ -0,0 +1,20 @@
1
+ // Receives a stream and yields Uint8Array binary chunks of a given size.
2
+ // The last chunk may be smaller than the chunkSize.
3
+ export async function * streamToChunks (stream, chunkSize) {
4
+ let buffer = new Uint8Array(0)
5
+
6
+ for await (const chunk of stream) {
7
+ const newBuffer = new Uint8Array(buffer.length + chunk.length)
8
+ newBuffer.set(buffer)
9
+ newBuffer.set(chunk, buffer.length)
10
+ buffer = newBuffer
11
+
12
+ while (buffer.length >= chunkSize) {
13
+ const chunkToYield = buffer.slice(0, chunkSize)
14
+ buffer = buffer.slice(chunkSize)
15
+ yield chunkToYield
16
+ }
17
+ }
18
+
19
+ if (buffer.length > 0) yield buffer
20
+ }
@@ -0,0 +1,4 @@
1
+ export function maybeUnref (timer) {
2
+ if (typeof window === 'undefined') timer.unref()
3
+ return timer
4
+ }
package/src/index.js ADDED
@@ -0,0 +1,131 @@
1
+ import NMMR from 'nmmr'
2
+ import { naddrEncode } from 'nostr-tools/nip19'
3
+ import Base122Encoder from '#services/base122-encoder.js'
4
+ import nostrRelays from '#services/nostr-relays.js'
5
+ import NostrSigner from '#services/nostr-signer.js'
6
+ import { streamToChunks } from '#helpers/stream.js'
7
+
8
+ export default async function (...args) {
9
+ try {
10
+ return await toApp(...args)
11
+ } finally {
12
+ await nostrRelays.disconnectAll()
13
+ }
14
+ }
15
+ export async function toApp (fileList, nostrSigner, { log = () => {}, appId, channel = 'main' } = {}) {
16
+ if (!nostrSigner && typeof window !== 'undefined') nostrSigner = window.nostr
17
+ if (!nostrSigner) throw new Error('No Nostr signer found')
18
+ if (typeof window !== 'undefined' && nostrSigner === window.nostr) {
19
+ nostrSigner.getRelays = NostrSigner.prototype.getRelays
20
+ }
21
+
22
+ appId ||= fileList[0].webkitRelativePath.split('/')[0]
23
+ .trim().replace(/[\s-]/g, '').toLowerCase().slice(0, 32)
24
+ let nmmr
25
+ const fileMetadata = []
26
+
27
+ log(`Processing ${fileList.length} files`)
28
+ for (const file of fileList) {
29
+ nmmr = new NMMR()
30
+ const stream = file.stream()
31
+
32
+ let chunkLength = 0
33
+ for await (const chunk of streamToChunks(stream, 54600)) {
34
+ chunkLength++
35
+ nmmr.append(chunk)
36
+ }
37
+ if (chunkLength) {
38
+ // remove root dir
39
+ const filename = file.webkitRelativePath.split('/').slice(1).join('/')
40
+ log(`Uploading ${chunkLength} file parts of ${filename}`)
41
+ await uploadBinaryDataChunks(nmmr, nostrSigner, { mimeType: file.type || 'application/octet-stream' })
42
+ fileMetadata.push({
43
+ rootHash: nmmr.getRoot(),
44
+ filename,
45
+ mimeType: file.type || 'application/octet-stream'
46
+ })
47
+ }
48
+ }
49
+
50
+ log(`Uploading bundle #${appId}`)
51
+ const bundle = await uploadBundle(appId, channel, fileMetadata, nostrSigner)
52
+
53
+ const naddr = naddrEncode({
54
+ identifier: bundle.tags.find(v => v[0] === 'd')[1],
55
+ pubkey: bundle.pubkey,
56
+ relays: [],
57
+ kind: bundle.kind
58
+ })
59
+ log(`Visit at https://44billion.net/${naddr}`)
60
+ }
61
+
62
+ async function uploadBinaryDataChunks (nmmr, signer, { mimeType } = {}) {
63
+ const writeRelays = (await signer.getRelays()).write
64
+ for await (const chunk of nmmr.getChunks()) {
65
+ const dTag = chunk.x
66
+ const currentCtag = `${chunk.rootX}:${chunk.index}`
67
+ const prevCTags = await getPreviousCtags(dTag, currentCtag, writeRelays, signer)
68
+ const binaryDataChunk = {
69
+ kind: 34600,
70
+ tags: [
71
+ ['d', dTag],
72
+ ...prevCTags,
73
+ ['c', currentCtag, chunk.length, ...chunk.proof],
74
+ ...(mimeType ? [['m', mimeType]] : [])
75
+ ],
76
+ // These chunks already have the expected size of 54600 bytes
77
+ content: new Base122Encoder().update(chunk.contentBytes).getEncoded(),
78
+ created_at: Math.floor(Date.now() / 1000)
79
+ }
80
+
81
+ const event = await signer.signEvent(binaryDataChunk)
82
+ await nostrRelays.sendEvent(event, writeRelays)
83
+ }
84
+ }
85
+
86
+ async function getPreviousCtags (dTagValue, currentCtagValue, writeRelays, signer) {
87
+ const storedEvents = await nostrRelays.getEvents({
88
+ kinds: [34600],
89
+ authors: [await signer.getPublicKey()],
90
+ '#d': [dTagValue],
91
+ limit: 1
92
+ }, writeRelays)
93
+ if (storedEvents.length === 0) return []
94
+
95
+ const cTagValues = { [currentCtagValue]: true }
96
+ const prevTags = storedEvents.sort((a, b) => b.created_at - a.created_at)[0].tags
97
+ if (!Array.isArray(prevTags)) return []
98
+ return prevTags
99
+ .filter(v => {
100
+ const isCTag =
101
+ Array.isArray(v) &&
102
+ v[0] === 'c' &&
103
+ typeof v[1] === 'string' &&
104
+ /^[0-9a-f]{64}:\d+$/.test(v[1])
105
+ if (!isCTag) return false
106
+
107
+ const isntDuplicate = !cTagValues[v[1]]
108
+ cTagValues[v[1]] = true
109
+ return isCTag && isntDuplicate
110
+ })
111
+ }
112
+
113
+ async function uploadBundle (appId, channel, fileMetadata, signer) {
114
+ const kind = {
115
+ main: 37448, // stable
116
+ next: 37449, // insider
117
+ draft: 37450 // vibe coded preview
118
+ }[channel] ?? 37448
119
+ const appBundle = {
120
+ kind,
121
+ tags: [
122
+ ['d', appId],
123
+ ...fileMetadata.map(v => ['file', v.rootHash, v.filename, v.mimeType])
124
+ ],
125
+ content: '',
126
+ created_at: Math.floor(Date.now() / 1000)
127
+ }
128
+ const event = await signer.signEvent(appBundle)
129
+ await nostrRelays.sendEvent(event, (await signer.getRelays()).write)
130
+ return event
131
+ }
File without changes
@@ -0,0 +1,9 @@
1
+ # src/services folder
2
+
3
+ - Place services here. Services are multi-step functionality that make the code more
4
+ readable than if placing every step together on a single function.
5
+
6
+ ## General Instructions:
7
+
8
+ - Use object-oriented programming paradigm.
9
+ - Each file default exports one class or singleton.
@@ -0,0 +1,56 @@
1
+ import { decode } from '#lib/base122.js'
2
+
3
+ // Decodes data from base122.
4
+ export default class Base122Decoder {
5
+ textEncoder = new TextEncoder()
6
+
7
+ constructor (source, { mimeType = '' } = {}) {
8
+ this.sourceIterator = source?.[Symbol.iterator]?.() || source?.[Symbol.asyncIterator]?.() || source()
9
+ this.isText = mimeType.startsWith('text/')
10
+ if (this.isText) this.textDecoder = new TextDecoder()
11
+ }
12
+
13
+ // decoder generator
14
+ * [Symbol.iterator] (base122String) {
15
+ let bytes
16
+ if (this.isText) {
17
+ while (base122String) {
18
+ bytes = this.textEncoder.encode(base122String) // from string to UInt8Array
19
+ // stream=true avoids cutting a multi-byte character
20
+ base122String = yield this.textDecoder.decode(new Uint8Array(decode(bytes)), { stream: true })
21
+ }
22
+ } else {
23
+ while (base122String) {
24
+ bytes = this.textEncoder.encode(base122String)
25
+ base122String = yield new Uint8Array(decode(bytes))
26
+ }
27
+ }
28
+ }
29
+
30
+ // Gets the decoded data.
31
+ getDecoded () { return iteratorToStream(this, this.sourceIterator) }
32
+ }
33
+
34
+ function iteratorToStream (decoder, sourceIterator) {
35
+ return new ReadableStream({
36
+ decoderIterator: null,
37
+ async start (controller) {
38
+ const { value: chunk, done } = await sourceIterator.next()
39
+ if (done) return controller.close()
40
+
41
+ // Pass first chunk when instantiating the decoder generator
42
+ this.decoderIterator = decoder[Symbol.iterator](chunk)
43
+ const { value } = this.decoderIterator.next()
44
+ if (value) controller.enqueue(value)
45
+ },
46
+ async pull (controller) {
47
+ if (!this.decoderIterator) return
48
+
49
+ const { value: chunk, done: sourceDone } = await sourceIterator.next()
50
+ const { value, done } = this.decoderIterator.next(chunk)
51
+
52
+ if (value) controller.enqueue(value)
53
+ if (done || sourceDone) controller.close()
54
+ }
55
+ })
56
+ }
@@ -0,0 +1,19 @@
1
+ import { encode } from '#lib/base122.js'
2
+
3
+ // Encodes data using base122.
4
+ export default class Base122Encoder {
5
+ textDecoder = new TextDecoder()
6
+ // The encoded data.
7
+ encoded = ''
8
+
9
+ // Updates the encoded data with the given bytes.
10
+ update (bytes) {
11
+ this.encoded += this.textDecoder.decode(new Uint8Array(encode(bytes)))
12
+ return this
13
+ }
14
+
15
+ // Gets the encoded data.
16
+ getEncoded () {
17
+ return this.encoded
18
+ }
19
+ }
@@ -0,0 +1,116 @@
1
+ import { Relay } from 'nostr-tools/relay'
2
+ import { maybeUnref } from '#helpers/timer.js'
3
+
4
+ export const seedRelays = [
5
+ 'wss://purplepag.es',
6
+ 'wss://user.kindpag.es',
7
+ 'wss://relay.nos.social',
8
+ 'wss://relay.nostr.band',
9
+ 'wss://nostr.land',
10
+ 'wss://indexer.coracle.social'
11
+ ]
12
+ export const freeRelays = [
13
+ 'wss://relay.damus.io',
14
+ 'wss://relay.nostr.band',
15
+ 'wss://nos.lol',
16
+ 'wss://relay.primal.net'
17
+ ]
18
+
19
+ // Interacts with Nostr relays.
20
+ export class NostrRelays {
21
+ #relays = new Map()
22
+ #relayTimeouts = new Map()
23
+ #timeout = 30000 // 30 seconds
24
+
25
+ // Get a relay connection, creating one if it doesn't exist.
26
+ async #getRelay (url) {
27
+ if (this.#relays.has(url)) {
28
+ clearTimeout(this.#relayTimeouts.get(url))
29
+ this.#relayTimeouts.set(url, maybeUnref(setTimeout(() => this.disconnect(url), this.#timeout)))
30
+ return this.#relays.get(url)
31
+ }
32
+
33
+ const relay = new Relay(url)
34
+ this.#relays.set(url, relay)
35
+
36
+ await relay.connect()
37
+
38
+ this.#relayTimeouts.set(url, maybeUnref(setTimeout(() => this.disconnect(url), this.#timeout)))
39
+
40
+ return relay
41
+ }
42
+
43
+ // Disconnect from a relay.
44
+ async disconnect (url) {
45
+ if (this.#relays.has(url)) {
46
+ await this.#relays.get(url).close()
47
+ this.#relays.delete(url)
48
+ clearTimeout(this.#relayTimeouts.get(url))
49
+ this.#relayTimeouts.delete(url)
50
+ }
51
+ }
52
+
53
+ // Disconnect from all relays.
54
+ async disconnectAll () {
55
+ for (const url of this.#relays.keys()) {
56
+ await this.disconnect(url)
57
+ }
58
+ }
59
+
60
+ // Get events from a list of relays
61
+ async getEvents (filter, relays, timeout = 5000) {
62
+ const events = []
63
+ const promises = relays.map(async (url) => {
64
+ try {
65
+ const relay = await this.#getRelay(url)
66
+ return new Promise((resolve) => {
67
+ const sub = relay.subscribe([filter], {
68
+ onevent: (event) => {
69
+ events.push(event)
70
+ },
71
+ onclose: () => {
72
+ clearTimeout(timer)
73
+ resolve()
74
+ },
75
+ oneose: () => {
76
+ clearTimeout(timer)
77
+ resolve()
78
+ }
79
+ })
80
+ const timer = maybeUnref(setTimeout(() => {
81
+ sub.close()
82
+ resolve()
83
+ }, timeout))
84
+ })
85
+ } catch (error) {
86
+ console.error(`Failed to get events from ${url}`, error)
87
+ }
88
+ })
89
+
90
+ const results = await Promise.allSettled(promises)
91
+ if (results.some(v => v.status === 'rejected')) throw new Error(results[0].reason)
92
+ return events
93
+ }
94
+
95
+ // Send an event to a list of relays.
96
+ async sendEvent (event, relays, timeout = 3000) {
97
+ const promises = relays.map(async (url) => {
98
+ try {
99
+ const relay = await this.#getRelay(url)
100
+ const timer = maybeUnref(setTimeout(() => {
101
+ throw new Error(`Timeout sending event to ${url}`)
102
+ }, timeout))
103
+ await relay.publish(event)
104
+ clearTimeout(timer)
105
+ } catch (error) {
106
+ console.error(`Failed to send event to ${url}`, error)
107
+ }
108
+ })
109
+
110
+ const results = await Promise.allSettled(promises)
111
+ if (results.some(v => v.status === 'rejected')) throw new Error(results[0].reason)
112
+ }
113
+ }
114
+ // Share same connection.
115
+ // Connections aren't authenticated, thus no need to split by authed user.
116
+ export default new NostrRelays()
@@ -0,0 +1,157 @@
1
+ import fs from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+ import * as dotenv from 'dotenv'
4
+ import { getPublicKey, finalizeEvent } from 'nostr-tools/pure'
5
+ import { getConversationKey, encrypt, decrypt } from 'nostr-tools/nip44'
6
+ import nostrRelays, { seedRelays, freeRelays } from '#services/nostr-relays.js'
7
+ const __dirname = fileURLToPath(new URL('.', import.meta.url))
8
+
9
+ dotenv.config({
10
+ path: process.env.DOTENV_CONFIG_PATH ?? `${__dirname}/../../.env`,
11
+ quiet: true
12
+ })
13
+
14
+ const nip44 = {
15
+ getConversationKey,
16
+ encrypt,
17
+ decrypt
18
+ }
19
+
20
+ const createToken = Symbol('createToken')
21
+
22
+ export default class NostrSigner {
23
+ #secretKey // bytes
24
+ #publicKey // hex
25
+
26
+ constructor (token, skBytes) {
27
+ if (token !== createToken) throw new Error('Use NostrSigner.create(?sk) to instantiate this class.')
28
+ if (!skBytes) throw new Error('Secret key missing.')
29
+
30
+ this.#secretKey = skBytes
31
+ }
32
+
33
+ static async create (skHex) {
34
+ if (skHex) return new this(createToken, hexToBytes(skHex))
35
+
36
+ let skBytes
37
+ let isNewSk = false
38
+ if (process.env.NOSTR_SECRET_KEY) {
39
+ skBytes = hexToBytes(process.env.NOSTR_SECRET_KEY)
40
+ } else {
41
+ isNewSk = true
42
+ skHex = generateSecretKey()
43
+ fs.appendFileSync('.env', `NOSTR_SECRET_KEY=${skHex}\n`)
44
+ skBytes = hexToBytes(skHex)
45
+ }
46
+ const ret = new this(createToken, skBytes)
47
+ if (isNewSk) await ret.#initSk(skHex)
48
+ return ret
49
+ }
50
+
51
+ async getRelays () {
52
+ if (this.relays) return this.relays
53
+
54
+ const relayLists = await nostrRelays.getEvents({ authors: [await this.getPublicKey()], kinds: [10002], limit: 1 }, seedRelays)
55
+ const relayList = relayLists.sort((a, b) => b.created_at - a.created_at)[0]
56
+ const rTags = relayList.tags.filter(v => v[0] === 'r' && /^wss?:\/\//.test(v[1]))
57
+ if (rTags.length === 0) return (this.relays = await this.#initRelays())
58
+
59
+ let keys
60
+ const keyAllowList = { read: true, write: true }
61
+ const relays = rTags.reduce((r, v) => {
62
+ keys = [v[2]].filter(v2 => keyAllowList[v2])
63
+ if (keys.length === 0) keys = ['read', 'write']
64
+ keys.forEach(k => r[k].push(v[1]))
65
+ return r
66
+ }, { read: [], write: [] })
67
+ Object.values(relays).forEach(v => { v.length === 0 && v.push(...freeRelays) })
68
+ return (this.relays = relays)
69
+ }
70
+
71
+ async #initRelays () {
72
+ const relays = freeRelays.slice(0, 2)
73
+ this.relays = {
74
+ read: relays,
75
+ write: relays
76
+ }
77
+ const relayList = await this.signEvent({
78
+ kind: 10002,
79
+ pubkey: await this.getPublicKey(),
80
+ tags: relays.map(v => ['r', v]),
81
+ content: '',
82
+ created_at: Math.floor(Date.now() / 1000)
83
+ })
84
+ await nostrRelays.sendEvent(relayList, [...new Set([...seedRelays, ...this.relays.write])])
85
+ return this.relays
86
+ }
87
+
88
+ async #initSk () {
89
+ const pubkey = this.getPublicKey()
90
+ const profile = this.signEvent({
91
+ kind: 0,
92
+ pubkey,
93
+ tags: [],
94
+ content: JSON.stringify({
95
+ name: `Publisher #${Math.random().toString(36).slice(2)}`,
96
+ about: 'An auto-generated https://44billion.net app publisher'
97
+ }),
98
+ created_at: Math.floor(Date.now() / 1000)
99
+ })
100
+ const writeRelays = (await this.getRelays()).write
101
+ await nostrRelays.sendEvent(profile, writeRelays)
102
+ }
103
+
104
+ // hex
105
+ getPublicKey () {
106
+ if (this.#publicKey) {
107
+ return this.#publicKey
108
+ }
109
+ this.#publicKey = getPublicKey(this.#secretKey)
110
+ return this.#publicKey
111
+ }
112
+
113
+ signEvent (event) {
114
+ return finalizeEvent(event, this.#secretKey)
115
+ }
116
+
117
+ nip44 = {
118
+ encrypt: this.nip44Encrypt.bind(this),
119
+ decrypt: this.nip44Decrypt.bind(this)
120
+ }
121
+
122
+ nip44Encrypt (pubkey, plaintext) {
123
+ const conversationKey = nip44.getConversationKey(this.#secretKey, pubkey)
124
+ return nip44.encrypt(conversationKey, plaintext)
125
+ }
126
+
127
+ nip44Decrypt (pubkey, ciphertext) {
128
+ const conversationKey = nip44.getConversationKey(this.#secretKey, pubkey)
129
+ return nip44.decrypt(conversationKey, ciphertext)
130
+ }
131
+ }
132
+
133
+ function bytesToHex (uint8aBytes) {
134
+ return Array.from(uint8aBytes).map(b => b.toString(16).padStart(2, '0')).join('')
135
+ }
136
+
137
+ function hexToBytes (hexString) {
138
+ const arr = new Uint8Array(hexString.length / 2) // create result array
139
+ for (let i = 0; i < arr.length; i++) {
140
+ const j = i * 2
141
+ const h = hexString.slice(j, j + 2)
142
+ const b = Number.parseInt(h, 16) // byte, created from string part
143
+ if (Number.isNaN(b) || b < 0) throw new Error('invalid hex')
144
+ arr[i] = b
145
+ }
146
+ return arr
147
+ }
148
+
149
+ function generateSecretKey () {
150
+ const randomBytes = crypto.getRandomValues(new Uint8Array(40))
151
+ const B256 = 2n ** 256n // secp256k1 is short weierstrass curve
152
+ const N = B256 - 0x14551231950b75fc4402da1732fc9bebfn // curve (group) order
153
+ const bytesToNumber = b => BigInt('0x' + (bytesToHex(b) || '0'))
154
+ const mod = (a, b) => { const r = a % b; return r >= 0n ? r : b + r } // mod division
155
+ const num = mod(bytesToNumber(randomBytes), N - 1n) + 1n // takes at least n+8 bytes
156
+ return num.toString(16).padStart(64, '0')
157
+ }