@humanwhocodes/crosspost
Version:
A utility to post across multiple social networks.
346 lines (345 loc) • 12.9 kB
JavaScript
/**
* @fileoverview Nostr strategy for posting messages.
* @author Nicholas C. Zakas
*/
/* global Buffer, WebSocket, setTimeout */
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { schnorr, hashes } from "@noble/secp256k1";
import { bech32 } from "bech32";
import { createHash, createHmac } from "node:crypto";
import { validatePostOptions } from "../util/options.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/** @typedef {import("../types.js").PostOptions} PostOptions */
/**
* @typedef {Object} NostrOptions
* @property {string} privateKey The private key for signing events (hex or bech32).
* @property {string[]} relays Array of relay URLs to post to.
*/
/**
* @typedef {Object} NostrEvent
* @property {string} id The event ID (32-byte hex).
* @property {string} pubkey The public key of the event creator (32-byte hex).
* @property {number} created_at Unix timestamp in seconds.
* @property {number} kind The event kind (1 for short text note).
* @property {string[][]} tags Array of tags.
* @property {string} content The event content.
* @property {string} sig The signature (64-byte hex).
*/
/**
* @typedef {Object} NostrEventResponse
* @property {string} id The event ID.
* @property {boolean} success Whether the event was published successfully.
* @property {string[]} relays Array of relays that accepted the event.
* @property {string[]} errors Array of relay errors.
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/*
* Configure noble/secp256k1 to use Node.js crypto for hashing.
* This is necessary because the default Web Crypto API in Node.js
* does not support synchronous hashing, which noble/secp256k1 requires.
*/
hashes.sha256 = /** @type {(message: Uint8Array) => Uint8Array} */ (msg => createHash("sha256").update(msg).digest());
hashes.hmacSha256 =
/** @type {(key: Uint8Array, ...messages: Uint8Array[]) => Uint8Array} */ ((key, ...msgs) => {
const hmac = createHmac("sha256", key);
for (const msg of msgs) {
hmac.update(msg);
}
return hmac.digest();
});
/**
* Converts a private key from bech32 format to hex.
* @param {string} privateKey The private key in hex or bech32 format.
* @returns {string} The private key in hex format.
* @throws {Error} If the key format is invalid.
*/
function normalizePrivateKey(privateKey) {
// Check if it's already hex (64 characters)
if (/^[0-9a-f]{64}$/i.test(privateKey)) {
return privateKey.toLowerCase();
}
// Try to decode bech32
if (privateKey.startsWith("nsec1")) {
try {
const { words } = bech32.decode(privateKey);
const keyBytes = bech32.fromWords(words);
return Buffer.from(keyBytes).toString("hex");
}
catch (error) {
throw new Error("Invalid bech32 private key format", {
cause: error,
});
}
}
throw new Error("Private key must be 64-character hex string or bech32 format starting with nsec1");
}
/**
* Creates a Nostr event.
* @param {string} privateKeyHex The private key in hex format.
* @param {string} content The content of the event.
* @returns {NostrEvent} The signed Nostr event.
*/
function createNostrEvent(privateKeyHex, content) {
const privateKeyBytes = new Uint8Array(Buffer.from(privateKeyHex, "hex"));
// Get the Schnorr public key (x-only, 32 bytes)
const publicKeyBytes = schnorr.getPublicKey(privateKeyBytes);
const pubkey = Buffer.from(publicKeyBytes).toString("hex");
const event = {
pubkey,
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content,
};
// Calculate event ID (hash of serialized event data)
const eventData = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content,
]);
const eventHash = createHash("sha256").update(eventData, "utf8").digest();
const id = eventHash.toString("hex");
// Sign the event hash using Schnorr signatures
const signature = schnorr.sign(eventHash, privateKeyBytes);
const sig = Buffer.from(signature).toString("hex");
return {
id,
...event,
sig,
};
}
/**
* Publishes an event to a Nostr relay via WebSocket.
* @param {string} relayUrl The relay URL.
* @param {NostrEvent} event The event to publish.
* @param {AbortSignal} [signal] Optional abort signal.
* @returns {Promise<{success: boolean, error?: string}>} The result of publishing.
*/
function publishToRelay(relayUrl, event, signal) {
return new Promise(resolve => {
/** @type {WebSocket | undefined} */
let ws;
let resolved = false;
const cleanup = () => {
if (ws) {
ws.close();
}
};
/** @param {{success: boolean, error?: string}} result */
const resolveOnce = result => {
if (!resolved) {
resolved = true;
cleanup();
resolve(result);
}
};
// Handle abort signal
if (signal?.aborted) {
return resolveOnce({
success: false,
error: "Request was aborted",
});
}
const abortHandler = () => {
resolveOnce({ success: false, error: "Request was aborted" });
};
signal?.addEventListener("abort", abortHandler);
try {
ws = new WebSocket(relayUrl);
ws.addEventListener("open", () => {
const message = JSON.stringify(["EVENT", event]);
if (ws) {
ws.send(message);
}
});
ws.addEventListener("message", msgEvent => {
try {
const data = JSON.parse(msgEvent.data.toString());
if (Array.isArray(data) &&
data[0] === "OK" &&
data[1] === event.id) {
const success = data[2] === true;
const error = success ? undefined : data[3];
resolveOnce({ success, error });
}
}
catch (error) {
const errorMessage = error instanceof Error
? error.message
: "Unknown error";
resolveOnce({
success: false,
error: "Invalid response from relay: " + errorMessage,
});
}
});
ws.addEventListener("error", () => {
resolveOnce({
success: false,
error: "WebSocket connection failed",
});
});
ws.addEventListener("close", () => {
if (!resolved) {
resolveOnce({
success: false,
error: "Connection closed unexpectedly",
});
}
});
// Timeout after 10 seconds
setTimeout(() => {
resolveOnce({ success: false, error: "Request timed out" });
}, 10000);
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
resolveOnce({ success: false, error: errorMessage });
}
finally {
signal?.removeEventListener("abort", abortHandler);
}
});
}
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* A strategy for posting messages to Nostr.
*/
export class NostrStrategy {
/**
* The ID of the strategy.
* @type {string}
* @readonly
*/
id = "nostr";
/**
* The display name of the strategy.
* @type {string}
* @readonly
*/
name = "Nostr";
/**
* Maximum length of a Nostr message in characters.
* Most Nostr clients use similar limits to Twitter.
* @type {number}
* @const
*/
MAX_MESSAGE_LENGTH = 280;
/**
* Options for this instance.
* @type {NostrOptions & {privateKeyHex: string}}
*/
#options;
/**
* Creates a new instance.
* @param {NostrOptions} options Options for the instance.
* @throws {Error} When options are missing or invalid.
*/
constructor(options) {
if (!options) {
throw new Error("Missing options for NostrStrategy.");
}
if (!options.privateKey) {
throw new Error("Missing private key.");
}
if (!options.relays || !Array.isArray(options.relays)) {
throw new Error("Missing or invalid relays array.");
}
if (options.relays.length === 0) {
throw new Error("At least one relay URL is required.");
}
// Validate relay URLs
for (const relay of options.relays) {
if (typeof relay !== "string" || !relay.startsWith("ws")) {
throw new Error("All relay URLs must be valid WebSocket URLs (ws:// or wss://).");
}
}
// Normalize and validate private key
const privateKeyHex = normalizePrivateKey(options.privateKey);
this.#options = {
...options,
privateKeyHex,
};
}
/**
* Calculates the length of a message according to Nostr's counting rules.
* @param {string} message The message to calculate the length of.
* @returns {number} The length of the message.
*/
calculateMessageLength(message) {
// Simple character count for Nostr
return [...message].length;
}
/**
* Posts a message to Nostr relays.
* @param {string} message The message to post.
* @param {PostOptions} [postOptions] Additional options for the post.
* @returns {Promise<NostrEventResponse>} A promise that resolves with the event response.
* @throws {Error} When the message fails to post to all relays.
*/
async post(message, postOptions) {
if (!message) {
throw new TypeError("Missing message to post.");
}
validatePostOptions(postOptions);
// Nostr doesn't support images in kind 1 events
if (postOptions?.images?.length) {
throw new Error("Images are not supported in Nostr text notes.");
}
// Create the Nostr event
const event = createNostrEvent(this.#options.privateKeyHex, message);
// Publish to all relays
const results = await Promise.allSettled(this.#options.relays.map(relay => publishToRelay(relay, event, postOptions?.signal)));
/** @type {string[]} */
const successfulRelays = [];
/** @type {string[]} */
const errors = [];
results.forEach((result, index) => {
const relay = this.#options.relays[index];
if (result.status === "fulfilled" && result.value.success) {
successfulRelays.push(relay);
}
else {
const error = result.status === "rejected"
? result.reason.message
: result.value.error;
errors.push(`${relay}: ${error}`);
}
});
// If no relays succeeded, throw an error
if (successfulRelays.length === 0) {
throw new Error(`Failed to publish to any relays: ${errors.join(", ")}`);
}
return {
id: event.id,
success: true,
relays: successfulRelays,
errors,
};
}
/**
* Gets a URL from the response (Nostr doesn't have a standard URL format).
* @param {NostrEventResponse} response The response from posting.
* @returns {string} A note URL (using the first successful relay).
*/
getUrlFromResponse(response) {
if (!response || !response.relays || response.relays.length === 0) {
throw new Error("No successful relays in response");
}
// Use nip19 note format with event ID
const noteId = `${bech32.encode("note", bech32.toWords(Buffer.from(response.id, "hex")))}`;
// Return a generic note URL - in practice, clients would construct their own URLs
return `nostr:${noteId}`;
}
}