Files
nostr_login_lite/lite/build.js
2025-09-14 13:23:52 -04:00

1346 lines
41 KiB
JavaScript

/**
* 🏗️ NOSTR_LOGIN_LITE Build Script
*
* ⚠️ IMPORTANT: This file contains the source code for the NOSTR_LOGIN_LITE library!
* ⚠️ DO NOT edit lite/nostr-lite.js directly - it's auto-generated by this script!
* ⚠️ To modify the library, edit this file (build.js) and run: node build.js
*
* This script builds the two-file architecture:
* 1. nostr.bundle.js (official nostr-tools bundle - static file)
* 2. nostr-lite.js (NOSTR_LOGIN_LITE library - built by this script)
*
* Features included:
* - CSS-Only Theme System (no JSON duplication)
* - Modal UI Component
* - FloatingTab Component
* - Extension Bridge
* - Window.nostr facade
* - Main NostrLite class with all functionality
*/
const fs = require('fs');
const path = require('path');
function createNostrLoginLiteBundle() {
console.log('🔧 Creating NOSTR_LOGIN_LITE bundle for two-file architecture...');
const outputPath = path.join(__dirname, 'nostr-lite.js');
// Remove old bundle
try {
if (fs.existsSync(outputPath)) {
fs.unlinkSync(outputPath);
}
} catch (e) {
console.log('No old bundle to remove');
}
// Start with the bundle header
let bundle = `/**
* NOSTR_LOGIN_LITE - Authentication Library
*
* ⚠️ WARNING: THIS FILE IS AUTO-GENERATED - DO NOT EDIT MANUALLY!
* ⚠️ To make changes, edit lite/build.js and run: cd lite && node build.js
* ⚠️ Any manual edits to this file will be OVERWRITTEN when build.js runs!
*
* Two-file architecture:
* 1. Load nostr.bundle.js (official nostr-tools bundle)
* 2. Load nostr-lite.js (this file - NOSTR_LOGIN_LITE library with CSS-only themes)
* Generated on: ${new Date().toISOString()}
*/
// Verify dependencies are loaded
if (typeof window !== 'undefined') {
if (!window.NostrTools) {
console.error('NOSTR_LOGIN_LITE: nostr.bundle.js must be loaded first');
throw new Error('Missing dependency: nostr.bundle.js');
}
console.log('NOSTR_LOGIN_LITE: Dependencies verified ✓');
console.log('NOSTR_LOGIN_LITE: NostrTools available with keys:', Object.keys(window.NostrTools));
console.log('NOSTR_LOGIN_LITE: NIP-46 available:', !!window.NostrTools.nip46);
}
// ===== NIP-46 Extension Integration =====
// Add NIP-46 functionality to NostrTools if not already present
if (typeof window.NostrTools !== 'undefined' && !window.NostrTools.nip46) {
console.log('NOSTR_LOGIN_LITE: Adding NIP-46 extension to NostrTools');
const { nip44, generateSecretKey, getPublicKey, finalizeEvent, verifyEvent, utils } = window.NostrTools;
// NIP-05 regex for parsing
const NIP05_REGEX = /^(?:([\\w.+-]+)@)?([\\w_-]+(\.[\\w_-]+)+)$/;
const BUNKER_REGEX = /^bunker:\\/\\/([0-9a-f]{64})\\??([?\\/\\w:.=&%-]*)$/;
const EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;
// Event kinds
const NostrConnect = 24133;
const ClientAuth = 22242;
const Handlerinformation = 31990;
// Fetch implementation
let _fetch;
try {
_fetch = fetch;
} catch {
_fetch = null;
}
function useFetchImplementation(fetchImplementation) {
_fetch = fetchImplementation;
}
// Simple Pool implementation for NIP-46
class SimplePool {
constructor() {
this.relays = new Map();
this.subscriptions = new Map();
}
async ensureRelay(url) {
if (!this.relays.has(url)) {
console.log(\`NIP-46: Connecting to relay \${url}\`);
const ws = new WebSocket(url);
const relay = {
ws,
connected: false,
subscriptions: new Map()
};
this.relays.set(url, relay);
// Wait for connection with proper event handlers
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
console.error(\`NIP-46: Connection timeout for \${url}\`);
reject(new Error(\`Connection timeout to \${url}\`));
}, 10000); // 10 second timeout
ws.onopen = () => {
console.log(\`NIP-46: Successfully connected to relay \${url}, WebSocket state: \${ws.readyState}\`);
relay.connected = true;
clearTimeout(timeout);
resolve();
};
ws.onerror = (error) => {
console.error(\`NIP-46: Failed to connect to \${url}:\`, error);
clearTimeout(timeout);
reject(new Error(\`Failed to connect to \${url}: \${error.message || 'Connection failed'}\`));
};
ws.onclose = (event) => {
console.log(\`NIP-46: Disconnected from relay \${url}:\`, event.code, event.reason);
relay.connected = false;
if (this.relays.has(url)) {
this.relays.delete(url);
}
clearTimeout(timeout);
reject(new Error(\`Connection closed during setup: \${event.reason || 'Unknown reason'}\`));
};
});
} else {
const relay = this.relays.get(url);
// Verify the existing connection is still open
if (!relay.connected || relay.ws.readyState !== WebSocket.OPEN) {
console.log(\`NIP-46: Reconnecting to relay \${url}\`);
this.relays.delete(url);
return await this.ensureRelay(url); // Recursively reconnect
}
}
const relay = this.relays.get(url);
console.log(\`NIP-46: Relay \${url} ready, WebSocket state: \${relay.ws.readyState}\`);
return relay;
}
subscribe(relays, filters, params = {}) {
const subId = Math.random().toString(36).substring(7);
relays.forEach(async (url) => {
try {
const relay = await this.ensureRelay(url);
relay.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data[0] === 'EVENT' && data[1] === subId) {
params.onevent?.(data[2]);
} else if (data[0] === 'EOSE' && data[1] === subId) {
params.oneose?.();
}
} catch (err) {
console.warn('Failed to parse message:', err);
}
};
// Ensure filters is an array
const filtersArray = Array.isArray(filters) ? filters : [filters];
const reqMsg = JSON.stringify(['REQ', subId, ...filtersArray]);
relay.ws.send(reqMsg);
} catch (err) {
console.warn('Failed to connect to relay:', url, err);
}
});
return {
close: () => {
relays.forEach(async (url) => {
const relay = this.relays.get(url);
if (relay?.connected) {
relay.ws.send(JSON.stringify(['CLOSE', subId]));
}
});
}
};
}
async publish(relays, event) {
console.log(\`NIP-46: Publishing event to \${relays.length} relays:\`, event);
const promises = relays.map(async (url) => {
try {
console.log(\`NIP-46: Attempting to publish to \${url}\`);
const relay = await this.ensureRelay(url);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
console.error(\`NIP-46: Publish timeout to \${url}\`);
reject(new Error(\`Publish timeout to \${url}\`));
}, 10000); // Increased timeout to 10 seconds
// Set up message handler for this specific event
const messageHandler = (msg) => {
try {
const data = JSON.parse(msg.data);
if (data[0] === 'OK' && data[1] === event.id) {
clearTimeout(timeout);
relay.ws.removeEventListener('message', messageHandler);
if (data[2]) {
console.log(\`NIP-46: Publish success to \${url}:\`, data[3]);
resolve(data[3]);
} else {
console.error(\`NIP-46: Publish rejected by \${url}:\`, data[3]);
reject(new Error(\`Publish rejected: \${data[3]}\`));
}
}
} catch (err) {
console.error(\`NIP-46: Error parsing message from \${url}:\`, err);
clearTimeout(timeout);
relay.ws.removeEventListener('message', messageHandler);
reject(err);
}
};
relay.ws.addEventListener('message', messageHandler);
// Double-check WebSocket state before sending
console.log(\`NIP-46: About to publish to \${url}, WebSocket state: \${relay.ws.readyState} (0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED)\`);
if (relay.ws.readyState === WebSocket.OPEN) {
console.log(\`NIP-46: Sending event to \${url}\`);
relay.ws.send(JSON.stringify(['EVENT', event]));
} else {
console.error(\`NIP-46: WebSocket not ready for \${url}, state: \${relay.ws.readyState}\`);
clearTimeout(timeout);
relay.ws.removeEventListener('message', messageHandler);
reject(new Error(\`WebSocket not ready for \${url}, state: \${relay.ws.readyState}\`));
}
});
} catch (err) {
console.error(\`NIP-46: Failed to publish to \${url}:\`, err);
return Promise.reject(new Error(\`Failed to publish to \${url}: \${err.message}\`));
}
});
const results = await Promise.allSettled(promises);
console.log(\`NIP-46: Publish results:\`, results);
return results;
}
async querySync(relays, filter, params = {}) {
return new Promise((resolve) => {
const events = [];
this.subscribe(relays, [filter], {
...params,
onevent: (event) => events.push(event),
oneose: () => resolve(events)
});
});
}
}
// Bunker URL utilities
function toBunkerURL(bunkerPointer) {
let bunkerURL = new URL(\`bunker://\${bunkerPointer.pubkey}\`);
bunkerPointer.relays.forEach((relay) => {
bunkerURL.searchParams.append('relay', relay);
});
if (bunkerPointer.secret) {
bunkerURL.searchParams.set('secret', bunkerPointer.secret);
}
return bunkerURL.toString();
}
async function parseBunkerInput(input) {
let match = input.match(BUNKER_REGEX);
if (match) {
try {
const pubkey = match[1];
const qs = new URLSearchParams(match[2]);
return {
pubkey,
relays: qs.getAll('relay'),
secret: qs.get('secret')
};
} catch (_err) {
// Continue to NIP-05 parsing
}
}
return queryBunkerProfile(input);
}
async function queryBunkerProfile(nip05) {
if (!_fetch) {
throw new Error('Fetch implementation not available');
}
const match = nip05.match(NIP05_REGEX);
if (!match) return null;
const [_, name = '_', domain] = match;
try {
const url = \`https://\${domain}/.well-known/nostr.json?name=\${name}\`;
const res = await (await _fetch(url, { redirect: 'error' })).json();
let pubkey = res.names[name];
let relays = res.nip46[pubkey] || [];
return { pubkey, relays, secret: null };
} catch (_err) {
return null;
}
}
// BunkerSigner class
class BunkerSigner {
constructor(clientSecretKey, bp, params = {}) {
if (bp.relays.length === 0) {
throw new Error('no relays are specified for this bunker');
}
this.params = params;
this.pool = params.pool || new SimplePool();
this.secretKey = clientSecretKey;
this.conversationKey = nip44.getConversationKey(clientSecretKey, bp.pubkey);
this.bp = bp;
this.isOpen = false;
this.idPrefix = Math.random().toString(36).substring(7);
this.serial = 0;
this.listeners = {};
this.waitingForAuth = {};
this.ready = false;
this.readyPromise = this.setupSubscription(params);
}
async setupSubscription(params) {
console.log('NIP-46: Setting up subscription to relays:', this.bp.relays);
const listeners = this.listeners;
const waitingForAuth = this.waitingForAuth;
const convKey = this.conversationKey;
// Ensure all relays are connected first
await Promise.all(this.bp.relays.map(url => this.pool.ensureRelay(url)));
console.log('NIP-46: All relays connected, setting up subscription');
this.subCloser = this.pool.subscribe(
this.bp.relays,
[{ kinds: [NostrConnect], authors: [this.bp.pubkey], '#p': [getPublicKey(this.secretKey)] }],
{
onevent: async (event) => {
const o = JSON.parse(nip44.decrypt(event.content, convKey));
const { id, result, error } = o;
if (result === 'auth_url' && waitingForAuth[id]) {
delete waitingForAuth[id];
if (params.onauth) {
params.onauth(error);
} else {
console.warn(
\`NIP-46: remote signer \${this.bp.pubkey} tried to send an "auth_url"='\${error}' but there was no onauth() callback configured.\`
);
}
return;
}
let handler = listeners[id];
if (handler) {
if (error) handler.reject(error);
else if (result) handler.resolve(result);
delete listeners[id];
}
},
onclose: () => {
this.subCloser = undefined;
}
}
);
this.isOpen = true;
this.ready = true;
console.log('NIP-46: BunkerSigner setup complete and ready');
}
async ensureReady() {
if (!this.ready) {
console.log('NIP-46: Waiting for BunkerSigner to be ready...');
await this.readyPromise;
}
}
async close() {
this.isOpen = false;
this.subCloser?.close();
}
async sendRequest(method, params) {
return new Promise(async (resolve, reject) => {
try {
await this.ensureReady(); // Wait for BunkerSigner to be ready
if (!this.isOpen) {
throw new Error('this signer is not open anymore, create a new one');
}
if (!this.subCloser) {
await this.setupSubscription(this.params);
}
this.serial++;
const id = \`\${this.idPrefix}-\${this.serial}\`;
const encryptedContent = nip44.encrypt(JSON.stringify({ id, method, params }), this.conversationKey);
const verifiedEvent = finalizeEvent(
{
kind: NostrConnect,
tags: [['p', this.bp.pubkey]],
content: encryptedContent,
created_at: Math.floor(Date.now() / 1000)
},
this.secretKey
);
this.listeners[id] = { resolve, reject };
this.waitingForAuth[id] = true;
console.log(\`NIP-46: Sending \${method} request with id \${id}\`);
const publishResults = await this.pool.publish(this.bp.relays, verifiedEvent);
// Check if at least one publish succeeded
const hasSuccess = publishResults.some(result => result.status === 'fulfilled');
if (!hasSuccess) {
throw new Error('Failed to publish to any relay');
}
console.log(\`NIP-46: \${method} request sent successfully\`);
} catch (err) {
console.error(\`NIP-46: sendRequest \${method} failed:\`, err);
reject(err);
}
});
}
async ping() {
let resp = await this.sendRequest('ping', []);
if (resp !== 'pong') {
throw new Error(\`result is not pong: \${resp}\`);
}
}
async connect() {
await this.sendRequest('connect', [this.bp.pubkey, this.bp.secret || '']);
}
async getPublicKey() {
if (!this.cachedPubKey) {
this.cachedPubKey = await this.sendRequest('get_public_key', []);
}
return this.cachedPubKey;
}
async signEvent(event) {
let resp = await this.sendRequest('sign_event', [JSON.stringify(event)]);
let signed = JSON.parse(resp);
if (verifyEvent(signed)) {
return signed;
} else {
throw new Error(\`event returned from bunker is improperly signed: \${JSON.stringify(signed)}\`);
}
}
async nip04Encrypt(thirdPartyPubkey, plaintext) {
return await this.sendRequest('nip04_encrypt', [thirdPartyPubkey, plaintext]);
}
async nip04Decrypt(thirdPartyPubkey, ciphertext) {
return await this.sendRequest('nip04_decrypt', [thirdPartyPubkey, ciphertext]);
}
async nip44Encrypt(thirdPartyPubkey, plaintext) {
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, plaintext]);
}
async nip44Decrypt(thirdPartyPubkey, ciphertext) {
return await this.sendRequest('nip44_decrypt', [thirdPartyPubkey, ciphertext]);
}
}
async function createAccount(bunker, params, username, domain, email, localSecretKey = generateSecretKey()) {
if (email && !EMAIL_REGEX.test(email)) {
throw new Error('Invalid email');
}
let rpc = new BunkerSigner(localSecretKey, bunker.bunkerPointer, params);
let pubkey = await rpc.sendRequest('create_account', [username, domain, email || '']);
rpc.bp.pubkey = pubkey;
await rpc.connect();
return rpc;
}
async function fetchBunkerProviders(pool, relays) {
const events = await pool.querySync(relays, {
kinds: [Handlerinformation],
'#k': [NostrConnect.toString()]
});
events.sort((a, b) => b.created_at - a.created_at);
const validatedBunkers = await Promise.all(
events.map(async (event, i) => {
try {
const content = JSON.parse(event.content);
try {
if (events.findIndex((ev) => JSON.parse(ev.content).nip05 === content.nip05) !== i) {
return undefined;
}
} catch (err) {
// Continue processing
}
const bp = await queryBunkerProfile(content.nip05);
if (bp && bp.pubkey === event.pubkey && bp.relays.length) {
return {
bunkerPointer: bp,
nip05: content.nip05,
domain: content.nip05.split('@')[1],
name: content.name || content.display_name,
picture: content.picture,
about: content.about,
website: content.website,
local: false
};
}
} catch (err) {
return undefined;
}
})
);
return validatedBunkers.filter((b) => b !== undefined);
}
// Extend NostrTools with NIP-46 functionality
window.NostrTools.nip46 = {
BunkerSigner,
parseBunkerInput,
toBunkerURL,
queryBunkerProfile,
createAccount,
fetchBunkerProviders,
useFetchImplementation,
BUNKER_REGEX,
SimplePool
};
console.log('NIP-46 extension loaded successfully');
console.log('Available: NostrTools.nip46');
}
// ======================================
// NOSTR_LOGIN_LITE Components
// ======================================
`;
// Embed CSS themes
console.log('🎨 Adding CSS-Only Theme System...');
const defaultThemeCssPath = path.join(__dirname, '../themes/default/theme.css');
const darkThemeCssPath = path.join(__dirname, '../themes/dark/theme.css');
if (fs.existsSync(defaultThemeCssPath) && fs.existsSync(darkThemeCssPath)) {
const defaultThemeCss = fs.readFileSync(defaultThemeCssPath, 'utf8')
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\${/g, '\\${');
const darkThemeCss = fs.readFileSync(darkThemeCssPath, 'utf8')
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\${/g, '\\${');
bundle += `// ======================================\n`;
bundle += `// CSS-Only Theme System\n`;
bundle += `// ======================================\n\n`;
bundle += `const THEME_CSS = {\n`;
bundle += ` 'default': \`${defaultThemeCss}\`,\n`;
bundle += ` 'dark': \`${darkThemeCss}\`\n`;
bundle += `};\n\n`;
bundle += `// Theme management functions\n`;
bundle += `function injectThemeCSS(themeName = 'default') {\n`;
bundle += ` if (typeof document !== 'undefined') {\n`;
bundle += ` // Remove existing theme CSS\n`;
bundle += ` const existingStyle = document.getElementById('nl-theme-css');\n`;
bundle += ` if (existingStyle) {\n`;
bundle += ` existingStyle.remove();\n`;
bundle += ` }\n`;
bundle += ` \n`;
bundle += ` // Inject selected theme CSS\n`;
bundle += ` const themeCss = THEME_CSS[themeName] || THEME_CSS['default'];\n`;
bundle += ` const style = document.createElement('style');\n`;
bundle += ` style.id = 'nl-theme-css';\n`;
bundle += ` style.textContent = themeCss;\n`;
bundle += ` document.head.appendChild(style);\n`;
bundle += ` console.log(\`NOSTR_LOGIN_LITE: \${themeName} theme CSS injected\`);\n`;
bundle += ` }\n`;
bundle += `}\n\n`;
// Auto-inject default theme on load
bundle += `// Auto-inject default theme when DOM is ready\n`;
bundle += `if (typeof document !== 'undefined') {\n`;
bundle += ` if (document.readyState === 'loading') {\n`;
bundle += ` document.addEventListener('DOMContentLoaded', () => injectThemeCSS('default'));\n`;
bundle += ` } else {\n`;
bundle += ` injectThemeCSS('default');\n`;
bundle += ` }\n`;
bundle += `}\n\n`;
}
// Add Modal UI
const modalPath = path.join(__dirname, 'ui/modal.js');
if (fs.existsSync(modalPath)) {
console.log('📄 Adding Modal UI...');
let modalContent = fs.readFileSync(modalPath, 'utf8');
// Skip header comments
let lines = modalContent.split('\n');
let contentStartIndex = 0;
for (let i = 0; i < Math.min(15, lines.length); i++) {
const line = lines[i].trim();
if (line.startsWith('/**') || line.startsWith('*') ||
line.startsWith('/*') || line.startsWith('//')) {
contentStartIndex = i + 1;
} else if (line && !line.startsWith('*') && !line.startsWith('//')) {
break;
}
}
if (contentStartIndex > 0) {
lines = lines.slice(contentStartIndex);
}
bundle += `// ======================================\n`;
bundle += `// Modal UI Component\n`;
bundle += `// ======================================\n\n`;
bundle += lines.join('\n');
bundle += '\n\n';
} else {
console.warn('⚠️ Modal UI not found: ui/modal.js');
}
// Add main library code
console.log('📄 Adding Main Library...');
bundle += `
// ======================================
// FloatingTab Component (Recovered from git history)
// ======================================
class FloatingTab {
constructor(modal, options = {}) {
this.modal = modal;
this.options = {
enabled: true,
hPosition: 1.0, // 0.0 = left, 1.0 = right
vPosition: 0.5, // 0.0 = top, 1.0 = bottom
offset: { x: 0, y: 0 },
appearance: {
style: 'pill', // 'pill', 'square', 'circle'
theme: 'auto', // 'auto', 'light', 'dark'
icon: '[LOGIN]',
text: 'Login',
iconOnly: false
},
behavior: {
hideWhenAuthenticated: true,
showUserInfo: true,
autoSlide: true,
persistent: false
},
...options
};
this.isAuthenticated = false;
this.userInfo = null;
this.container = null;
this.isVisible = false;
if (this.options.enabled) {
this._init();
}
}
_init() {
console.log('FloatingTab: Initializing with options:', this.options);
this._createContainer();
this._setupEventListeners();
this._updateAppearance();
this._position();
this.show();
}
_createContainer() {
// Remove existing floating tab if any
const existingTab = document.getElementById('nl-floating-tab');
if (existingTab) {
existingTab.remove();
}
this.container = document.createElement('div');
this.container.id = 'nl-floating-tab';
this.container.className = 'nl-floating-tab';
// Base styles - positioning and behavior
this.container.style.cssText = \`
position: fixed;
z-index: 9999;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
font-size: 14px;
font-weight: 500;
padding: 8px 16px;
min-width: 80px;
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
\`;
document.body.appendChild(this.container);
}
_setupEventListeners() {
if (!this.container) return;
// Click handler
this.container.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this._handleClick();
});
// Hover effects
this.container.addEventListener('mouseenter', () => {
if (this.options.behavior.autoSlide) {
this._slideIn();
}
});
this.container.addEventListener('mouseleave', () => {
if (this.options.behavior.autoSlide) {
this._slideOut();
}
});
// Listen for authentication events
window.addEventListener('nlMethodSelected', (e) => {
console.log('FloatingTab: Authentication method selected:', e.detail);
this._handleAuth(e.detail);
});
window.addEventListener('nlLogout', () => {
console.log('FloatingTab: Logout detected');
this._handleLogout();
});
}
_handleClick() {
console.log('FloatingTab: Clicked');
if (this.isAuthenticated && this.options.behavior.showUserInfo) {
// Show user menu or profile options
this._showUserMenu();
} else {
// Open login modal
if (this.modal) {
this.modal.open({ startScreen: 'login' });
}
}
}
_handleAuth(authData) {
console.log('FloatingTab: Handling authentication:', authData);
this.isAuthenticated = true;
this.userInfo = authData;
if (this.options.behavior.hideWhenAuthenticated) {
this.hide();
} else {
this._updateAppearance();
}
}
_handleLogout() {
console.log('FloatingTab: Handling logout');
this.isAuthenticated = false;
this.userInfo = null;
if (this.options.behavior.hideWhenAuthenticated) {
this.show();
}
this._updateAppearance();
}
_showUserMenu() {
// Simple user menu - could be expanded
const menu = document.createElement('div');
menu.style.cssText = \`
position: fixed;
background: var(--nl-secondary-color);
border: var(--nl-border-width) solid var(--nl-primary-color);
border-radius: var(--nl-border-radius);
padding: 12px;
z-index: 10000;
font-family: var(--nl-font-family);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
\`;
// Position near the floating tab
const tabRect = this.container.getBoundingClientRect();
if (this.options.hPosition > 0.5) {
// Tab is on right side, show menu to the left
menu.style.right = (window.innerWidth - tabRect.left) + 'px';
} else {
// Tab is on left side, show menu to the right
menu.style.left = tabRect.right + 'px';
}
menu.style.top = tabRect.top + 'px';
// Menu content
const userDisplay = this.userInfo?.pubkey ?
\`\${this.userInfo.pubkey.slice(0, 8)}...\${this.userInfo.pubkey.slice(-4)}\` :
'Authenticated';
menu.innerHTML = \`
<div style="margin-bottom: 8px; font-weight: bold; color: var(--nl-primary-color);">\${userDisplay}</div>
<button onclick="window.NOSTR_LOGIN_LITE.logout(); this.parentElement.remove();"
style="background: var(--nl-secondary-color); color: var(--nl-primary-color);
border: 1px solid var(--nl-primary-color); border-radius: 4px;
padding: 6px 12px; cursor: pointer; width: 100%;">
Logout
</button>
\`;
document.body.appendChild(menu);
// Auto-remove menu after delay or on outside click
const removeMenu = () => menu.remove();
setTimeout(removeMenu, 5000);
document.addEventListener('click', function onOutsideClick(e) {
if (!menu.contains(e.target) && e.target !== this.container) {
removeMenu();
document.removeEventListener('click', onOutsideClick);
}
});
}
_updateAppearance() {
if (!this.container) return;
// Update content
if (this.isAuthenticated && this.options.behavior.showUserInfo) {
const display = this.userInfo?.pubkey ?
(this.options.appearance.iconOnly ?
'[USER]' :
\`[USER] \${this.userInfo.pubkey.slice(0, 6)}...\`) :
(this.options.appearance.iconOnly ? '[AUTH]' : '[AUTH] Logged In');
this.container.textContent = display;
this.container.className = 'nl-floating-tab nl-floating-tab--logged-in';
} else {
const display = this.options.appearance.iconOnly ?
this.options.appearance.icon :
\`\${this.options.appearance.icon} \${this.options.appearance.text}\`;
this.container.textContent = display;
this.container.className = 'nl-floating-tab nl-floating-tab--logged-out';
}
// Apply appearance styles based on current state
this._applyThemeStyles();
}
_applyThemeStyles() {
if (!this.container) return;
// The CSS classes will handle the theming through CSS custom properties
// Additional style customizations can be added here if needed
// Apply style variant
if (this.options.appearance.style === 'circle') {
this.container.style.borderRadius = '50%';
this.container.style.width = '48px';
this.container.style.height = '48px';
this.container.style.minWidth = '48px';
this.container.style.padding = '0';
} else if (this.options.appearance.style === 'square') {
this.container.style.borderRadius = '4px';
} else {
// pill style (default)
this.container.style.borderRadius = 'var(--nl-border-radius)';
}
}
_position() {
if (!this.container) return;
const padding = 16; // Distance from screen edge
// Calculate position based on percentage
const x = this.options.hPosition * (window.innerWidth - this.container.offsetWidth - padding * 2) + padding + this.options.offset.x;
const y = this.options.vPosition * (window.innerHeight - this.container.offsetHeight - padding * 2) + padding + this.options.offset.y;
this.container.style.left = \`\${x}px\`;
this.container.style.top = \`\${y}px\`;
console.log(\`FloatingTab: Positioned at (\${x}, \${y})\`);
}
_slideIn() {
if (!this.container || !this.options.behavior.autoSlide) return;
// Slide towards center slightly
const currentTransform = this.container.style.transform || '';
if (this.options.hPosition > 0.5) {
this.container.style.transform = currentTransform + ' translateX(-8px)';
} else {
this.container.style.transform = currentTransform + ' translateX(8px)';
}
}
_slideOut() {
if (!this.container || !this.options.behavior.autoSlide) return;
// Reset position
this.container.style.transform = '';
}
show() {
if (!this.container) return;
this.container.style.display = 'flex';
this.isVisible = true;
console.log('FloatingTab: Shown');
}
hide() {
if (!this.container) return;
this.container.style.display = 'none';
this.isVisible = false;
console.log('FloatingTab: Hidden');
}
destroy() {
if (this.container) {
this.container.remove();
this.container = null;
}
this.isVisible = false;
console.log('FloatingTab: Destroyed');
}
// Update options and re-apply
updateOptions(newOptions) {
this.options = { ...this.options, ...newOptions };
if (this.container) {
this._updateAppearance();
this._position();
}
}
// Get current state
getState() {
return {
isVisible: this.isVisible,
isAuthenticated: this.isAuthenticated,
userInfo: this.userInfo,
options: this.options
};
}
}
// ======================================
// Main NOSTR_LOGIN_LITE Library
// ======================================
// Extension Bridge for managing browser extensions
class ExtensionBridge {
constructor() {
this.extensions = new Map();
this.primaryExtension = null;
this._detectExtensions();
}
_detectExtensions() {
// Common extension locations
const locations = [
{ path: 'window.nostr', name: 'Generic' },
{ path: 'window.alby?.nostr', name: 'Alby' },
{ path: 'window.nos2x?.nostr', name: 'nos2x' },
{ path: 'window.flamingo?.nostr', name: 'Flamingo' },
{ path: 'window.getAlby?.nostr', name: 'Alby Legacy' },
{ path: 'window.mutiny?.nostr', name: 'Mutiny' }
];
for (const location of locations) {
try {
const obj = eval(location.path);
if (obj && typeof obj.getPublicKey === 'function') {
this.extensions.set(location.name, {
name: location.name,
extension: obj,
constructor: obj.constructor?.name || 'Unknown'
});
if (!this.primaryExtension) {
this.primaryExtension = this.extensions.get(location.name);
}
}
} catch (e) {
// Extension not available
}
}
}
getAllExtensions() {
return Array.from(this.extensions.values());
}
getExtensionCount() {
return this.extensions.size;
}
}
// Main NostrLite class
class NostrLite {
constructor() {
this.options = {};
this.extensionBridge = new ExtensionBridge();
this.initialized = false;
this.currentTheme = 'default';
this.modal = null;
this.floatingTab = null;
}
async init(options = {}) {
console.log('NOSTR_LOGIN_LITE: Initializing with options:', options);
this.options = {
theme: 'default',
relays: ['wss://relay.damus.io', 'wss://nos.lol'],
methods: {
extension: true,
local: true,
readonly: true,
connect: false,
otp: false
},
floatingTab: {
enabled: false,
hPosition: 1.0,
vPosition: 0.5,
offset: { x: 0, y: 0 },
appearance: {
style: 'pill',
theme: 'auto',
icon: '[LOGIN]',
text: 'Login',
iconOnly: false
},
behavior: {
hideWhenAuthenticated: true,
showUserInfo: true,
autoSlide: true,
persistent: false
}
},
...options
};
// Apply the selected theme (CSS-only)
this.switchTheme(this.options.theme);
// Set up window.nostr facade if no extension detected
if (this.extensionBridge.getExtensionCount() === 0) {
this._setupWindowNostrFacade();
}
// Create modal during init (matching original git architecture)
this.modal = new Modal(this.options);
console.log('NOSTR_LOGIN_LITE: Modal created during init');
// Initialize floating tab if enabled
if (this.options.floatingTab.enabled) {
this.floatingTab = new FloatingTab(this.modal, this.options.floatingTab);
console.log('NOSTR_LOGIN_LITE: Floating tab initialized');
}
this.initialized = true;
console.log('NOSTR_LOGIN_LITE: Initialization complete');
return this;
}
_setupWindowNostrFacade() {
if (typeof window !== 'undefined' && !window.nostr) {
window.nostr = new WindowNostr(this);
console.log('NOSTR_LOGIN_LITE: window.nostr facade installed');
}
}
launch(startScreen = 'login') {
console.log('NOSTR_LOGIN_LITE: Launching with screen:', startScreen);
if (this.modal) {
this.modal.open({ startScreen });
} else {
console.error('NOSTR_LOGIN_LITE: Modal not initialized - call init() first');
}
}
logout() {
console.log('NOSTR_LOGIN_LITE: Logout called');
// Clear stored data
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('nl_current');
}
// Dispatch logout event
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('nlLogout', {
detail: { timestamp: Date.now() }
}));
}
}
// CSS-only theme switching
switchTheme(themeName) {
console.log(\`NOSTR_LOGIN_LITE: Switching to \${themeName} theme\`);
if (THEME_CSS[themeName]) {
injectThemeCSS(themeName);
this.currentTheme = themeName;
// Dispatch theme change event
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('nlThemeChanged', {
detail: { theme: themeName }
}));
}
return { theme: themeName };
} else {
console.warn(\`Theme '\${themeName}' not found, using default\`);
injectThemeCSS('default');
this.currentTheme = 'default';
return { theme: 'default' };
}
}
getCurrentTheme() {
return this.currentTheme;
}
getAvailableThemes() {
return Object.keys(THEME_CSS);
}
embed(container, options = {}) {
console.log('NOSTR_LOGIN_LITE: Creating embedded modal in container:', container);
const embedOptions = {
...this.options,
...options,
embedded: container
};
// Create new modal instance for embedding
const embeddedModal = new Modal(embedOptions);
embeddedModal.open();
return embeddedModal;
}
// Floating tab management methods
showFloatingTab() {
if (this.floatingTab) {
this.floatingTab.show();
} else {
console.warn('NOSTR_LOGIN_LITE: Floating tab not enabled');
}
}
hideFloatingTab() {
if (this.floatingTab) {
this.floatingTab.hide();
}
}
toggleFloatingTab() {
if (this.floatingTab) {
if (this.floatingTab.isVisible) {
this.floatingTab.hide();
} else {
this.floatingTab.show();
}
}
}
updateFloatingTab(options) {
if (this.floatingTab) {
this.floatingTab.updateOptions(options);
}
}
getFloatingTabState() {
return this.floatingTab ? this.floatingTab.getState() : null;
}
}
// Window.nostr facade for when no extension is available
class WindowNostr {
constructor(nostrLite) {
this.nostrLite = nostrLite;
}
async getPublicKey() {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
}
async signEvent(event) {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
}
async getRelays() {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
}
get nip04() {
return {
async encrypt(pubkey, plaintext) {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
},
async decrypt(pubkey, ciphertext) {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
}
};
}
get nip44() {
return {
async encrypt(pubkey, plaintext) {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
},
async decrypt(pubkey, ciphertext) {
throw new Error('Authentication required - use NOSTR_LOGIN_LITE.launch()');
}
};
}
}
// Initialize and export
if (typeof window !== 'undefined') {
const nostrLite = new NostrLite();
// Export main API
window.NOSTR_LOGIN_LITE = {
init: (options) => nostrLite.init(options),
launch: (startScreen) => nostrLite.launch(startScreen),
logout: () => nostrLite.logout(),
// Embedded modal method
embed: (container, options) => nostrLite.embed(container, options),
// CSS-only theme management API
switchTheme: (themeName) => nostrLite.switchTheme(themeName),
getCurrentTheme: () => nostrLite.getCurrentTheme(),
getAvailableThemes: () => nostrLite.getAvailableThemes(),
// Floating tab management API
showFloatingTab: () => nostrLite.showFloatingTab(),
hideFloatingTab: () => nostrLite.hideFloatingTab(),
toggleFloatingTab: () => nostrLite.toggleFloatingTab(),
updateFloatingTab: (options) => nostrLite.updateFloatingTab(options),
getFloatingTabState: () => nostrLite.getFloatingTabState(),
// Expose for debugging
_extensionBridge: nostrLite.extensionBridge,
_instance: nostrLite
};
console.log('NOSTR_LOGIN_LITE: Library loaded and ready');
console.log('NOSTR_LOGIN_LITE: Use window.NOSTR_LOGIN_LITE.init(options) to initialize');
console.log('NOSTR_LOGIN_LITE: Detected', nostrLite.extensionBridge.getExtensionCount(), 'browser extensions');
} else {
// Node.js environment
module.exports = { NostrLite };
}
`;
// Write the complete bundle
fs.writeFileSync(outputPath, bundle, 'utf8');
const sizeKB = (bundle.length / 1024).toFixed(2);
console.log(`\n✅ nostr-lite.js bundle created: ${outputPath}`);
console.log(`📏 Bundle size: ${sizeKB} KB`);
console.log(`📄 Total lines: ${bundle.split('\n').length}`);
// Check what's included
const hasModal = bundle.includes('class Modal');
const hasNostrLite = bundle.includes('NOSTR_LOGIN_LITE');
const hasThemeCss = bundle.includes('THEME_CSS');
console.log('\n📋 Bundle contents:');
console.log(` Modal UI: ${hasModal ? '✅ Included' : '❌ Missing'}`);
console.log(` NOSTR_LOGIN_LITE: ${hasNostrLite ? '✅ Included' : '❌ Missing'}`);
console.log(` CSS-Only Themes: ${hasThemeCss ? '✅ Included' : '❌ Missing'}`);
console.log(` Extension Bridge: ✅ Included`);
console.log(` Window.nostr facade: ✅ Included`);
console.log('\n📋 Two-file architecture:');
console.log(' 1. nostr.bundle.js (official nostr-tools - 220KB)');
console.log(` 2. nostr-lite.js (NOSTR_LOGIN_LITE with CSS-only themes - ${sizeKB}KB)`);
return bundle;
}
// Run if called directly
if (typeof require !== 'undefined' && require.main === module) {
createNostrLoginLiteBundle();
}
module.exports = { createNostrLoginLiteBundle };