1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
|
// Live streams (NIP-53): a feed shows its people's streams in a row above
// the posts (a stream gone quiet for an hour is over), a tap opens the
// stream's page — the player playing the HLS stream, the chat and zaps as
// they come, a chat line sent as a kind 1311 — and the address bar names it
// (/naddr1…). A post linking the stream shows it as a card, the host's
// profile says they're on, Back stops the player, the browser without HLS
// of its own plays it through hls.js, and a zap carries the stream's `a`.
// Run: bun tools/live-stream-test.js
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { naddrEncode } from 'nostr-tools/nip19';
import { buildZapRequest } from '../src/lnurl.js';
import { setZapAddr } from '../src/live.js';
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
const PORT = 5302;
const now = Math.floor(Date.now() / 1000);
const key = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; };
const HOST = key(), SERVICE = key(), FAN = key(), STALE = key();
// a stream: 30 s of HLS, its playlist without an end (as a live one is)
const dir = mkdtempSync(tmpdir() + '/live-stream-');
Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=25', '-f', 'lavfi', '-i', 'sine=frequency=440',
'-t', '30', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '50', '-c:a', 'aac', '-f', 'hls', '-hls_time', '2', '-hls_list_size', '0', dir + '/index.m3u8']);
writeFileSync(dir + '/index.m3u8', readFileSync(dir + '/index.m3u8', 'utf8').replace('#EXT-X-ENDLIST\n', ''));
const STREAM = `http://localhost:${PORT}/live/index.m3u8`;
// zap.stream's shape: the service signs, the streamer is the Host
const live = finalizeEvent({ kind: 30311, created_at: now - 30, content: '', tags: [
['d', 'stream-1'], ['title', 'Building coinos live'], ['summary', 'Hacking on the wallet'], ['status', 'live'],
['streaming', STREAM], ['current_participants', '12'], ['starts', String(now - 600)],
['p', HOST.pk, '', 'Host'], ['relays', 'wss://relay.example.com'],
] }, SERVICE.sk);
const A = `30311:${SERVICE.pk}:stream-1`;
const NADDR = naddrEncode({ kind: 30311, pubkey: SERVICE.pk, identifier: 'stream-1' });
// still says live, but nobody has refreshed it in two hours
const stale = finalizeEvent({ kind: 30311, created_at: now - 7200, content: '', tags: [
['d', 'old'], ['title', 'Forgotten stream'], ['status', 'live'], ['streaming', STREAM], ['p', HOST.pk, '', 'host'],
] }, STALE.sk);
const chat = finalizeEvent({ kind: 1311, created_at: now - 20, content: 'hello from the chat', tags: [['a', A, '', 'root']] }, FAN.sk);
const zapReq = finalizeEvent({ kind: 9734, created_at: now - 15, content: 'great stream', tags: [['p', HOST.pk], ['a', A], ['amount', '21000']] }, FAN.sk);
const zap = finalizeEvent({ kind: 9735, created_at: now - 15, content: '', tags: [['p', HOST.pk], ['a', A], ['bolt11', 'lnbc210n1invalid'], ['description', JSON.stringify(zapReq)], ['P', FAN.pk]] }, SERVICE.sk);
const post = finalizeEvent({ kind: 1, created_at: now - 100, content: 'come watch nostr:' + NADDR, tags: [] }, HOST.sk);
const FIXTURES = [live, stale, chat, zap];
const ended = finalizeEvent({ ...live, id: undefined, sig: undefined, created_at: now - 5, tags: live.tags.map((x) => (x[0] === 'status' ? ['status', 'ended'] : x)) }, SERVICE.sk);
// the site as built (the split build: hls.js arrives as its own chunk)
if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed');
const server = Bun.serve({ port: PORT, fetch: async (req) => {
const path = new URL(req.url).pathname;
if (path.startsWith('/live/')) return new Response(Bun.file(dir + path.slice(5)), { headers: { 'content-type': path.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t', 'access-control-allow-origin': '*' } });
const f = Bun.file('dist' + path);
return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html'));
} });
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
// the relays: they hold the fixtures, answer by the filter, and keep what's published
const relayStub = (fx) => {
window.__published = []; window.__reqs = [];
const subs = [];
// a new event, to every open subscription it matches (a relay's live feed)
window.__push = (e) => { for (const [ws, id, fs] of subs) if (ws.readyState === 1 && fs.some((f) => match(f, e))) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, e]) })); };
const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey))
&& (!f.since || e.created_at >= f.since)
&& Object.keys(f).filter((k) => k[0] === '#').every((k) => e.tags.some((x) => x[0] === k.slice(1) && f[k].includes(x[1])));
const Real = window.WebSocket;
window.WebSocket = function (...a) {
const ws = new Real(...a);
const send = ws.send.bind(ws);
ws.send = (data) => {
try {
const m = JSON.parse(data);
if (m[0] === 'REQ' && m[2]) {
window.__reqs.push([a[0], ...m.slice(2)]);
subs.push([ws, m[1], m.slice(2).map((f) => ({ ...f, since: undefined, limit: undefined }))]);
const hits = [...fx, ...window.__published].filter((e) => m.slice(2).some((f) => match(f, e)));
if (hits.length) setTimeout(() => { for (const e of hits) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], e]) })); }, 0);
}
if (m[0] === 'EVENT' && m[1]) window.__published.push(m[1]);
} catch {}
return send(data);
};
return ws;
};
window.WebSocket.prototype = Real.prototype;
Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
};
const newPage = async (noHls = false) => {
const page = await browser.newPage();
await page.setViewport({ width: 390, height: 844 });
await page.evaluateOnNewDocument(relayStub, FIXTURES);
// Firefox: no HLS of its own
if (noHls) await page.evaluateOnNewDocument(() => {
const real = HTMLMediaElement.prototype.canPlayType;
HTMLMediaElement.prototype.canPlayType = function (t) { return /mpegurl/i.test(t) ? '' : real.call(this, t); };
});
const errs = [];
page.on('pageerror', (e) => errs.push(String(e)));
page.errs = errs;
return page;
};
const helpers = (page) => ({
click: (x) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, x),
waitText: async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x.toLowerCase())) return true; await sleep(250); } return false; },
waitFor: async (fn, ms = 10000, arg) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(200); } return false; },
});
const playing = () => { const v = document.querySelector('video.live-video'); return !!v && !v.paused && v.currentTime > 1 && v.videoWidth > 0; };
try {
console.log('[a zap on a stream names it]');
setZapAddr(live.id, A);
const req = await buildZapRequest({ amountMsat: 21000, relays: ['wss://r'], recipientPk: HOST.pk, eventId: live.id, signFn: (e) => e });
check('the zap request carries the stream\'s a tag beside its e', req.tags.some((x) => x[0] === 'a' && x[1] === A) && req.tags.some((x) => x[0] === 'e' && x[1] === live.id));
const page = await newPage();
const { click, waitText, waitFor } = helpers(page);
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
await page.reload({ waitUntil: 'domcontentloaded' });
await sleep(400);
await click('create a new wallet'); await sleep(600);
await page.waitForSelector('.words .w .t');
const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
await click('skip verification');
await waitText('receive');
const base = cacheKeyFor(mn + '\n');
await page.evaluate(([k, pk, ns]) => {
localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Stream Host', t: Date.now() } }));
localStorage.setItem('btc-wallet-feed:' + k, 'following');
}, [base, HOST.pk, [post]]);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive');
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
console.log('\n[the feed]');
check('a followed host\'s stream shows above the posts', await waitFor(() => !!document.querySelector('.live-strip .live-tile'), 15000));
const tile = await page.evaluate(() => [...document.querySelectorAll('.live-strip .live-tile')].map((t) => t.innerText.replace(/\s+/g, ' ')));
check('...titled, marked LIVE, with how many watch and who', tile.length && /LIVE/.test(tile[0]) && /12 watching/.test(tile[0]) && /Building coinos live/.test(tile[0]), JSON.stringify(tile));
check('...and a stream unrefreshed for two hours is not there', tile.length === 1 && !(await page.evaluate(() => document.body.innerText.includes('Forgotten stream'))));
check('a post linking the stream shows it as a card', await waitFor(() => !!document.querySelector('.notes-feed .live-card'), 15000));
check('the host\'s face on their post wears the live ring', await waitFor((pk) => !!document.querySelector('.notes-feed .note-post .note-avatar.live-ring'), 5000));
await page.evaluate(() => document.querySelector('.notes-feed .note-post .note-avatar.live-ring').click());
check('...and a tap on it opens the stream', await waitFor(() => !!document.querySelector('.live-page'), 5000));
await page.goBack();
await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.live-strip'), 5000);
console.log('\n[the stream\'s page]');
await page.evaluate(() => document.querySelector('.live-strip .live-tile').click());
check('a tap opens it, the address bar naming the stream', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor((n) => location.pathname === '/' + n, 5000, NADDR), await page.evaluate(() => location.pathname));
check('the stream plays', await waitFor(playing, 20000), JSON.stringify(await page.evaluate(() => { const v = document.querySelector('video.live-video'); return v && { t: v.currentTime, paused: v.paused, err: v.error && v.error.code, src: v.currentSrc.slice(0, 60) }; })));
check('...from its playlist, natively in Chrome', await page.evaluate((s) => document.querySelector('video.live-video').currentSrc === s, STREAM));
check('the chat shows what was said', await waitText('hello from the chat', 8000));
check('...and the zap, its amount and words', await waitText('zapped 21 sats', 5000) && await waitText('great stream', 1000));
await page.focus('.live-input');
await page.keyboard.type('first time here');
await page.keyboard.press('Enter');
check('a line typed in the chat shows at once', await waitText('first time here', 3000));
const sent = await page.evaluate(() => window.__published.find((e) => e.kind === 1311));
check('...and goes out as a kind 1311 addressed to the stream', !!sent && sent.content === 'first time here' && sent.tags.some((x) => x[0] === 'a' && x[1] === A), JSON.stringify(sent && sent.tags));
check('the player keeps playing through the renders', await waitFor(playing, 3000));
console.log('\n[leaving]');
await page.goBack();
check('Back leaves the stream\'s page', await waitFor(() => !document.querySelector('.live-page'), 5000));
check('...and nothing plays on behind it', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused || !v.isConnected)));
await page.evaluate(() => document.querySelector('.notes-feed .live-card').click());
check('the card in the post opens the stream too', await waitFor(() => !!document.querySelector('.live-page'), 5000) && await waitFor(playing, 20000));
await page.evaluate(() => { const a = [...document.querySelectorAll('.live-page a.quote-name')].find((x) => /Stream Host/.test(x.textContent)); a.click(); });
check('the host\'s profile says they\'re on', await waitFor(() => !!document.querySelector('.live-banner'), 10000) && await waitText('Building coinos live', 2000));
check('...the player stopped when the page went', await page.evaluate(() => [...document.querySelectorAll('video')].every((v) => v.paused || !v.isConnected)));
check('no page errors', !page.errs.length, page.errs.join(' | '));
await page.close();
console.log('\n[a browser without HLS of its own, opening a link]');
const ff = await newPage(true);
const f = helpers(ff);
await ff.goto(`http://localhost:${PORT}/${NADDR}`, { waitUntil: 'domcontentloaded' });
check('a /naddr1… link opens the stream, no wallet needed', await f.waitFor(() => !!document.querySelector('.live-page'), 15000) && await f.waitText('Building coinos live', 3000));
check('hls.js plays it', await f.waitFor(playing, 25000), JSON.stringify(await ff.evaluate(() => { const v = document.querySelector('video.live-video'); return v && { t: v.currentTime, src: v.currentSrc.slice(0, 30), err: v.error && v.error.code }; })));
check('...through Media Source', await ff.evaluate(() => document.querySelector('video.live-video').currentSrc.startsWith('blob:')));
check('a visitor reads the chat', await f.waitText('hello from the chat', 8000));
await ff.evaluate((e) => window.__push(e), ended);
check('the stream ending while watched says so, the player gone', await f.waitText('This stream has ended', 5000) && await ff.evaluate(() => !document.querySelector('video.live-video')));
check('no page errors', !ff.errs.length, ff.errs.join(' | '));
} catch (e) { console.error(e); ok = false; } finally {
await browser.close(); server.stop(true);
rmSync(dir, { recursive: true, force: true });
}
console.log(ok ? '\n✅ live streams: found, watched, chatted in' : '\n❌ failed');
process.exit(ok ? 0 : 1);
|