wireweave 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,57 +2,72 @@
2
2
 
3
3
  > probably emerging 🌀
4
4
 
5
- nostr + webrtc voice SDK. the networking layer for 247420 projects.
5
+ serverless nostr + webrtc voice SDK. the networking layer for 247420 projects.
6
6
 
7
7
  ```
8
8
  npm i wireweave
9
9
  ```
10
10
 
11
- ## what it is
11
+ ## one-liner setup
12
12
 
13
- serverless nostr relay pool + auth, designed for voice/chat apps that run on public relays with zero backend. extracted from [zellous](https://github.com/AnEntrypoint/zellous). buildless, esm-only, no framework lock-in.
14
-
15
- ## what's in this release
13
+ ```js
14
+ import { createWireweave } from 'wireweave';
15
+ import * as NostrTools from 'nostr-tools';
16
+ import * as XState from 'xstate';
16
17
 
17
- **SDK-shaped (ES modules, zero window.*):**
18
- - `RelayPool` — multi-relay connect, subscribe, publish, reconnect with backoff, event dedup
19
- - `NostrAuth` — local-key gen, nsec/hex import, NIP-07 extension login, event signing
18
+ const ww = createWireweave({ nostrTools: NostrTools, xstate: XState });
19
+ ww.pool.connect();
20
+ ww.auth.loadFromStorage() || ww.auth.generateKey();
21
+ ww.servers.init();
22
+ ```
20
23
 
21
- **`magicwand/legacy/` full browser-globals port (17 files, ~94kb):**
22
- nostr voice (perfect-negotiation, SFU hub election, camera), chat, channels, servers, roles, bans, media (blossom), pages, settings. copied verbatim from zellous. expects `state` + `XState` + `NostrTools` + `ui` on window. load order in `src/legacy/README.md`.
24
+ `ww` exposes: `pool`, `auth`, `fsm`, `message`, `bans`, `roles`, `settings`, `pages`, `media`, `channels`, `servers`, `chat`, `voice` (lazy via `ensureVoice()`), `setCurrentChannel()`.
23
25
 
24
- SDK-shaping the voice/chat/channels/servers into proper ES modules lands in 0.3+. legacy files are the honest "move everything" — they work today exactly as they did in zellous.
26
+ every submodule is an `EventTarget`. subscribe with `addEventListener('event', ...)`.
25
27
 
26
- ## usage
28
+ ## modules
27
29
 
28
30
  ```js
29
- import { RelayPool, NostrAuth } from 'wireweave';
30
- import * as NostrTools from 'nostr-tools';
31
+ import {
32
+ RelayPool, NostrAuth, VoiceSession, Chat, Channels, Servers,
33
+ Bans, Roles, Settings, Media, Pages, MessageBus, createFSM
34
+ } from 'wireweave';
35
+ ```
31
36
 
32
- const auth = new NostrAuth({ nostrTools: NostrTools, storage: localStorage });
33
- auth.generateKey();
37
+ each also exported via subpath: `wireweave/relay-pool`, `wireweave/voice`, `wireweave/chat`, etc.
34
38
 
35
- const pool = new RelayPool({
36
- relays: ['wss://relay.damus.io', 'wss://nos.lol'],
37
- verifyEvent: NostrTools.verifyEvent
38
- });
39
- pool.connect();
39
+ ## voice (webrtc mesh + sfu hub election)
40
40
 
41
- pool.subscribe('mysub', [{ kinds: [1], limit: 10 }], (event) => {
42
- console.log('got event', event.id);
43
- });
44
-
45
- const signed = await auth.sign({
46
- kind: 1, created_at: Math.floor(Date.now()/1000), tags: [], content: 'gm'
41
+ ```js
42
+ const voice = ww.ensureVoice({
43
+ serverId: 'abc:xyz',
44
+ displayName: 'you',
45
+ onAudioTrack: ({ peer, stream }) => {
46
+ const a = new Audio();
47
+ a.srcObject = stream;
48
+ a.autoplay = true;
49
+ document.body.appendChild(a);
50
+ peer.audioEl = a;
51
+ },
52
+ onVideoTrack: ({ peerPubkey, stream }) => { /* attach to <video> */ }
47
53
  });
48
- pool.publish(signed);
54
+ await voice.connect('general-voice', { displayName: 'you' });
55
+ voice.toggleMic();
56
+ voice.toggleDeafen();
57
+ voice.addEventListener('participants', e => console.log(e.detail.list));
49
58
  ```
50
59
 
51
- ## node usage
60
+ Voice carries every empirically-discovered reliability pattern: perfect negotiation (RFC 8840), ICE restart on disconnect, track-stall detection, SFU hub election (mesh→star at 3+ peers), exponential-backoff reconnect.
61
+
62
+ ## node usage (relay + auth only)
52
63
 
53
64
  ```js
54
65
  import WebSocket from 'ws';
55
- const pool = new RelayPool({ relays: [...], WebSocketImpl: WebSocket });
66
+ import { RelayPool, NostrAuth } from 'wireweave';
67
+ import * as NostrTools from 'nostr-tools';
68
+
69
+ const pool = new RelayPool({ relays: ['wss://relay.damus.io'], verifyEvent: NostrTools.verifyEvent, WebSocketImpl: WebSocket });
70
+ const auth = new NostrAuth({ nostrTools: NostrTools });
56
71
  ```
57
72
 
58
73
  ## test
@@ -61,11 +76,7 @@ const pool = new RelayPool({ relays: [...], WebSocketImpl: WebSocket });
61
76
  npm test
62
77
  ```
63
78
 
64
- hits `wss://relay.damus.io` for a real publish → subscribe round-trip. no mocks.
65
-
66
- ## peer deps
67
-
68
- - `nostr-tools` ^2.7
79
+ hits `wss://relay.damus.io` for a real publish → subscribe round-trip.
69
80
 
70
81
  ## license
71
82
 
package/package.json CHANGED
@@ -1,17 +1,27 @@
1
1
  {
2
2
  "name": "wireweave",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "nostr + webrtc voice SDK. networking layer for 247420 projects.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "module": "./src/index.js",
8
8
  "exports": {
9
9
  ".": "./src/index.js",
10
+ "./wireweave": "./src/wireweave.js",
10
11
  "./relay-pool": "./src/relay-pool.js",
11
12
  "./auth": "./src/auth.js",
12
- "./debug": "./src/debug.js",
13
- "./legacy": "./src/legacy/index.js",
14
- "./legacy/*": "./src/legacy/*"
13
+ "./fsm": "./src/fsm.js",
14
+ "./voice": "./src/voice.js",
15
+ "./chat": "./src/chat.js",
16
+ "./channels": "./src/channels.js",
17
+ "./servers": "./src/servers.js",
18
+ "./message": "./src/message.js",
19
+ "./bans": "./src/bans.js",
20
+ "./roles": "./src/roles.js",
21
+ "./settings": "./src/settings.js",
22
+ "./media": "./src/media.js",
23
+ "./pages": "./src/pages.js",
24
+ "./debug": "./src/debug.js"
15
25
  },
16
26
  "files": [
17
27
  "src",
package/src/bans.js ADDED
@@ -0,0 +1,45 @@
1
+ export class Bans extends EventTarget {
2
+ constructor({ relayPool }) {
3
+ super();
4
+ if (!relayPool) throw new Error('Bans: relayPool required');
5
+ this.pool = relayPool;
6
+ this.store = new Map();
7
+ this.sub = null;
8
+ }
9
+
10
+ isBanned(serverId, pubkey) { return !!(this.store.get(serverId)?.banned || []).includes(pubkey); }
11
+
12
+ isTimedOut(serverId, pubkey) {
13
+ const t = this.store.get(serverId)?.timeouts?.[pubkey];
14
+ return !!t && t.expiry > Math.floor(Date.now() / 1000);
15
+ }
16
+
17
+ subscribe(serverId) {
18
+ if (this.sub) { this.pool.unsubscribe(this.sub); this.sub = null; }
19
+ if (!serverId) return;
20
+ const creator = serverId.split(':')[0];
21
+ if (!creator) return;
22
+ this.sub = 'bans-' + serverId;
23
+ this.pool.subscribe(this.sub,
24
+ [{ kinds: [30078], authors: [creator], '#d': ['zellous-ban:' + serverId, 'zellous-timeout:' + serverId] }],
25
+ (event) => {
26
+ if (event.pubkey !== creator) return;
27
+ try {
28
+ const dTag = event.tags.find(t => t[0] === 'd');
29
+ if (!dTag?.[1]) return;
30
+ const [prefix, , pubkey] = dTag[1].split(':');
31
+ const data = this.store.get(serverId) || { banned: [], timeouts: {} };
32
+ if (prefix === 'zellous-ban' && pubkey && !data.banned.includes(pubkey)) data.banned.push(pubkey);
33
+ else if (prefix === 'zellous-timeout' && pubkey) {
34
+ const parsed = JSON.parse(event.content);
35
+ if (parsed.expiry > Math.floor(Date.now() / 1000)) (data.timeouts = data.timeouts || {})[pubkey] = { expiry: parsed.expiry };
36
+ else if (data.timeouts?.[pubkey]) delete data.timeouts[pubkey];
37
+ }
38
+ this.store.set(serverId, data);
39
+ this.dispatchEvent(new CustomEvent('updated', { detail: { serverId, data } }));
40
+ } catch {}
41
+ });
42
+ }
43
+ }
44
+
45
+ export const createBans = (opts) => new Bans(opts);
@@ -0,0 +1,107 @@
1
+ const DEFAULT_CATEGORIES = [
2
+ { id: 'general', name: 'TEXT CHANNELS', position: 0 },
3
+ { id: 'voice', name: 'VOICE CHANNELS', position: 1 }
4
+ ];
5
+
6
+ const DEFAULT_CHANNELS = [
7
+ { id: 'general', name: 'general', type: 'text', categoryId: 'general', position: 0 },
8
+ { id: 'announcements', name: 'announcements', type: 'announcement', categoryId: 'general', position: 1 },
9
+ { id: 'general-voice', name: 'General', type: 'voice', categoryId: 'voice', position: 0 }
10
+ ];
11
+
12
+ export class Channels extends EventTarget {
13
+ constructor({ relayPool, auth }) {
14
+ super();
15
+ if (!relayPool || !auth) throw new Error('Channels: relayPool + auth required');
16
+ this.pool = relayPool; this.auth = auth;
17
+ this.serverId = ''; this.channels = []; this.categories = [];
18
+ }
19
+
20
+ isOwner() { return this.auth.pubkey && this.serverId && this.auth.pubkey === this.serverId.split(':')[0]; }
21
+
22
+ load(serverId, onReady) {
23
+ this.serverId = serverId;
24
+ this.channels = []; this.categories = [];
25
+ const ownerPubkey = serverId.split(':')[0];
26
+ const dTag = 'zellous-channels:' + serverId;
27
+ this.pool.subscribe('channels-' + serverId,
28
+ [{ kinds: [30078], authors: [ownerPubkey], '#d': [dTag] }],
29
+ (event) => {
30
+ const hasTag = event.tags?.some(t => t[0] === 'd' && t[1] === dTag);
31
+ if (!hasTag) return;
32
+ try {
33
+ const data = JSON.parse(event.content);
34
+ this.channels = data.channels || [];
35
+ this.categories = data.categories || [];
36
+ this._emit('updated', { channels: this.channels, categories: this.categories });
37
+ } catch {}
38
+ },
39
+ () => {
40
+ if (!this.channels.length) this._setDefaults();
41
+ onReady?.();
42
+ });
43
+ }
44
+
45
+ _setDefaults() {
46
+ this.channels = DEFAULT_CHANNELS.map(c => ({ ...c }));
47
+ this.categories = DEFAULT_CATEGORIES.map(c => ({ ...c }));
48
+ this._emit('updated', { channels: this.channels, categories: this.categories });
49
+ if (this.isOwner()) this._publish().catch(() => {});
50
+ }
51
+
52
+ async _publish() {
53
+ if (!this.isOwner()) return;
54
+ const signed = await this.auth.sign({
55
+ kind: 30078, created_at: Math.floor(Date.now() / 1000),
56
+ tags: [['d', 'zellous-channels:' + this.serverId]],
57
+ content: JSON.stringify({ channels: this.channels, categories: this.categories })
58
+ });
59
+ this.pool.publish(signed);
60
+ }
61
+
62
+ async create(name, type = 'text', categoryId = 'general') {
63
+ this.channels = [...this.channels, { id: 'ch-' + Date.now(), name, type, categoryId, position: this.channels.length }];
64
+ await this._publish();
65
+ this._emit('updated', { channels: this.channels, categories: this.categories });
66
+ }
67
+
68
+ async rename(id, name) {
69
+ this.channels = this.channels.map(c => c.id === id ? { ...c, name } : c);
70
+ await this._publish(); this._emit('updated', { channels: this.channels, categories: this.categories });
71
+ }
72
+
73
+ async remove(id) {
74
+ this.channels = this.channels.filter(c => c.id !== id);
75
+ await this._publish(); this._emit('updated', { channels: this.channels, categories: this.categories });
76
+ }
77
+
78
+ async createCategory(name) {
79
+ this.categories = [...this.categories, { id: 'cat-' + Date.now(), name, position: this.categories.length }];
80
+ await this._publish(); this._emit('updated', { channels: this.channels, categories: this.categories });
81
+ }
82
+
83
+ async renameCategory(id, name) {
84
+ this.categories = this.categories.map(c => c.id === id ? { ...c, name } : c);
85
+ await this._publish(); this._emit('updated', { channels: this.channels, categories: this.categories });
86
+ }
87
+
88
+ async deleteCategory(id) {
89
+ this.categories = this.categories.filter(c => c.id !== id);
90
+ this.channels = this.channels.map(c => c.categoryId === id ? { ...c, categoryId: null } : c);
91
+ await this._publish(); this._emit('updated', { channels: this.channels, categories: this.categories });
92
+ }
93
+
94
+ async reorder(catId, ids) {
95
+ ids.forEach((chId, idx) => { this.channels = this.channels.map(c => c.id === chId ? { ...c, position: idx, categoryId: catId } : c); });
96
+ await this._publish(); this._emit('updated', { channels: this.channels, categories: this.categories });
97
+ }
98
+
99
+ async reorderCategories(ids) {
100
+ ids.forEach((catId, idx) => { this.categories = this.categories.map(c => c.id === catId ? { ...c, position: idx } : c); });
101
+ await this._publish(); this._emit('updated', { channels: this.channels, categories: this.categories });
102
+ }
103
+
104
+ _emit(t, d) { this.dispatchEvent(new CustomEvent(t, { detail: d })); }
105
+ }
106
+
107
+ export const createChannels = (opts) => new Channels(opts);
package/src/chat.js ADDED
@@ -0,0 +1,95 @@
1
+ const hexChannelId = async (channelId, serverId) => {
2
+ const h = await crypto.subtle.digest('SHA-256', new TextEncoder().encode((serverId || 'default') + ':' + channelId));
3
+ return Array.from(new Uint8Array(h)).map(b => b.toString(16).padStart(2, '0')).join('');
4
+ };
5
+
6
+ export class Chat extends EventTarget {
7
+ constructor({ relayPool, auth, getChannelContext = () => ({ channelId: null, serverId: '' }), isAdmin = () => false }) {
8
+ super();
9
+ if (!relayPool || !auth) throw new Error('Chat: relayPool + auth required');
10
+ this.pool = relayPool; this.auth = auth;
11
+ this.getChannelContext = getChannelContext; this.isAdmin = isAdmin;
12
+ this.activeChannelId = null;
13
+ this.messages = [];
14
+ this.profiles = new Map(); this.fetching = new Set();
15
+ }
16
+
17
+ async send(content, { announcement = false } = {}) {
18
+ const { channelId, serverId } = this.getChannelContext();
19
+ if (!this.auth.isLoggedIn() || !channelId) return;
20
+ if (announcement && !this.isAdmin(serverId)) return;
21
+ const trimmed = content.trim(); if (!trimmed) return;
22
+ const chanHex = await hexChannelId(channelId, serverId);
23
+ const tags = [['e', chanHex, '', 'root']];
24
+ if (announcement) tags.push(['t', 'announcement']);
25
+ const signed = await this.auth.sign({ kind: 42, created_at: Math.floor(Date.now() / 1000), tags, content: trimmed });
26
+ this.pool.publish(signed);
27
+ this._addMessage(this._eventToMsg(signed));
28
+ }
29
+
30
+ async loadHistory(channelId) {
31
+ const { serverId } = this.getChannelContext();
32
+ if (this.activeChannelId) {
33
+ this.pool.unsubscribe('chat-' + this.activeChannelId);
34
+ this.pool.unsubscribe('chat-live-' + this.activeChannelId);
35
+ }
36
+ this.activeChannelId = channelId;
37
+ this.messages = [];
38
+ this._emit('messages', { list: [] });
39
+ const chanHex = await hexChannelId(channelId, serverId);
40
+ const collected = [];
41
+ this.pool.subscribe('chat-' + channelId,
42
+ [{ kinds: [42], '#e': [chanHex], limit: 50 }],
43
+ (ev) => collected.push(this._eventToMsg(ev)),
44
+ () => {
45
+ collected.sort((a, b) => a.timestamp - b.timestamp);
46
+ this.messages = collected;
47
+ this._emit('messages', { list: collected });
48
+ });
49
+ this.pool.subscribe('chat-live-' + channelId,
50
+ [{ kinds: [42], '#e': [chanHex], since: Math.floor(Date.now() / 1000) }],
51
+ (ev) => this._addMessage(this._eventToMsg(ev)));
52
+ }
53
+
54
+ async deleteMessage(id) {
55
+ const signed = await this.auth.sign({ kind: 5, created_at: Math.floor(Date.now() / 1000), tags: [['e', id]], content: 'deleted' });
56
+ this.pool.publish(signed);
57
+ this.messages = this.messages.filter(m => m.id !== id);
58
+ this._emit('messages', { list: this.messages });
59
+ }
60
+
61
+ _eventToMsg(event) {
62
+ const tTags = (event.tags || []).filter(t => t[0] === 't').map(t => t[1]);
63
+ this._fetchProfile(event.pubkey);
64
+ return { id: event.id, type: 'text', userId: event.pubkey, content: event.content, timestamp: event.created_at * 1000, tags: tTags };
65
+ }
66
+
67
+ _addMessage(msg) {
68
+ if (this.messages.find(m => m.id === msg.id)) return;
69
+ this.messages = [...this.messages, msg];
70
+ this._emit('message', { message: msg });
71
+ this._emit('messages', { list: this.messages });
72
+ }
73
+
74
+ resolveProfile(pubkey) {
75
+ const p = this.profiles.get(pubkey);
76
+ if (p) return p.name || this.auth.npubShort(pubkey);
77
+ this._fetchProfile(pubkey);
78
+ return this.auth.npubShort(pubkey);
79
+ }
80
+
81
+ _fetchProfile(pubkey) {
82
+ if (this.fetching.has(pubkey)) return;
83
+ this.fetching.add(pubkey);
84
+ this.pool.subscribe('profile-' + pubkey,
85
+ [{ kinds: [0], authors: [pubkey], limit: 1 }],
86
+ (event) => { try { this.profiles.set(pubkey, JSON.parse(event.content)); this._emit('profile', { pubkey, profile: this.profiles.get(pubkey) }); } catch {} },
87
+ () => { this.pool.unsubscribe('profile-' + pubkey); this.fetching.delete(pubkey); });
88
+ }
89
+
90
+ updateProfile(pubkey, profile) { this.profiles.set(pubkey, profile); this._emit('profile', { pubkey, profile }); }
91
+
92
+ _emit(t, d) { this.dispatchEvent(new CustomEvent(t, { detail: d })); }
93
+ }
94
+
95
+ export const createChat = (opts) => new Chat(opts);
package/src/fsm.js ADDED
@@ -0,0 +1,51 @@
1
+ export const createFSM = (xstate) => {
2
+ if (!xstate?.createMachine) throw new Error('xstate required');
3
+ return {
4
+ voiceMachine: xstate.createMachine({
5
+ initial: 'idle',
6
+ states: {
7
+ idle: { on: { connect: 'connecting' } },
8
+ connecting: { on: { connected: 'connected', fail: 'idle' } },
9
+ connected: { on: { disconnect: 'disconnecting' } },
10
+ disconnecting: { on: { done: 'idle' } }
11
+ }
12
+ }),
13
+ peerMachine: xstate.createMachine({
14
+ initial: 'new',
15
+ states: {
16
+ new: { on: { offer: 'offering', recv_offer: 'answering' } },
17
+ offering: { on: { recv_answer: 'connected', fail: 'new', restart: 'offering' } },
18
+ answering: { on: { sent_answer: 'connected', recv_answer: 'connected', fail: 'new' } },
19
+ connected: { on: { disconnect: 'reconnecting', close: 'closed' } },
20
+ reconnecting: { on: { offer: 'offering', recv_answer: 'connected', close: 'closed' } },
21
+ closed: {}
22
+ }
23
+ }),
24
+ cameraMachine: xstate.createMachine({
25
+ initial: 'idle',
26
+ states: {
27
+ idle: { on: { enable: 'requesting' } },
28
+ requesting: { on: { enabled: 'active', denied: 'idle' } },
29
+ active: { on: { disable: 'idle', error: 'idle' } },
30
+ error: { on: { enable: 'requesting' } }
31
+ }
32
+ }),
33
+ relayMachine: xstate.createMachine({
34
+ initial: 'connecting',
35
+ states: {
36
+ connecting: { on: { connected: 'connected', fail: 'error' } },
37
+ connected: { on: { fail: 'error', disconnect: 'disconnected' } },
38
+ disconnected: { on: { reconnect: 'connecting' } },
39
+ error: { on: { reconnect: 'connecting' } }
40
+ }
41
+ }),
42
+ sfuMachine: xstate.createMachine({
43
+ initial: 'mesh',
44
+ states: {
45
+ mesh: { on: { elect: 'electing' } },
46
+ electing: { on: { elected: 'star', dissolve: 'mesh' } },
47
+ star: { on: { dissolve: 'mesh' } }
48
+ }
49
+ })
50
+ };
51
+ };
package/src/index.js CHANGED
@@ -1,3 +1,15 @@
1
1
  export { RelayPool, createRelayPool } from './relay-pool.js';
2
2
  export { NostrAuth, createAuth } from './auth.js';
3
+ export { createFSM } from './fsm.js';
4
+ export { VoiceSession, createVoiceSession } from './voice.js';
5
+ export { Chat, createChat } from './chat.js';
6
+ export { Channels, createChannels } from './channels.js';
7
+ export { Servers, createServers } from './servers.js';
8
+ export { MessageBus, createMessageBus } from './message.js';
9
+ export { Bans, createBans } from './bans.js';
10
+ export { Roles, createRoles } from './roles.js';
11
+ export { Settings, createSettings } from './settings.js';
12
+ export { Media, createMedia } from './media.js';
13
+ export { Pages, createPages } from './pages.js';
14
+ export { createWireweave } from './wireweave.js';
3
15
  export * as debug from './debug.js';
package/src/media.js ADDED
@@ -0,0 +1,81 @@
1
+ const BLOSSOM_SERVERS = [
2
+ 'https://blossom.nostr.build',
3
+ 'https://files.sovbit.host',
4
+ 'https://nostrcheck.me'
5
+ ];
6
+
7
+ const hashFile = async (file) => {
8
+ const buf = await file.arrayBuffer();
9
+ const h = await crypto.subtle.digest('SHA-256', buf);
10
+ return Array.from(new Uint8Array(h)).map(b => b.toString(16).padStart(2, '0')).join('');
11
+ };
12
+
13
+ const hexChannelId = async (channelId, serverId) => {
14
+ const h = await crypto.subtle.digest('SHA-256', new TextEncoder().encode((serverId || 'default') + ':' + channelId));
15
+ return Array.from(new Uint8Array(h)).map(b => b.toString(16).padStart(2, '0')).join('');
16
+ };
17
+
18
+ export class Media {
19
+ constructor({ relayPool, auth, servers = BLOSSOM_SERVERS }) {
20
+ if (!relayPool || !auth) throw new Error('Media: deps required');
21
+ this.pool = relayPool; this.auth = auth; this.servers = servers;
22
+ }
23
+
24
+ async _genAuth(file) {
25
+ if (!this.auth.pubkey) throw new Error('Not authenticated');
26
+ const hash = await hashFile(file);
27
+ const signed = await this.auth.sign({
28
+ kind: 24242, created_at: Math.floor(Date.now() / 1000),
29
+ tags: [['t', 'upload'], ['x', hash], ['expiration', String(Math.floor(Date.now() / 1000) + 600)]],
30
+ content: 'Upload ' + file.name
31
+ });
32
+ return { header: 'Nostr ' + btoa(JSON.stringify(signed)), hash };
33
+ }
34
+
35
+ async _uploadBlossom(file, serverUrl) {
36
+ const { header } = await this._genAuth(file);
37
+ const res = await fetch(serverUrl + '/upload', {
38
+ method: 'PUT', body: file,
39
+ headers: { Authorization: header, 'Content-Type': file.type || 'application/octet-stream' }
40
+ });
41
+ if (!res.ok) throw new Error('status ' + res.status);
42
+ const data = await res.json();
43
+ const url = data.url || (data.content && JSON.parse(data.content).url);
44
+ if (!url) throw new Error('no url in response');
45
+ return url;
46
+ }
47
+
48
+ async upload(file) {
49
+ const errors = [];
50
+ for (const srv of this.servers) {
51
+ try { return { url: await this._uploadBlossom(file, srv), type: file.type, name: file.name, size: file.size }; }
52
+ catch (e) { errors.push(srv + ': ' + e.message); }
53
+ }
54
+ throw new Error('upload failed: ' + errors.join('; '));
55
+ }
56
+
57
+ isMedia(url) {
58
+ if (typeof url !== 'string') return null;
59
+ if (/\.(png|jpe?g|gif|webp|svg|avif)(\?|$)/i.test(url)) return 'image';
60
+ if (/\.(mp4|webm|mov|ogg)(\?|$)/i.test(url)) return 'video';
61
+ return null;
62
+ }
63
+
64
+ extractUrls(text) { return text ? (text.match(/https?:\/\/[^\s<>"]+/g) || []) : []; }
65
+
66
+ async sendMedia(file, { channelId, serverId }) {
67
+ if (file.size > 20 * 1024 * 1024) throw new Error('file too large (max 20MB)');
68
+ const result = await this.upload(file);
69
+ const chanHex = await hexChannelId(channelId, serverId);
70
+ const imetaTag = ['imeta', 'url ' + result.url, 'm ' + result.type];
71
+ if (result.size) imetaTag.push('size ' + result.size);
72
+ const signed = await this.auth.sign({
73
+ kind: 42, created_at: Math.floor(Date.now() / 1000),
74
+ tags: [['e', chanHex, '', 'root'], imetaTag], content: result.url
75
+ });
76
+ this.pool.publish(signed);
77
+ return { signed, result };
78
+ }
79
+ }
80
+
81
+ export const createMedia = (opts) => new Media(opts);
package/src/message.js ADDED
@@ -0,0 +1,22 @@
1
+ export class MessageBus extends EventTarget {
2
+ constructor({ maxMessages = 50 } = {}) {
3
+ super();
4
+ this.max = maxMessages;
5
+ this.messages = [];
6
+ this.handlers = {};
7
+ }
8
+
9
+ handle(m) { this.handlers[m.type]?.(m); }
10
+ register(type, fn) { this.handlers[type] = fn; }
11
+
12
+ add(text, { audioData = null, userId = null, username = null } = {}) {
13
+ const msg = { id: Date.now() + Math.random(), text, time: Date.now(), userId, username, audioData };
14
+ this.messages = [...this.messages, msg];
15
+ if (this.messages.length > this.max) this.messages = this.messages.slice(-this.max);
16
+ this.dispatchEvent(new CustomEvent('message', { detail: msg }));
17
+ this.dispatchEvent(new CustomEvent('messages', { detail: { list: this.messages } }));
18
+ return msg;
19
+ }
20
+ }
21
+
22
+ export const createMessageBus = (opts) => new MessageBus(opts);
package/src/pages.js ADDED
@@ -0,0 +1,77 @@
1
+ const sanitize = (html) => {
2
+ if (typeof document === 'undefined') return html;
3
+ const el = document.createElement('div');
4
+ el.innerHTML = html;
5
+ el.querySelectorAll('script,iframe,object,embed,form,input,button').forEach(n => n.remove());
6
+ el.querySelectorAll('*').forEach(n => {
7
+ [...n.attributes].forEach(a => {
8
+ if (/^on/i.test(a.name) || (a.name === 'href' && /^javascript:/i.test(a.value)) || (a.name === 'src' && /^javascript:/i.test(a.value))) n.removeAttribute(a.name);
9
+ });
10
+ });
11
+ return el.innerHTML;
12
+ };
13
+
14
+ export class Pages extends EventTarget {
15
+ constructor({ relayPool, auth, roles }) {
16
+ super();
17
+ if (!relayPool || !auth || !roles) throw new Error('Pages: deps required');
18
+ this.pool = relayPool; this.auth = auth; this.roles = roles;
19
+ this.store = new Map(); this.subs = new Map();
20
+ }
21
+
22
+ _key(serverId, slug) { return 'zellous-page:' + serverId + ':' + slug; }
23
+ getPages(serverId) { return Array.from((this.store.get(serverId) || new Map()).values()); }
24
+
25
+ subscribe(serverId) {
26
+ if (this.subs.has(serverId)) return;
27
+ const creator = serverId?.split(':')[0];
28
+ if (!creator) return;
29
+ const subId = 'pages-' + serverId;
30
+ this.subs.set(serverId, subId);
31
+ this.pool.subscribe(subId,
32
+ [{ kinds: [30078], authors: [creator] }],
33
+ (event) => {
34
+ if (event.pubkey !== creator) return;
35
+ const dTag = (event.tags.find(t => t[0] === 'd') || [])[1] || '';
36
+ const prefix = 'zellous-page:' + serverId + ':';
37
+ if (!dTag.startsWith(prefix)) return;
38
+ const slug = dTag.slice(prefix.length); if (!slug) return;
39
+ try {
40
+ const data = JSON.parse(event.content);
41
+ const pages = this.store.get(serverId) || new Map();
42
+ if (data.deleted) pages.delete(slug);
43
+ else pages.set(slug, { slug, title: data.title || slug, html: sanitize(data.html || ''), updatedAt: event.created_at });
44
+ this.store.set(serverId, pages);
45
+ this.dispatchEvent(new CustomEvent('updated', { detail: { serverId, pages: this.getPages(serverId) } }));
46
+ } catch {}
47
+ });
48
+ }
49
+
50
+ unsubscribe(serverId) {
51
+ const subId = this.subs.get(serverId);
52
+ if (subId) { this.pool.unsubscribe(subId); this.subs.delete(serverId); }
53
+ }
54
+
55
+ async publish(serverId, slug, title, html) {
56
+ if (!this.roles.isAdmin(serverId)) throw new Error('Admin only');
57
+ const safe = sanitize(html);
58
+ const signed = await this.auth.sign({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', this._key(serverId, slug)]], content: JSON.stringify({ title, html: safe }) });
59
+ this.pool.publish(signed);
60
+ const pages = this.store.get(serverId) || new Map();
61
+ pages.set(slug, { slug, title, html: safe, updatedAt: Math.floor(Date.now() / 1000) });
62
+ this.store.set(serverId, pages);
63
+ this.dispatchEvent(new CustomEvent('updated', { detail: { serverId, pages: this.getPages(serverId) } }));
64
+ }
65
+
66
+ async deletePage(serverId, slug) {
67
+ if (!this.roles.isAdmin(serverId)) throw new Error('Admin only');
68
+ const signed = await this.auth.sign({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', this._key(serverId, slug)]], content: JSON.stringify({ deleted: true }) });
69
+ this.pool.publish(signed);
70
+ const pages = this.store.get(serverId) || new Map();
71
+ pages.delete(slug);
72
+ this.store.set(serverId, pages);
73
+ this.dispatchEvent(new CustomEvent('updated', { detail: { serverId, pages: this.getPages(serverId) } }));
74
+ }
75
+ }
76
+
77
+ export const createPages = (opts) => new Pages(opts);