Compare commits
9 commits
492fb8981a
...
eb0278a82c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb0278a82c | ||
|
|
fe4046a439 | ||
|
|
1f4157b00f | ||
|
|
66b1ceedef | ||
|
|
e6a4994213 | ||
|
|
86baf10041 | ||
|
|
7973fa83cb | ||
|
|
748a2d3ed6 | ||
|
|
30d818c4d4 |
19 changed files with 3049 additions and 1878 deletions
|
|
@ -2,3 +2,21 @@
|
||||||
.github
|
.github
|
||||||
build
|
build
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
# Runtime state files (should not be baked into image)
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-journal
|
||||||
|
*.sqlite-wal
|
||||||
|
*.sqlite-shm
|
||||||
|
*.db
|
||||||
|
admin.connect
|
||||||
|
admin.enroll
|
||||||
|
admin.npub
|
||||||
|
app.nprofile
|
||||||
|
.jwt_secret
|
||||||
|
|
||||||
|
# Runtime data directories
|
||||||
|
metric_cache/
|
||||||
|
metric_events/
|
||||||
|
bundler_events/
|
||||||
|
logs/
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM node:18
|
FROM node:20
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
|
||||||
2926
package-lock.json
generated
2926
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -77,6 +77,7 @@
|
||||||
"zip-a-folder": "^3.1.9"
|
"zip-a-folder": "^3.1.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/chai": "^4.3.4",
|
"@types/chai": "^4.3.4",
|
||||||
"@types/chai-string": "^1.4.5",
|
"@types/chai-string": "^1.4.5",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
|
|
@ -93,4 +94,4 @@
|
||||||
"typescript": "5.5.4"
|
"typescript": "5.5.4"
|
||||||
},
|
},
|
||||||
"overrides": {}
|
"overrides": {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
731
src/extensions/README.md
Normal file
731
src/extensions/README.md
Normal file
|
|
@ -0,0 +1,731 @@
|
||||||
|
# Lightning.Pub Extension System
|
||||||
|
|
||||||
|
A modular extension system that allows third-party functionality to be added to Lightning.Pub without modifying core code.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Architecture](#architecture)
|
||||||
|
- [Creating an Extension](#creating-an-extension)
|
||||||
|
- [Extension Lifecycle](#extension-lifecycle)
|
||||||
|
- [ExtensionContext API](#extensioncontext-api)
|
||||||
|
- [Database Isolation](#database-isolation)
|
||||||
|
- [RPC Methods](#rpc-methods)
|
||||||
|
- [HTTP Routes](#http-routes)
|
||||||
|
- [Event Handling](#event-handling)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [Examples](#examples)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The extension system provides:
|
||||||
|
|
||||||
|
- **Modularity**: Extensions are self-contained modules with their own code and data
|
||||||
|
- **Isolation**: Each extension gets its own SQLite database
|
||||||
|
- **Integration**: Extensions can register RPC methods, handle events, and interact with Lightning.Pub's payment and Nostr systems
|
||||||
|
- **Lifecycle Management**: Automatic discovery, loading, and graceful shutdown
|
||||||
|
|
||||||
|
### Built-in Extensions
|
||||||
|
|
||||||
|
| Extension | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `marketplace` | NIP-15 Nostr marketplace for selling products via Lightning |
|
||||||
|
| `withdraw` | LNURL-withdraw (LUD-03) for vouchers, faucets, and gifts |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Lightning.Pub │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Extension Loader │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ Extension A │ │ Extension B │ │ Extension C │ ... │
|
||||||
|
│ │ ┌───────┐ │ │ ┌───────┐ │ │ ┌───────┐ │ │
|
||||||
|
│ │ │Context│ │ │ │Context│ │ │ │Context│ │ │
|
||||||
|
│ │ └───────┘ │ │ └───────┘ │ │ └───────┘ │ │
|
||||||
|
│ │ ┌───────┐ │ │ ┌───────┐ │ │ ┌───────┐ │ │
|
||||||
|
│ │ │ DB │ │ │ │ DB │ │ │ │ DB │ │ │
|
||||||
|
│ │ └───────┘ │ │ └───────┘ │ │ └───────┘ │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Payment Manager │ Nostr Transport │ Application Manager │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
|
||||||
|
| Component | File | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `ExtensionLoader` | `loader.ts` | Discovers, loads, and manages extensions |
|
||||||
|
| `ExtensionContext` | `context.ts` | Bridge between extensions and Lightning.Pub |
|
||||||
|
| `ExtensionDatabase` | `database.ts` | Isolated SQLite database per extension |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Creating an Extension
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/extensions/
|
||||||
|
└── my-extension/
|
||||||
|
├── index.ts # Main entry point (required)
|
||||||
|
├── types.ts # TypeScript interfaces
|
||||||
|
├── migrations.ts # Database migrations
|
||||||
|
└── managers/ # Business logic
|
||||||
|
└── myManager.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Minimal Extension
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/extensions/my-extension/index.ts
|
||||||
|
|
||||||
|
import { Extension, ExtensionInfo, ExtensionContext, ExtensionDatabase } from '../types.js'
|
||||||
|
|
||||||
|
export default class MyExtension implements Extension {
|
||||||
|
readonly info: ExtensionInfo = {
|
||||||
|
id: 'my-extension', // Must match directory name
|
||||||
|
name: 'My Extension',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Does something useful',
|
||||||
|
author: 'Your Name',
|
||||||
|
minPubVersion: '1.0.0' // Minimum Lightning.Pub version
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void> {
|
||||||
|
// Run migrations
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS my_table (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
data TEXT
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Register RPC methods
|
||||||
|
ctx.registerMethod('my-extension.doSomething', async (req, appId) => {
|
||||||
|
return { result: 'done' }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.log('info', 'Extension initialized')
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown(): Promise<void> {
|
||||||
|
// Cleanup resources
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extension Interface
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Extension {
|
||||||
|
// Required: Extension metadata
|
||||||
|
readonly info: ExtensionInfo
|
||||||
|
|
||||||
|
// Required: Called once when extension is loaded
|
||||||
|
initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void>
|
||||||
|
|
||||||
|
// Optional: Called when Lightning.Pub shuts down
|
||||||
|
shutdown?(): Promise<void>
|
||||||
|
|
||||||
|
// Optional: Health check for monitoring
|
||||||
|
healthCheck?(): Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
minPubVersion?: string // Minimum Lightning.Pub version
|
||||||
|
dependencies?: string[] // Other extension IDs required
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extension Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐
|
||||||
|
│ Discover │ Scan extensions directory for index.ts files
|
||||||
|
└──────┬───────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────┐
|
||||||
|
│ Load │ Import module, instantiate class
|
||||||
|
└──────┬───────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────┐
|
||||||
|
│ Initialize │ Create database, call initialize()
|
||||||
|
└──────┬───────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────┐
|
||||||
|
│ Ready │ Extension is active, handling requests
|
||||||
|
└──────┬───────┘
|
||||||
|
│
|
||||||
|
▼ (on shutdown)
|
||||||
|
┌──────────────┐
|
||||||
|
│ Shutdown │ Call shutdown(), close database
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### States
|
||||||
|
|
||||||
|
| State | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `loading` | Extension is being loaded |
|
||||||
|
| `ready` | Extension is active and healthy |
|
||||||
|
| `error` | Initialization failed |
|
||||||
|
| `stopped` | Extension has been shut down |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ExtensionContext API
|
||||||
|
|
||||||
|
The `ExtensionContext` is passed to your extension during initialization. It provides access to Lightning.Pub functionality.
|
||||||
|
|
||||||
|
### Application Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Get information about an application
|
||||||
|
const app = await ctx.getApplication(applicationId)
|
||||||
|
// Returns: { id, name, nostr_public, balance_sats } | null
|
||||||
|
```
|
||||||
|
|
||||||
|
### Payment Operations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Create a Lightning invoice
|
||||||
|
const invoice = await ctx.createInvoice(amountSats, {
|
||||||
|
memo: 'Payment for service',
|
||||||
|
expiry: 3600, // seconds
|
||||||
|
metadata: { order_id: '123' } // Returned in payment callback
|
||||||
|
})
|
||||||
|
// Returns: { id, paymentRequest, paymentHash, expiry }
|
||||||
|
|
||||||
|
// Pay a Lightning invoice
|
||||||
|
const result = await ctx.payInvoice(applicationId, bolt11Invoice, maxFeeSats)
|
||||||
|
// Returns: { paymentHash, feeSats }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nostr Operations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Send encrypted DM (NIP-44)
|
||||||
|
const eventId = await ctx.sendEncryptedDM(applicationId, recipientPubkey, content)
|
||||||
|
|
||||||
|
// Publish a Nostr event (signed by application's key)
|
||||||
|
const eventId = await ctx.publishNostrEvent({
|
||||||
|
kind: 30017,
|
||||||
|
pubkey: appPubkey,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [['d', 'identifier']],
|
||||||
|
content: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### RPC Method Registration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Register a method that can be called via RPC
|
||||||
|
ctx.registerMethod('my-extension.methodName', async (request, applicationId, userPubkey?) => {
|
||||||
|
// request: The RPC request payload
|
||||||
|
// applicationId: The calling application's ID
|
||||||
|
// userPubkey: The user's Nostr pubkey (if authenticated)
|
||||||
|
|
||||||
|
return { result: 'success' }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Subscriptions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Subscribe to payment received events
|
||||||
|
ctx.onPaymentReceived(async (payment) => {
|
||||||
|
// payment: { invoiceId, paymentHash, amountSats, metadata }
|
||||||
|
|
||||||
|
if (payment.metadata?.extension === 'my-extension') {
|
||||||
|
// Handle payment for this extension
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Subscribe to incoming Nostr events
|
||||||
|
ctx.onNostrEvent(async (event, applicationId) => {
|
||||||
|
// event: { id, pubkey, kind, tags, content, created_at }
|
||||||
|
// applicationId: The application this event is for
|
||||||
|
|
||||||
|
if (event.kind === 4) { // DM
|
||||||
|
// Handle incoming message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
ctx.log('debug', 'Detailed debugging info')
|
||||||
|
ctx.log('info', 'Normal operation info')
|
||||||
|
ctx.log('warn', 'Warning message')
|
||||||
|
ctx.log('error', 'Error occurred', errorObject)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Isolation
|
||||||
|
|
||||||
|
Each extension gets its own SQLite database file at:
|
||||||
|
```
|
||||||
|
{databaseDir}/{extension-id}.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Interface
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ExtensionDatabase {
|
||||||
|
// Execute write queries (INSERT, UPDATE, DELETE, CREATE)
|
||||||
|
execute(sql: string, params?: any[]): Promise<{ changes?: number; lastId?: number }>
|
||||||
|
|
||||||
|
// Execute read queries (SELECT)
|
||||||
|
query<T>(sql: string, params?: any[]): Promise<T[]>
|
||||||
|
|
||||||
|
// Run multiple statements in a transaction
|
||||||
|
transaction<T>(fn: () => Promise<T>): Promise<T>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// migrations.ts
|
||||||
|
|
||||||
|
export interface Migration {
|
||||||
|
version: number
|
||||||
|
name: string
|
||||||
|
up: (db: ExtensionDatabase) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const migrations: Migration[] = [
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
name: 'create_initial_tables',
|
||||||
|
up: async (db) => {
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE items (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 2,
|
||||||
|
name: 'add_status_column',
|
||||||
|
up: async (db) => {
|
||||||
|
await db.execute(`ALTER TABLE items ADD COLUMN status TEXT DEFAULT 'active'`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Run migrations in initialize()
|
||||||
|
export async function runMigrations(db: ExtensionDatabase): Promise<void> {
|
||||||
|
const result = await db.query<{ value: string }>(
|
||||||
|
`SELECT value FROM _extension_meta WHERE key = 'migration_version'`
|
||||||
|
).catch(() => [])
|
||||||
|
|
||||||
|
const currentVersion = result.length > 0 ? parseInt(result[0].value, 10) : 0
|
||||||
|
|
||||||
|
for (const migration of migrations) {
|
||||||
|
if (migration.version > currentVersion) {
|
||||||
|
console.log(`Running migration ${migration.version}: ${migration.name}`)
|
||||||
|
await migration.up(db)
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO _extension_meta (key, value) VALUES ('migration_version', ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||||
|
[String(migration.version)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RPC Methods
|
||||||
|
|
||||||
|
Extensions register RPC methods that can be called by clients.
|
||||||
|
|
||||||
|
### Naming Convention
|
||||||
|
|
||||||
|
Methods should be namespaced with the extension ID:
|
||||||
|
```
|
||||||
|
{extension-id}.{methodName}
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `marketplace.createStall`
|
||||||
|
- `withdraw.createLink`
|
||||||
|
|
||||||
|
### Method Handler Signature
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type RpcMethodHandler = (
|
||||||
|
request: any, // The request payload
|
||||||
|
applicationId: string, // The calling application
|
||||||
|
userPubkey?: string // The authenticated user (if any)
|
||||||
|
) => Promise<any>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
ctx.registerMethod('my-extension.createItem', async (req, appId, userPubkey) => {
|
||||||
|
// Validate request
|
||||||
|
if (!req.name) {
|
||||||
|
throw new Error('Name is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create item
|
||||||
|
const item = await this.manager.create(appId, req)
|
||||||
|
|
||||||
|
// Return response
|
||||||
|
return { item }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTP Routes
|
||||||
|
|
||||||
|
Some extensions need HTTP endpoints (e.g., LNURL protocol). Extensions can define routes that the main application mounts.
|
||||||
|
|
||||||
|
### Defining Routes
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface HttpRoute {
|
||||||
|
method: 'GET' | 'POST'
|
||||||
|
path: string
|
||||||
|
handler: (req: HttpRequest) => Promise<HttpResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HttpRequest {
|
||||||
|
params: Record<string, string> // URL path params
|
||||||
|
query: Record<string, string> // Query string params
|
||||||
|
body?: any // POST body
|
||||||
|
headers: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HttpResponse {
|
||||||
|
status: number
|
||||||
|
body: any
|
||||||
|
headers?: Record<string, string>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class MyExtension implements Extension {
|
||||||
|
getHttpRoutes(): HttpRoute[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/v1/my-extension/:id',
|
||||||
|
handler: async (req) => {
|
||||||
|
const item = await this.getItem(req.params.id)
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: item,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event Handling
|
||||||
|
|
||||||
|
### Payment Callbacks
|
||||||
|
|
||||||
|
When you create an invoice with metadata, you'll receive that metadata back in the payment callback:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Creating invoice with metadata
|
||||||
|
const invoice = await ctx.createInvoice(1000, {
|
||||||
|
metadata: {
|
||||||
|
extension: 'my-extension',
|
||||||
|
order_id: 'order-123'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handling payment
|
||||||
|
ctx.onPaymentReceived(async (payment) => {
|
||||||
|
if (payment.metadata?.extension === 'my-extension') {
|
||||||
|
const orderId = payment.metadata.order_id
|
||||||
|
await this.handlePayment(orderId, payment)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nostr Events
|
||||||
|
|
||||||
|
Subscribe to Nostr events for your application:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
ctx.onNostrEvent(async (event, applicationId) => {
|
||||||
|
// Filter by event kind
|
||||||
|
if (event.kind === 4) { // Encrypted DM
|
||||||
|
await this.handleDirectMessage(event, applicationId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Loader Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ExtensionLoaderConfig {
|
||||||
|
extensionsDir: string // Directory containing extensions
|
||||||
|
databaseDir: string // Directory for extension databases
|
||||||
|
enabledExtensions?: string[] // Whitelist (if set, only these load)
|
||||||
|
disabledExtensions?: string[] // Blacklist
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createExtensionLoader } from './extensions'
|
||||||
|
|
||||||
|
const loader = createExtensionLoader({
|
||||||
|
extensionsDir: './src/extensions',
|
||||||
|
databaseDir: './data/extensions',
|
||||||
|
disabledExtensions: ['experimental-ext']
|
||||||
|
}, mainHandler)
|
||||||
|
|
||||||
|
await loader.loadAll()
|
||||||
|
|
||||||
|
// Call extension methods
|
||||||
|
const result = await loader.callMethod(
|
||||||
|
'marketplace.createStall',
|
||||||
|
{ name: 'My Shop', currency: 'sat', shipping_zones: [] },
|
||||||
|
applicationId,
|
||||||
|
userPubkey
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dispatch events
|
||||||
|
loader.dispatchPaymentReceived(paymentData)
|
||||||
|
loader.dispatchNostrEvent(event, applicationId)
|
||||||
|
|
||||||
|
// Shutdown
|
||||||
|
await loader.shutdown()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Example: Simple Counter Extension
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/extensions/counter/index.ts
|
||||||
|
|
||||||
|
import { Extension, ExtensionInfo, ExtensionContext, ExtensionDatabase } from '../types.js'
|
||||||
|
|
||||||
|
export default class CounterExtension implements Extension {
|
||||||
|
readonly info: ExtensionInfo = {
|
||||||
|
id: 'counter',
|
||||||
|
name: 'Simple Counter',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'A simple counter for each application',
|
||||||
|
author: 'Example'
|
||||||
|
}
|
||||||
|
|
||||||
|
private db!: ExtensionDatabase
|
||||||
|
|
||||||
|
async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void> {
|
||||||
|
this.db = db
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS counters (
|
||||||
|
application_id TEXT PRIMARY KEY,
|
||||||
|
count INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
ctx.registerMethod('counter.increment', async (req, appId) => {
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO counters (application_id, count) VALUES (?, 1)
|
||||||
|
ON CONFLICT(application_id) DO UPDATE SET count = count + 1`,
|
||||||
|
[appId]
|
||||||
|
)
|
||||||
|
const result = await db.query<{ count: number }>(
|
||||||
|
'SELECT count FROM counters WHERE application_id = ?',
|
||||||
|
[appId]
|
||||||
|
)
|
||||||
|
return { count: result[0]?.count || 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('counter.get', async (req, appId) => {
|
||||||
|
const result = await db.query<{ count: number }>(
|
||||||
|
'SELECT count FROM counters WHERE application_id = ?',
|
||||||
|
[appId]
|
||||||
|
)
|
||||||
|
return { count: result[0]?.count || 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.registerMethod('counter.reset', async (req, appId) => {
|
||||||
|
await db.execute(
|
||||||
|
'UPDATE counters SET count = 0 WHERE application_id = ?',
|
||||||
|
[appId]
|
||||||
|
)
|
||||||
|
return { count: 0 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Payment-Triggered Extension
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/extensions/donations/index.ts
|
||||||
|
|
||||||
|
import { Extension, ExtensionContext, ExtensionDatabase } from '../types.js'
|
||||||
|
|
||||||
|
export default class DonationsExtension implements Extension {
|
||||||
|
readonly info = {
|
||||||
|
id: 'donations',
|
||||||
|
name: 'Donations',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Accept donations with thank-you messages',
|
||||||
|
author: 'Example'
|
||||||
|
}
|
||||||
|
|
||||||
|
private db!: ExtensionDatabase
|
||||||
|
private ctx!: ExtensionContext
|
||||||
|
|
||||||
|
async initialize(ctx: ExtensionContext, db: ExtensionDatabase): Promise<void> {
|
||||||
|
this.db = db
|
||||||
|
this.ctx = ctx
|
||||||
|
|
||||||
|
await db.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS donations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
application_id TEXT NOT NULL,
|
||||||
|
amount_sats INTEGER NOT NULL,
|
||||||
|
donor_pubkey TEXT,
|
||||||
|
message TEXT,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Create donation invoice
|
||||||
|
ctx.registerMethod('donations.createInvoice', async (req, appId) => {
|
||||||
|
const invoice = await ctx.createInvoice(req.amount_sats, {
|
||||||
|
memo: req.message || 'Donation',
|
||||||
|
metadata: {
|
||||||
|
extension: 'donations',
|
||||||
|
donor_pubkey: req.donor_pubkey,
|
||||||
|
message: req.message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { invoice: invoice.paymentRequest }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle successful payments
|
||||||
|
ctx.onPaymentReceived(async (payment) => {
|
||||||
|
if (payment.metadata?.extension !== 'donations') return
|
||||||
|
|
||||||
|
// Record donation
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO donations (id, application_id, amount_sats, donor_pubkey, message, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
payment.paymentHash,
|
||||||
|
payment.metadata.application_id,
|
||||||
|
payment.amountSats,
|
||||||
|
payment.metadata.donor_pubkey,
|
||||||
|
payment.metadata.message,
|
||||||
|
Math.floor(Date.now() / 1000)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send thank-you DM if donor has pubkey
|
||||||
|
if (payment.metadata.donor_pubkey) {
|
||||||
|
await ctx.sendEncryptedDM(
|
||||||
|
payment.metadata.application_id,
|
||||||
|
payment.metadata.donor_pubkey,
|
||||||
|
`Thank you for your donation of ${payment.amountSats} sats!`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// List donations
|
||||||
|
ctx.registerMethod('donations.list', async (req, appId) => {
|
||||||
|
const donations = await db.query(
|
||||||
|
`SELECT * FROM donations WHERE application_id = ? ORDER BY created_at DESC LIMIT ?`,
|
||||||
|
[appId, req.limit || 50]
|
||||||
|
)
|
||||||
|
return { donations }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Namespace your methods**: Always prefix RPC methods with your extension ID
|
||||||
|
2. **Use migrations**: Never modify existing migration files; create new ones
|
||||||
|
3. **Handle errors gracefully**: Throw descriptive errors, don't return error objects
|
||||||
|
4. **Clean up in shutdown**: Close connections, cancel timers, etc.
|
||||||
|
5. **Log appropriately**: Use debug for verbose info, error for failures
|
||||||
|
6. **Validate inputs**: Check request parameters before processing
|
||||||
|
7. **Use transactions**: For multi-step database operations
|
||||||
|
8. **Document your API**: Include types and descriptions for RPC methods
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Extension not loading
|
||||||
|
|
||||||
|
1. Check that directory name matches `info.id`
|
||||||
|
2. Verify `index.ts` has a default export
|
||||||
|
3. Check for TypeScript/import errors in logs
|
||||||
|
|
||||||
|
### Database errors
|
||||||
|
|
||||||
|
1. Check migration syntax
|
||||||
|
2. Verify column types match queries
|
||||||
|
3. Look for migration version conflicts
|
||||||
|
|
||||||
|
### RPC method not found
|
||||||
|
|
||||||
|
1. Verify method is registered in `initialize()`
|
||||||
|
2. Check method name includes extension prefix
|
||||||
|
3. Ensure extension status is `ready`
|
||||||
|
|
||||||
|
### Payment callbacks not firing
|
||||||
|
|
||||||
|
1. Verify `metadata.extension` matches your extension ID
|
||||||
|
2. Check that `onPaymentReceived` is registered in `initialize()`
|
||||||
|
3. Confirm invoice was created through the extension
|
||||||
309
src/extensions/context.ts
Normal file
309
src/extensions/context.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
import {
|
||||||
|
ExtensionContext,
|
||||||
|
ExtensionDatabase,
|
||||||
|
ExtensionInfo,
|
||||||
|
ApplicationInfo,
|
||||||
|
CreateInvoiceOptions,
|
||||||
|
CreatedInvoice,
|
||||||
|
PaymentReceivedData,
|
||||||
|
NostrEvent,
|
||||||
|
UnsignedNostrEvent,
|
||||||
|
RpcMethodHandler,
|
||||||
|
LnurlPayInfo
|
||||||
|
} 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
|
||||||
|
}>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get LNURL-pay info for a user by their Nostr pubkey
|
||||||
|
* This enables Lightning Address (LUD-16) and zap (NIP-57) support
|
||||||
|
*/
|
||||||
|
getLnurlPayInfoByPubkey(pubkeyHex: string, options?: {
|
||||||
|
metadata?: string
|
||||||
|
description?: string
|
||||||
|
}): Promise<LnurlPayInfo>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get LNURL-pay info for a user by pubkey
|
||||||
|
* Enables Lightning Address and zap support
|
||||||
|
*/
|
||||||
|
async getLnurlPayInfo(pubkeyHex: string, options?: {
|
||||||
|
metadata?: string
|
||||||
|
description?: string
|
||||||
|
}): Promise<LnurlPayInfo> {
|
||||||
|
return this.mainHandler.paymentManager.getLnurlPayInfoByPubkey(pubkeyHex, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
254
src/extensions/types.ts
Normal file
254
src/extensions/types.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LNURL-pay info response (LUD-06/LUD-16)
|
||||||
|
* Used for Lightning Address and zap support
|
||||||
|
*/
|
||||||
|
export interface LnurlPayInfo {
|
||||||
|
tag: 'payRequest'
|
||||||
|
callback: string // URL to call with amount
|
||||||
|
minSendable: number // Minimum msats
|
||||||
|
maxSendable: number // Maximum msats
|
||||||
|
metadata: string // JSON-encoded metadata array
|
||||||
|
allowsNostr?: boolean // Whether zaps are supported
|
||||||
|
nostrPubkey?: string // Pubkey for zap receipts (hex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get LNURL-pay info for a user (by pubkey)
|
||||||
|
* Used to enable Lightning Address support (LUD-16) and zaps (NIP-57)
|
||||||
|
*/
|
||||||
|
getLnurlPayInfo(pubkeyHex: string, options?: {
|
||||||
|
metadata?: string // Custom metadata JSON
|
||||||
|
description?: string // Human-readable description
|
||||||
|
}): Promise<LnurlPayInfo>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
|
@ -105,7 +105,7 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Stop: () => { mainHandler.adminManager.setNostrConnected(false); return nostr.Stop },
|
Stop: () => { mainHandler.adminManager.setNostrConnected(false); return nostr.Stop },
|
||||||
Send: (...args) => nostr.Send(...args),
|
Send: async (...args) => nostr.Send(...args),
|
||||||
Ping: () => nostr.Ping(),
|
Ping: () => nostr.Ping(),
|
||||||
Reset: (settings: NostrSettings) => nostr.Reset(settings)
|
Reset: (settings: NostrSettings) => nostr.Reset(settings)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,8 @@ export default class {
|
||||||
const paid = await this.paymentManager.PayInvoice(appUser.user.user_id, req, app, {
|
const paid = await this.paymentManager.PayInvoice(appUser.user.user_id, req, app, {
|
||||||
ack: pendingOp => { this.notifyAppUserPayment(appUser, pendingOp) }
|
ack: pendingOp => { this.notifyAppUserPayment(appUser, pendingOp) }
|
||||||
})
|
})
|
||||||
|
// Refresh appUser balance from DB so notification has accurate latest_balance
|
||||||
|
appUser.user.balance_sats = paid.latest_balance
|
||||||
this.notifyAppUserPayment(appUser, paid.operation)
|
this.notifyAppUserPayment(appUser, paid.operation)
|
||||||
getLogger({ appName: app.name })(appUser.identifier, "invoice paid", paid.amount_paid, "sats")
|
getLogger({ appName: app.name })(appUser.identifier, "invoice paid", paid.amount_paid, "sats")
|
||||||
return paid
|
return paid
|
||||||
|
|
|
||||||
|
|
@ -153,13 +153,14 @@ export class DebitManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyPaymentSuccess = (debitRes: NdebitSuccess, event: { pub: string, id: string, appId: string }) => {
|
notifyPaymentSuccess = (debitRes: NdebitSuccess, event: { pub: string, id: string, appId: string }) => {
|
||||||
|
this.logger("✅ [DEBIT REQUEST] Payment successful, sending OK response to", event.pub.slice(0, 16) + "...", "for event", event.id.slice(0, 16) + "...")
|
||||||
this.sendDebitResponse(debitRes, event)
|
this.sendDebitResponse(debitRes, event)
|
||||||
}
|
}
|
||||||
|
|
||||||
sendDebitResponse = (debitRes: NdebitFailure | NdebitSuccess, event: { pub: string, id: string, appId: string }) => {
|
sendDebitResponse = (debitRes: NdebitFailure | NdebitSuccess, event: { pub: string, id: string, appId: string }) => {
|
||||||
|
this.logger("📤 [DEBIT RESPONSE] Sending Kind 21002 response:", JSON.stringify(debitRes), "to", event.pub.slice(0, 16) + "...")
|
||||||
const e = newNdebitResponse(JSON.stringify(debitRes), event)
|
const e = newNdebitResponse(JSON.stringify(debitRes), event)
|
||||||
this.storage.NostrSender().Send({ type: 'app', appId: event.appId }, { type: 'event', event: e, encrypt: { toPub: event.pub } })
|
this.storage.NostrSender().Send({ type: 'app', appId: event.appId }, { type: 'event', event: e, encrypt: { toPub: event.pub } })
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
payNdebitInvoice = async (event: NostrEvent, pointerdata: NdebitData): Promise<HandleNdebitRes> => {
|
payNdebitInvoice = async (event: NostrEvent, pointerdata: NdebitData): Promise<HandleNdebitRes> => {
|
||||||
|
|
|
||||||
|
|
@ -132,12 +132,12 @@ const handleNostrSettings = (settings: NostrSettings) => {
|
||||||
send(event)
|
send(event)
|
||||||
})
|
})
|
||||||
} */
|
} */
|
||||||
const sendToNostr: NostrSend = (initiator, data, relays) => {
|
const sendToNostr: NostrSend = async (initiator, data, relays) => {
|
||||||
if (!subProcessHandler) {
|
if (!subProcessHandler) {
|
||||||
getLogger({ component: "nostrMiddleware" })(ERROR, "nostr was not initialized")
|
getLogger({ component: "nostrMiddleware" })(ERROR, "nostr was not initialized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
subProcessHandler.Send(initiator, data, relays)
|
await subProcessHandler.Send(initiator, data, relays)
|
||||||
}
|
}
|
||||||
|
|
||||||
send({ type: 'ready' })
|
send({ type: 'ready' })
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { base64 } from "@scure/base";
|
import { base64, hex } from "@scure/base";
|
||||||
import { randomBytes } from "@noble/hashes/utils";
|
import { randomBytes } from "@noble/hashes/utils";
|
||||||
import { streamXOR as xchacha20 } from "@stablelib/xchacha20";
|
import { streamXOR as xchacha20 } from "@stablelib/xchacha20";
|
||||||
import { secp256k1 } from "@noble/curves/secp256k1";
|
import { secp256k1 } from "@noble/curves/secp256k1.js";
|
||||||
import { sha256 } from "@noble/hashes/sha256";
|
import { sha256 } from "@noble/hashes/sha256";
|
||||||
export type EncryptedData = {
|
export type EncryptedData = {
|
||||||
ciphertext: Uint8Array;
|
ciphertext: Uint8Array;
|
||||||
nonce: Uint8Array;
|
nonce: Uint8Array;
|
||||||
}
|
}
|
||||||
export const getSharedSecret = (privateKey: string, publicKey: string) => {
|
export const getSharedSecret = (privateKey: string, publicKey: string) => {
|
||||||
const key = secp256k1.getSharedSecret(privateKey, "02" + publicKey);
|
const key = secp256k1.getSharedSecret(hex.decode(privateKey), hex.decode("02" + publicKey));
|
||||||
return sha256(key.slice(1, 33));
|
return sha256(key.slice(1, 33));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export type SendDataContent = { type: "content", content: string, pub: string }
|
||||||
export type SendDataEvent = { type: "event", event: UnsignedEvent, encrypt?: { toPub: string } }
|
export type SendDataEvent = { type: "event", event: UnsignedEvent, encrypt?: { toPub: string } }
|
||||||
export type SendData = SendDataContent | SendDataEvent
|
export type SendData = SendDataContent | SendDataEvent
|
||||||
export type SendInitiator = { type: 'app', appId: string } | { type: 'client', clientId: string }
|
export type SendInitiator = { type: 'app', appId: string } | { type: 'client', clientId: string }
|
||||||
export type NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => void
|
export type NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => Promise<void>
|
||||||
|
|
||||||
export type LinkedProviderInfo = { pubkey: string, clientId: string, relayUrl: string }
|
export type LinkedProviderInfo = { pubkey: string, clientId: string, relayUrl: string }
|
||||||
export type AppInfo = { appId: string, publicKey: string, privateKey: string, name: string, provider?: LinkedProviderInfo }
|
export type AppInfo = { appId: string, publicKey: string, privateKey: string, name: string, provider?: LinkedProviderInfo }
|
||||||
|
|
@ -203,21 +203,26 @@ export class NostrPool {
|
||||||
const signed = finalizeEvent(event, Buffer.from(keys.privateKey, 'hex'))
|
const signed = finalizeEvent(event, Buffer.from(keys.privateKey, 'hex'))
|
||||||
let sent = false
|
let sent = false
|
||||||
const log = getLogger({ appName: keys.name })
|
const log = getLogger({ appName: keys.name })
|
||||||
// const r = relays ? relays : this.getServiceRelays()
|
this.log(`📤 Publishing Kind ${event.kind} event to ${relays.length} relay(s): ${relays.join(', ')}`)
|
||||||
const pool = new SimplePool()
|
const pool = new SimplePool()
|
||||||
await Promise.all(pool.publish(relays, signed).map(async p => {
|
try {
|
||||||
try {
|
await Promise.all(pool.publish(relays, signed).map(async p => {
|
||||||
await p
|
try {
|
||||||
sent = true
|
await p
|
||||||
} catch (e: any) {
|
sent = true
|
||||||
console.log(e)
|
} catch (e: any) {
|
||||||
log(e)
|
this.log(ERROR, `Failed to publish Kind ${event.kind} event:`, e.message || e)
|
||||||
|
log(e)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
if (!sent) {
|
||||||
|
this.log(ERROR, `Failed to send Kind ${event.kind} event to any relay`)
|
||||||
|
log("failed to send event")
|
||||||
|
} else {
|
||||||
|
this.log(`✅ Kind ${event.kind} event published successfully (id: ${signed.id.slice(0, 16)}...)`)
|
||||||
}
|
}
|
||||||
}))
|
} finally {
|
||||||
if (!sent) {
|
pool.close(relays)
|
||||||
log("failed to send event")
|
|
||||||
} else {
|
|
||||||
//log("sent event")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { NostrSend, SendData, SendInitiator } from "./nostrPool.js"
|
import { NostrSend, SendData, SendInitiator } from "./nostrPool.js"
|
||||||
import { getLogger } from "../helpers/logger.js"
|
import { ERROR, getLogger } from "../helpers/logger.js"
|
||||||
export class NostrSender {
|
export class NostrSender {
|
||||||
private _nostrSend: NostrSend = () => { throw new Error('nostr send not initialized yet') }
|
private _nostrSend: NostrSend = async () => { throw new Error('nostr send not initialized yet') }
|
||||||
private isReady: boolean = false
|
private isReady: boolean = false
|
||||||
private onReadyCallbacks: (() => void)[] = []
|
private onReadyCallbacks: (() => void)[] = []
|
||||||
private pendingSends: { initiator: SendInitiator, data: SendData, relays?: string[] | undefined }[] = []
|
private pendingSends: { initiator: SendInitiator, data: SendData, relays?: string[] | undefined }[] = []
|
||||||
|
|
@ -12,7 +12,12 @@ export class NostrSender {
|
||||||
this.isReady = true
|
this.isReady = true
|
||||||
this.onReadyCallbacks.forEach(cb => cb())
|
this.onReadyCallbacks.forEach(cb => cb())
|
||||||
this.onReadyCallbacks = []
|
this.onReadyCallbacks = []
|
||||||
this.pendingSends.forEach(send => this._nostrSend(send.initiator, send.data, send.relays))
|
// Process pending sends with proper error handling
|
||||||
|
this.pendingSends.forEach(send => {
|
||||||
|
this._nostrSend(send.initiator, send.data, send.relays).catch(e => {
|
||||||
|
this.log(ERROR, "failed to send pending event", e.message || e)
|
||||||
|
})
|
||||||
|
})
|
||||||
this.pendingSends = []
|
this.pendingSends = []
|
||||||
}
|
}
|
||||||
OnReady(callback: () => void) {
|
OnReady(callback: () => void) {
|
||||||
|
|
@ -22,13 +27,16 @@ export class NostrSender {
|
||||||
this.onReadyCallbacks.push(callback)
|
this.onReadyCallbacks.push(callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Send(initiator: SendInitiator, data: SendData, relays?: string[] | undefined) {
|
Send(initiator: SendInitiator, data: SendData, relays?: string[] | undefined): void {
|
||||||
if (!this.isReady) {
|
if (!this.isReady) {
|
||||||
this.log("tried to send before nostr was ready, caching request")
|
this.log("tried to send before nostr was ready, caching request")
|
||||||
this.pendingSends.push({ initiator, data, relays })
|
this.pendingSends.push({ initiator, data, relays })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this._nostrSend(initiator, data, relays)
|
// Fire and forget but log errors
|
||||||
|
this._nostrSend(initiator, data, relays).catch(e => {
|
||||||
|
this.log(ERROR, "failed to send event", e.message || e)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
IsReady() {
|
IsReady() {
|
||||||
return this.isReady
|
return this.isReady
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ class TlvFilesStorageProcessor {
|
||||||
throw new Error('Unknown metric type: ' + t)
|
throw new Error('Unknown metric type: ' + t)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
this.wrtc.attachNostrSend((initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => {
|
this.wrtc.attachNostrSend(async (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => {
|
||||||
this.sendResponse({
|
this.sendResponse({
|
||||||
success: true,
|
success: true,
|
||||||
type: 'nostrSend',
|
type: 'nostrSend',
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,11 @@ export default class webRTC {
|
||||||
attachNostrSend(f: NostrSend) {
|
attachNostrSend(f: NostrSend) {
|
||||||
this._nostrSend = f
|
this._nostrSend = f
|
||||||
}
|
}
|
||||||
private nostrSend: NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => {
|
private nostrSend: NostrSend = async (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => {
|
||||||
if (!this._nostrSend) {
|
if (!this._nostrSend) {
|
||||||
throw new Error("No nostrSend attached")
|
throw new Error("No nostrSend attached")
|
||||||
}
|
}
|
||||||
this._nostrSend(initiator, data, relays)
|
await this._nostrSend(initiator, data, relays)
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendCandidate = (u: WebRtcUserInfo, candidate: string) => {
|
private sendCandidate = (u: WebRtcUserInfo, candidate: string) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue