feat(extensions): add extension loader infrastructure
Adds a modular extension system for Lightning.Pub that allows third-party functionality to be added without modifying core code. Features: - ExtensionLoader: discovers and loads extensions from directory - ExtensionContext: provides extensions with access to Lightning.Pub APIs - ExtensionDatabase: isolated SQLite database per extension - Lifecycle management: initialize, shutdown, health checks - RPC method registration: extensions can add new RPC methods - Event dispatching: routes payments and Nostr events to extensions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e6513b4797
commit
a6acf3dffc
5 changed files with 1129 additions and 0 deletions
288
src/extensions/context.ts
Normal file
288
src/extensions/context.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
import {
|
||||
ExtensionContext,
|
||||
ExtensionDatabase,
|
||||
ExtensionInfo,
|
||||
ApplicationInfo,
|
||||
CreateInvoiceOptions,
|
||||
CreatedInvoice,
|
||||
PaymentReceivedData,
|
||||
NostrEvent,
|
||||
UnsignedNostrEvent,
|
||||
RpcMethodHandler
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* Main Handler interface (from Lightning.Pub)
|
||||
* This is a minimal interface - the actual MainHandler has more methods
|
||||
*/
|
||||
export interface MainHandlerInterface {
|
||||
// Application management
|
||||
applicationManager: {
|
||||
getById(id: string): Promise<any>
|
||||
}
|
||||
|
||||
// Payment operations
|
||||
paymentManager: {
|
||||
createInvoice(params: {
|
||||
applicationId: string
|
||||
amountSats: number
|
||||
memo?: string
|
||||
expiry?: number
|
||||
metadata?: Record<string, any>
|
||||
}): Promise<{
|
||||
id: string
|
||||
paymentRequest: string
|
||||
paymentHash: string
|
||||
expiry: number
|
||||
}>
|
||||
|
||||
payInvoice(params: {
|
||||
applicationId: string
|
||||
paymentRequest: string
|
||||
maxFeeSats?: number
|
||||
}): Promise<{
|
||||
paymentHash: string
|
||||
feeSats: number
|
||||
}>
|
||||
}
|
||||
|
||||
// Nostr operations
|
||||
sendNostrEvent(event: any): Promise<string | null>
|
||||
sendEncryptedDM(applicationId: string, recipientPubkey: string, content: string): Promise<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback registries for extension events
|
||||
*/
|
||||
interface CallbackRegistries {
|
||||
paymentReceived: Array<(payment: PaymentReceivedData) => Promise<void>>
|
||||
nostrEvent: Array<(event: NostrEvent, applicationId: string) => Promise<void>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered RPC method
|
||||
*/
|
||||
interface RegisteredMethod {
|
||||
extensionId: string
|
||||
handler: RpcMethodHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension Context Implementation
|
||||
*
|
||||
* Provides the interface for extensions to interact with Lightning.Pub.
|
||||
* Each extension gets its own context instance.
|
||||
*/
|
||||
export class ExtensionContextImpl implements ExtensionContext {
|
||||
private callbacks: CallbackRegistries = {
|
||||
paymentReceived: [],
|
||||
nostrEvent: []
|
||||
}
|
||||
|
||||
constructor(
|
||||
private extensionInfo: ExtensionInfo,
|
||||
private database: ExtensionDatabase,
|
||||
private mainHandler: MainHandlerInterface,
|
||||
private methodRegistry: Map<string, RegisteredMethod>
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get information about an application
|
||||
*/
|
||||
async getApplication(applicationId: string): Promise<ApplicationInfo | null> {
|
||||
try {
|
||||
const app = await this.mainHandler.applicationManager.getById(applicationId)
|
||||
if (!app) return null
|
||||
|
||||
return {
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
nostr_public: app.nostr_public,
|
||||
balance_sats: app.balance || 0
|
||||
}
|
||||
} catch (e) {
|
||||
this.log('error', `Failed to get application ${applicationId}:`, e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Lightning invoice
|
||||
*/
|
||||
async createInvoice(amountSats: number, options: CreateInvoiceOptions = {}): Promise<CreatedInvoice> {
|
||||
// Note: In practice, this needs an applicationId. Extensions typically
|
||||
// get this from the RPC request context. For now, we'll need to handle
|
||||
// this in the actual implementation.
|
||||
throw new Error('createInvoice requires applicationId from request context')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create invoice with explicit application ID
|
||||
* This is the internal method used by extensions
|
||||
*/
|
||||
async createInvoiceForApp(
|
||||
applicationId: string,
|
||||
amountSats: number,
|
||||
options: CreateInvoiceOptions = {}
|
||||
): Promise<CreatedInvoice> {
|
||||
const result = await this.mainHandler.paymentManager.createInvoice({
|
||||
applicationId,
|
||||
amountSats,
|
||||
memo: options.memo,
|
||||
expiry: options.expiry,
|
||||
metadata: {
|
||||
...options.metadata,
|
||||
extension: this.extensionInfo.id
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
id: result.id,
|
||||
paymentRequest: result.paymentRequest,
|
||||
paymentHash: result.paymentHash,
|
||||
expiry: result.expiry
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay a Lightning invoice
|
||||
*/
|
||||
async payInvoice(
|
||||
applicationId: string,
|
||||
paymentRequest: string,
|
||||
maxFeeSats?: number
|
||||
): Promise<{ paymentHash: string; feeSats: number }> {
|
||||
return this.mainHandler.paymentManager.payInvoice({
|
||||
applicationId,
|
||||
paymentRequest,
|
||||
maxFeeSats
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an encrypted DM via Nostr
|
||||
*/
|
||||
async sendEncryptedDM(
|
||||
applicationId: string,
|
||||
recipientPubkey: string,
|
||||
content: string
|
||||
): Promise<string> {
|
||||
return this.mainHandler.sendEncryptedDM(applicationId, recipientPubkey, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a Nostr event
|
||||
*/
|
||||
async publishNostrEvent(event: UnsignedNostrEvent): Promise<string | null> {
|
||||
return this.mainHandler.sendNostrEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to payment received callbacks
|
||||
*/
|
||||
onPaymentReceived(callback: (payment: PaymentReceivedData) => Promise<void>): void {
|
||||
this.callbacks.paymentReceived.push(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to incoming Nostr events
|
||||
*/
|
||||
onNostrEvent(callback: (event: NostrEvent, applicationId: string) => Promise<void>): void {
|
||||
this.callbacks.nostrEvent.push(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an RPC method
|
||||
*/
|
||||
registerMethod(name: string, handler: RpcMethodHandler): void {
|
||||
const fullName = name.startsWith(`${this.extensionInfo.id}.`)
|
||||
? name
|
||||
: `${this.extensionInfo.id}.${name}`
|
||||
|
||||
if (this.methodRegistry.has(fullName)) {
|
||||
throw new Error(`RPC method ${fullName} already registered`)
|
||||
}
|
||||
|
||||
this.methodRegistry.set(fullName, {
|
||||
extensionId: this.extensionInfo.id,
|
||||
handler
|
||||
})
|
||||
|
||||
this.log('debug', `Registered RPC method: ${fullName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the extension's database
|
||||
*/
|
||||
getDatabase(): ExtensionDatabase {
|
||||
return this.database
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message
|
||||
*/
|
||||
log(level: 'debug' | 'info' | 'warn' | 'error', message: string, ...args: any[]): void {
|
||||
const prefix = `[Extension:${this.extensionInfo.id}]`
|
||||
switch (level) {
|
||||
case 'debug':
|
||||
console.debug(prefix, message, ...args)
|
||||
break
|
||||
case 'info':
|
||||
console.info(prefix, message, ...args)
|
||||
break
|
||||
case 'warn':
|
||||
console.warn(prefix, message, ...args)
|
||||
break
|
||||
case 'error':
|
||||
console.error(prefix, message, ...args)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Internal Methods (called by ExtensionLoader) =====
|
||||
|
||||
/**
|
||||
* Dispatch payment received event to extension callbacks
|
||||
*/
|
||||
async dispatchPaymentReceived(payment: PaymentReceivedData): Promise<void> {
|
||||
for (const callback of this.callbacks.paymentReceived) {
|
||||
try {
|
||||
await callback(payment)
|
||||
} catch (e) {
|
||||
this.log('error', 'Error in payment callback:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch Nostr event to extension callbacks
|
||||
*/
|
||||
async dispatchNostrEvent(event: NostrEvent, applicationId: string): Promise<void> {
|
||||
for (const callback of this.callbacks.nostrEvent) {
|
||||
try {
|
||||
await callback(event, applicationId)
|
||||
} catch (e) {
|
||||
this.log('error', 'Error in Nostr event callback:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get registered callbacks for external access
|
||||
*/
|
||||
getCallbacks(): CallbackRegistries {
|
||||
return this.callbacks
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an extension context
|
||||
*/
|
||||
export function createExtensionContext(
|
||||
extensionInfo: ExtensionInfo,
|
||||
database: ExtensionDatabase,
|
||||
mainHandler: MainHandlerInterface,
|
||||
methodRegistry: Map<string, RegisteredMethod>
|
||||
): ExtensionContextImpl {
|
||||
return new ExtensionContextImpl(extensionInfo, database, mainHandler, methodRegistry)
|
||||
}
|
||||
148
src/extensions/database.ts
Normal file
148
src/extensions/database.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import Database from 'better-sqlite3'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { ExtensionDatabase } from './types.js'
|
||||
|
||||
/**
|
||||
* Extension Database Implementation
|
||||
*
|
||||
* Provides isolated SQLite database access for each extension.
|
||||
* Uses better-sqlite3 for synchronous, high-performance access.
|
||||
*/
|
||||
export class ExtensionDatabaseImpl implements ExtensionDatabase {
|
||||
private db: Database.Database
|
||||
private extensionId: string
|
||||
|
||||
constructor(extensionId: string, databaseDir: string) {
|
||||
this.extensionId = extensionId
|
||||
|
||||
// Ensure database directory exists
|
||||
if (!fs.existsSync(databaseDir)) {
|
||||
fs.mkdirSync(databaseDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Create database file for this extension
|
||||
const dbPath = path.join(databaseDir, `${extensionId}.db`)
|
||||
this.db = new Database(dbPath)
|
||||
|
||||
// Enable WAL mode for better concurrency
|
||||
this.db.pragma('journal_mode = WAL')
|
||||
|
||||
// Enable foreign keys
|
||||
this.db.pragma('foreign_keys = ON')
|
||||
|
||||
// Create metadata table for tracking migrations
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS _extension_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a write query (INSERT, UPDATE, DELETE, CREATE, etc.)
|
||||
*/
|
||||
async execute(sql: string, params: any[] = []): Promise<{ changes?: number; lastId?: number }> {
|
||||
try {
|
||||
const stmt = this.db.prepare(sql)
|
||||
const result = stmt.run(...params)
|
||||
|
||||
return {
|
||||
changes: result.changes,
|
||||
lastId: result.lastInsertRowid as number
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Extension:${this.extensionId}] Database execute error:`, e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a read query (SELECT)
|
||||
*/
|
||||
async query<T = any>(sql: string, params: any[] = []): Promise<T[]> {
|
||||
try {
|
||||
const stmt = this.db.prepare(sql)
|
||||
return stmt.all(...params) as T[]
|
||||
} catch (e) {
|
||||
console.error(`[Extension:${this.extensionId}] Database query error:`, e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute multiple statements in a transaction
|
||||
*/
|
||||
async transaction<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const runTransaction = this.db.transaction(() => {
|
||||
// Note: better-sqlite3 transactions are synchronous
|
||||
// We wrap the async function but it executes synchronously
|
||||
return fn()
|
||||
})
|
||||
|
||||
return runTransaction() as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a metadata value
|
||||
*/
|
||||
async getMeta(key: string): Promise<string | null> {
|
||||
const rows = await this.query<{ value: string }>(
|
||||
'SELECT value FROM _extension_meta WHERE key = ?',
|
||||
[key]
|
||||
)
|
||||
return rows.length > 0 ? rows[0].value : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a metadata value
|
||||
*/
|
||||
async setMeta(key: string, value: string): Promise<void> {
|
||||
await this.execute(
|
||||
`INSERT INTO _extension_meta (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
[key, value]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current migration version
|
||||
*/
|
||||
async getMigrationVersion(): Promise<number> {
|
||||
const version = await this.getMeta('migration_version')
|
||||
return version ? parseInt(version, 10) : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Set migration version
|
||||
*/
|
||||
async setMigrationVersion(version: number): Promise<void> {
|
||||
await this.setMeta('migration_version', String(version))
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
close(): void {
|
||||
this.db.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying database for advanced operations
|
||||
* (Use with caution - bypasses isolation)
|
||||
*/
|
||||
getUnderlyingDb(): Database.Database {
|
||||
return this.db
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an extension database instance
|
||||
*/
|
||||
export function createExtensionDatabase(
|
||||
extensionId: string,
|
||||
databaseDir: string
|
||||
): ExtensionDatabaseImpl {
|
||||
return new ExtensionDatabaseImpl(extensionId, databaseDir)
|
||||
}
|
||||
56
src/extensions/index.ts
Normal file
56
src/extensions/index.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Lightning.Pub Extension System
|
||||
*
|
||||
* This module provides the extension infrastructure for Lightning.Pub.
|
||||
* Extensions can add functionality like marketplaces, subscriptions,
|
||||
* tipping, and more.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* ```typescript
|
||||
* import { createExtensionLoader, ExtensionLoaderConfig } from './extensions'
|
||||
*
|
||||
* const config: ExtensionLoaderConfig = {
|
||||
* extensionsDir: './extensions',
|
||||
* databaseDir: './data/extensions'
|
||||
* }
|
||||
*
|
||||
* const loader = createExtensionLoader(config, mainHandler)
|
||||
* await loader.loadAll()
|
||||
*
|
||||
* // Call extension methods
|
||||
* const result = await loader.callMethod(
|
||||
* 'marketplace.createStall',
|
||||
* { name: 'My Shop', currency: 'sat', shipping_zones: [...] },
|
||||
* applicationId
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Export types
|
||||
export {
|
||||
Extension,
|
||||
ExtensionInfo,
|
||||
ExtensionContext,
|
||||
ExtensionDatabase,
|
||||
ExtensionModule,
|
||||
ExtensionConstructor,
|
||||
LoadedExtension,
|
||||
ExtensionLoaderConfig,
|
||||
ApplicationInfo,
|
||||
CreateInvoiceOptions,
|
||||
CreatedInvoice,
|
||||
PaymentReceivedData,
|
||||
NostrEvent,
|
||||
UnsignedNostrEvent,
|
||||
RpcMethodHandler
|
||||
} from './types.js'
|
||||
|
||||
// Export loader
|
||||
export { ExtensionLoader, createExtensionLoader } from './loader.js'
|
||||
|
||||
// Export database utilities
|
||||
export { ExtensionDatabaseImpl, createExtensionDatabase } from './database.js'
|
||||
|
||||
// Export context utilities
|
||||
export { ExtensionContextImpl, createExtensionContext, MainHandlerInterface } from './context.js'
|
||||
406
src/extensions/loader.ts
Normal file
406
src/extensions/loader.ts
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import {
|
||||
Extension,
|
||||
ExtensionInfo,
|
||||
ExtensionModule,
|
||||
LoadedExtension,
|
||||
ExtensionLoaderConfig,
|
||||
RpcMethodHandler,
|
||||
PaymentReceivedData,
|
||||
NostrEvent
|
||||
} from './types.js'
|
||||
import { ExtensionDatabaseImpl, createExtensionDatabase } from './database.js'
|
||||
import { ExtensionContextImpl, createExtensionContext, MainHandlerInterface } from './context.js'
|
||||
|
||||
/**
|
||||
* Registered RPC method entry
|
||||
*/
|
||||
interface RegisteredMethod {
|
||||
extensionId: string
|
||||
handler: RpcMethodHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension Loader
|
||||
*
|
||||
* Discovers, loads, and manages Lightning.Pub extensions.
|
||||
* Provides lifecycle management and event dispatching.
|
||||
*/
|
||||
export class ExtensionLoader {
|
||||
private config: ExtensionLoaderConfig
|
||||
private mainHandler: MainHandlerInterface
|
||||
private extensions: Map<string, LoadedExtension> = new Map()
|
||||
private contexts: Map<string, ExtensionContextImpl> = new Map()
|
||||
private methodRegistry: Map<string, RegisteredMethod> = new Map()
|
||||
private initialized = false
|
||||
|
||||
constructor(config: ExtensionLoaderConfig, mainHandler: MainHandlerInterface) {
|
||||
this.config = config
|
||||
this.mainHandler = mainHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and load all extensions
|
||||
*/
|
||||
async loadAll(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
throw new Error('Extension loader already initialized')
|
||||
}
|
||||
|
||||
console.log('[Extensions] Loading extensions from:', this.config.extensionsDir)
|
||||
|
||||
// Ensure directories exist
|
||||
if (!fs.existsSync(this.config.extensionsDir)) {
|
||||
console.log('[Extensions] Extensions directory does not exist, creating...')
|
||||
fs.mkdirSync(this.config.extensionsDir, { recursive: true })
|
||||
this.initialized = true
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.existsSync(this.config.databaseDir)) {
|
||||
fs.mkdirSync(this.config.databaseDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Discover extensions
|
||||
const extensionDirs = await this.discoverExtensions()
|
||||
console.log(`[Extensions] Found ${extensionDirs.length} extension(s)`)
|
||||
|
||||
// Load extensions in dependency order
|
||||
const loadOrder = await this.resolveDependencies(extensionDirs)
|
||||
|
||||
for (const extDir of loadOrder) {
|
||||
try {
|
||||
await this.loadExtension(extDir)
|
||||
} catch (e) {
|
||||
console.error(`[Extensions] Failed to load extension from ${extDir}:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
this.initialized = true
|
||||
console.log(`[Extensions] Loaded ${this.extensions.size} extension(s)`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover extension directories
|
||||
*/
|
||||
private async discoverExtensions(): Promise<string[]> {
|
||||
const entries = fs.readdirSync(this.config.extensionsDir, { withFileTypes: true })
|
||||
const extensionDirs: string[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
|
||||
const extDir = path.join(this.config.extensionsDir, entry.name)
|
||||
const indexPath = path.join(extDir, 'index.ts')
|
||||
const indexJsPath = path.join(extDir, 'index.js')
|
||||
|
||||
// Check for index file
|
||||
if (fs.existsSync(indexPath) || fs.existsSync(indexJsPath)) {
|
||||
// Check enabled/disabled lists
|
||||
if (this.config.disabledExtensions?.includes(entry.name)) {
|
||||
console.log(`[Extensions] Skipping disabled extension: ${entry.name}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (this.config.enabledExtensions &&
|
||||
!this.config.enabledExtensions.includes(entry.name)) {
|
||||
console.log(`[Extensions] Skipping non-enabled extension: ${entry.name}`)
|
||||
continue
|
||||
}
|
||||
|
||||
extensionDirs.push(extDir)
|
||||
}
|
||||
}
|
||||
|
||||
return extensionDirs
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve extension dependencies and return load order
|
||||
*/
|
||||
private async resolveDependencies(extensionDirs: string[]): Promise<string[]> {
|
||||
// For now, simple alphabetical order
|
||||
// TODO: Implement proper dependency resolution with topological sort
|
||||
return extensionDirs.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single extension
|
||||
*/
|
||||
private async loadExtension(extensionDir: string): Promise<void> {
|
||||
const dirName = path.basename(extensionDir)
|
||||
console.log(`[Extensions] Loading extension: ${dirName}`)
|
||||
|
||||
// Determine index file path
|
||||
let indexPath = path.join(extensionDir, 'index.js')
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
indexPath = path.join(extensionDir, 'index.ts')
|
||||
}
|
||||
|
||||
// Dynamic import
|
||||
const moduleUrl = `file://${indexPath}`
|
||||
const module = await import(moduleUrl) as ExtensionModule
|
||||
|
||||
if (!module.default) {
|
||||
throw new Error(`Extension ${dirName} has no default export`)
|
||||
}
|
||||
|
||||
// Instantiate extension
|
||||
const ExtensionClass = module.default
|
||||
const instance = new ExtensionClass() as Extension
|
||||
|
||||
if (!instance.info) {
|
||||
throw new Error(`Extension ${dirName} has no info property`)
|
||||
}
|
||||
|
||||
const info = instance.info
|
||||
|
||||
// Validate extension ID matches directory name
|
||||
if (info.id !== dirName) {
|
||||
console.warn(
|
||||
`[Extensions] Extension ID '${info.id}' doesn't match directory '${dirName}'`
|
||||
)
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
if (this.extensions.has(info.id)) {
|
||||
throw new Error(`Extension ${info.id} already loaded`)
|
||||
}
|
||||
|
||||
// Create isolated database
|
||||
const database = createExtensionDatabase(info.id, this.config.databaseDir)
|
||||
|
||||
// Create context
|
||||
const context = createExtensionContext(
|
||||
info,
|
||||
database,
|
||||
this.mainHandler,
|
||||
this.methodRegistry
|
||||
)
|
||||
|
||||
// Track as loading
|
||||
const loaded: LoadedExtension = {
|
||||
info,
|
||||
instance,
|
||||
database,
|
||||
status: 'loading',
|
||||
loadedAt: Date.now()
|
||||
}
|
||||
this.extensions.set(info.id, loaded)
|
||||
this.contexts.set(info.id, context)
|
||||
|
||||
try {
|
||||
// Initialize extension
|
||||
await instance.initialize(context, database)
|
||||
|
||||
loaded.status = 'ready'
|
||||
console.log(`[Extensions] Extension ${info.id} v${info.version} loaded successfully`)
|
||||
} catch (e) {
|
||||
loaded.status = 'error'
|
||||
loaded.error = e as Error
|
||||
console.error(`[Extensions] Extension ${info.id} initialization failed:`, e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload a specific extension
|
||||
*/
|
||||
async unloadExtension(extensionId: string): Promise<void> {
|
||||
const loaded = this.extensions.get(extensionId)
|
||||
if (!loaded) {
|
||||
throw new Error(`Extension ${extensionId} not found`)
|
||||
}
|
||||
|
||||
console.log(`[Extensions] Unloading extension: ${extensionId}`)
|
||||
|
||||
try {
|
||||
// Call shutdown if available
|
||||
if (loaded.instance.shutdown) {
|
||||
await loaded.instance.shutdown()
|
||||
}
|
||||
|
||||
loaded.status = 'stopped'
|
||||
} catch (e) {
|
||||
console.error(`[Extensions] Error during ${extensionId} shutdown:`, e)
|
||||
}
|
||||
|
||||
// Close database
|
||||
if (loaded.database instanceof ExtensionDatabaseImpl) {
|
||||
loaded.database.close()
|
||||
}
|
||||
|
||||
// Remove registered methods
|
||||
for (const [name, method] of this.methodRegistry.entries()) {
|
||||
if (method.extensionId === extensionId) {
|
||||
this.methodRegistry.delete(name)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from maps
|
||||
this.extensions.delete(extensionId)
|
||||
this.contexts.delete(extensionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown all extensions
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
console.log('[Extensions] Shutting down all extensions...')
|
||||
|
||||
for (const extensionId of this.extensions.keys()) {
|
||||
try {
|
||||
await this.unloadExtension(extensionId)
|
||||
} catch (e) {
|
||||
console.error(`[Extensions] Error unloading ${extensionId}:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Extensions] All extensions shut down')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a loaded extension
|
||||
*/
|
||||
getExtension(extensionId: string): LoadedExtension | undefined {
|
||||
return this.extensions.get(extensionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all loaded extensions
|
||||
*/
|
||||
getAllExtensions(): LoadedExtension[] {
|
||||
return Array.from(this.extensions.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an extension is loaded and ready
|
||||
*/
|
||||
isReady(extensionId: string): boolean {
|
||||
const ext = this.extensions.get(extensionId)
|
||||
return ext?.status === 'ready'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered RPC methods
|
||||
*/
|
||||
getRegisteredMethods(): Map<string, RegisteredMethod> {
|
||||
return this.methodRegistry
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an extension RPC method
|
||||
*/
|
||||
async callMethod(
|
||||
methodName: string,
|
||||
request: any,
|
||||
applicationId: string,
|
||||
userPubkey?: string
|
||||
): Promise<any> {
|
||||
const method = this.methodRegistry.get(methodName)
|
||||
if (!method) {
|
||||
throw new Error(`Unknown method: ${methodName}`)
|
||||
}
|
||||
|
||||
const ext = this.extensions.get(method.extensionId)
|
||||
if (!ext || ext.status !== 'ready') {
|
||||
throw new Error(`Extension ${method.extensionId} not ready`)
|
||||
}
|
||||
|
||||
return method.handler(request, applicationId, userPubkey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a method exists
|
||||
*/
|
||||
hasMethod(methodName: string): boolean {
|
||||
return this.methodRegistry.has(methodName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch payment received event to all extensions
|
||||
*/
|
||||
async dispatchPaymentReceived(payment: PaymentReceivedData): Promise<void> {
|
||||
for (const context of this.contexts.values()) {
|
||||
try {
|
||||
await context.dispatchPaymentReceived(payment)
|
||||
} catch (e) {
|
||||
console.error('[Extensions] Error dispatching payment:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch Nostr event to all extensions
|
||||
*/
|
||||
async dispatchNostrEvent(event: NostrEvent, applicationId: string): Promise<void> {
|
||||
for (const context of this.contexts.values()) {
|
||||
try {
|
||||
await context.dispatchNostrEvent(event, applicationId)
|
||||
} catch (e) {
|
||||
console.error('[Extensions] Error dispatching Nostr event:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run health checks on all extensions
|
||||
*/
|
||||
async healthCheck(): Promise<Map<string, boolean>> {
|
||||
const results = new Map<string, boolean>()
|
||||
|
||||
for (const [id, ext] of this.extensions.entries()) {
|
||||
if (ext.status !== 'ready') {
|
||||
results.set(id, false)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
if (ext.instance.healthCheck) {
|
||||
results.set(id, await ext.instance.healthCheck())
|
||||
} else {
|
||||
results.set(id, true)
|
||||
}
|
||||
} catch (e) {
|
||||
results.set(id, false)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Get extension status summary
|
||||
*/
|
||||
getStatus(): {
|
||||
total: number
|
||||
ready: number
|
||||
error: number
|
||||
extensions: Array<{ id: string; name: string; version: string; status: string }>
|
||||
} {
|
||||
const extensions = this.getAllExtensions().map(ext => ({
|
||||
id: ext.info.id,
|
||||
name: ext.info.name,
|
||||
version: ext.info.version,
|
||||
status: ext.status
|
||||
}))
|
||||
|
||||
return {
|
||||
total: extensions.length,
|
||||
ready: extensions.filter(e => e.status === 'ready').length,
|
||||
error: extensions.filter(e => e.status === 'error').length,
|
||||
extensions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an extension loader instance
|
||||
*/
|
||||
export function createExtensionLoader(
|
||||
config: ExtensionLoaderConfig,
|
||||
mainHandler: MainHandlerInterface
|
||||
): ExtensionLoader {
|
||||
return new ExtensionLoader(config, mainHandler)
|
||||
}
|
||||
231
src/extensions/types.ts
Normal file
231
src/extensions/types.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
/**
|
||||
* Extension System Core Types
|
||||
*
|
||||
* These types define the contract between Lightning.Pub and extensions.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extension metadata
|
||||
*/
|
||||
export interface ExtensionInfo {
|
||||
id: string // Unique identifier (lowercase, no spaces)
|
||||
name: string // Display name
|
||||
version: string // Semver version
|
||||
description: string // Short description
|
||||
author: string // Author name or organization
|
||||
minPubVersion?: string // Minimum Lightning.Pub version required
|
||||
dependencies?: string[] // Other extension IDs this depends on
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension database interface
|
||||
* Provides isolated database access for each extension
|
||||
*/
|
||||
export interface ExtensionDatabase {
|
||||
/**
|
||||
* Execute a write query (INSERT, UPDATE, DELETE, CREATE, etc.)
|
||||
*/
|
||||
execute(sql: string, params?: any[]): Promise<{ changes?: number; lastId?: number }>
|
||||
|
||||
/**
|
||||
* Execute a read query (SELECT)
|
||||
*/
|
||||
query<T = any>(sql: string, params?: any[]): Promise<T[]>
|
||||
|
||||
/**
|
||||
* Execute multiple statements in a transaction
|
||||
*/
|
||||
transaction<T>(fn: () => Promise<T>): Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Application info provided to extensions
|
||||
*/
|
||||
export interface ApplicationInfo {
|
||||
id: string
|
||||
name: string
|
||||
nostr_public: string // Application's Nostr pubkey (hex)
|
||||
balance_sats: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoice creation options
|
||||
*/
|
||||
export interface CreateInvoiceOptions {
|
||||
memo?: string
|
||||
expiry?: number // Seconds until expiry
|
||||
metadata?: Record<string, any> // Custom metadata for callbacks
|
||||
}
|
||||
|
||||
/**
|
||||
* Created invoice result
|
||||
*/
|
||||
export interface CreatedInvoice {
|
||||
id: string // Internal invoice ID
|
||||
paymentRequest: string // BOLT11 invoice string
|
||||
paymentHash: string // Payment hash (hex)
|
||||
expiry: number // Expiry timestamp
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment received callback data
|
||||
*/
|
||||
export interface PaymentReceivedData {
|
||||
invoiceId: string
|
||||
paymentHash: string
|
||||
amountSats: number
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* Nostr event structure (minimal)
|
||||
*/
|
||||
export interface NostrEvent {
|
||||
id: string
|
||||
pubkey: string
|
||||
created_at: number
|
||||
kind: number
|
||||
tags: string[][]
|
||||
content: string
|
||||
sig?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsigned Nostr event for publishing
|
||||
*/
|
||||
export interface UnsignedNostrEvent {
|
||||
kind: number
|
||||
pubkey: string
|
||||
created_at: number
|
||||
tags: string[][]
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC method handler function
|
||||
*/
|
||||
export type RpcMethodHandler = (
|
||||
request: any,
|
||||
applicationId: string,
|
||||
userPubkey?: string
|
||||
) => Promise<any>
|
||||
|
||||
/**
|
||||
* Extension context - interface provided to extensions for interacting with Lightning.Pub
|
||||
*/
|
||||
export interface ExtensionContext {
|
||||
/**
|
||||
* Get information about an application
|
||||
*/
|
||||
getApplication(applicationId: string): Promise<ApplicationInfo | null>
|
||||
|
||||
/**
|
||||
* Create a Lightning invoice
|
||||
*/
|
||||
createInvoice(amountSats: number, options?: CreateInvoiceOptions): Promise<CreatedInvoice>
|
||||
|
||||
/**
|
||||
* Pay a Lightning invoice (requires sufficient balance)
|
||||
*/
|
||||
payInvoice(applicationId: string, paymentRequest: string, maxFeeSats?: number): Promise<{
|
||||
paymentHash: string
|
||||
feeSats: number
|
||||
}>
|
||||
|
||||
/**
|
||||
* Send an encrypted DM via Nostr (NIP-44)
|
||||
*/
|
||||
sendEncryptedDM(applicationId: string, recipientPubkey: string, content: string): Promise<string>
|
||||
|
||||
/**
|
||||
* Publish a Nostr event (signed by application's key)
|
||||
*/
|
||||
publishNostrEvent(event: UnsignedNostrEvent): Promise<string | null>
|
||||
|
||||
/**
|
||||
* Subscribe to payment received callbacks
|
||||
*/
|
||||
onPaymentReceived(callback: (payment: PaymentReceivedData) => Promise<void>): void
|
||||
|
||||
/**
|
||||
* Subscribe to incoming Nostr events for the application
|
||||
*/
|
||||
onNostrEvent(callback: (event: NostrEvent, applicationId: string) => Promise<void>): void
|
||||
|
||||
/**
|
||||
* Register an RPC method
|
||||
*/
|
||||
registerMethod(name: string, handler: RpcMethodHandler): void
|
||||
|
||||
/**
|
||||
* Get the extension's isolated database
|
||||
*/
|
||||
getDatabase(): ExtensionDatabase
|
||||
|
||||
/**
|
||||
* Log a message (prefixed with extension ID)
|
||||
*/
|
||||
log(level: 'debug' | 'info' | 'warn' | 'error', message: string, ...args: any[]): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension interface - what extensions must implement
|
||||
*/
|
||||
export interface Extension {
|
||||
/**
|
||||
* Extension metadata
|
||||
*/
|
||||
readonly info: ExtensionInfo
|
||||
|
||||
/**
|
||||
* Initialize the extension
|
||||
* Called once when the extension is loaded
|
||||
*/
|
||||
initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void>
|
||||
|
||||
/**
|
||||
* Shutdown the extension
|
||||
* Called when Lightning.Pub is shutting down
|
||||
*/
|
||||
shutdown?(): Promise<void>
|
||||
|
||||
/**
|
||||
* Health check
|
||||
* Return true if extension is healthy
|
||||
*/
|
||||
healthCheck?(): Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension constructor type
|
||||
*/
|
||||
export type ExtensionConstructor = new () => Extension
|
||||
|
||||
/**
|
||||
* Extension module default export
|
||||
*/
|
||||
export interface ExtensionModule {
|
||||
default: ExtensionConstructor
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded extension state
|
||||
*/
|
||||
export interface LoadedExtension {
|
||||
info: ExtensionInfo
|
||||
instance: Extension
|
||||
database: ExtensionDatabase
|
||||
status: 'loading' | 'ready' | 'error' | 'stopped'
|
||||
error?: Error
|
||||
loadedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension loader configuration
|
||||
*/
|
||||
export interface ExtensionLoaderConfig {
|
||||
extensionsDir: string // Directory containing extensions
|
||||
databaseDir: string // Directory for extension databases
|
||||
enabledExtensions?: string[] // If set, only load these extensions
|
||||
disabledExtensions?: string[] // Extensions to skip
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue