// 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('the Live chip sits after Videos, a red dot on it while a followed host streams', await waitFor(() => { const modes = [...document.querySelectorAll('.media-mode')].map((b) => b.dataset.mode); return modes.indexOf('live') === modes.indexOf('videos') + 1 && !!document.querySelector('.media-mode[data-mode="live"] .live-dot'); }, 15000)); check('...and no streams above the posts', !(await page.evaluate(() => document.querySelector('.live-grid, .live-strip')))); await page.evaluate(() => document.querySelector('.media-mode[data-mode="live"]').click()); check('the Live chip shows the stream', await waitFor(() => !!document.querySelector('.live-grid .live-tile'), 15000)); const tile = await page.evaluate(() => [...document.querySelectorAll('.live-grid .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')))); await page.evaluate(() => document.querySelector('.media-mode[data-mode="all"]').click()); 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('.media-modes'), 5000); await page.evaluate(() => document.querySelector('.media-mode[data-mode="live"]').click()); await waitFor(() => !!document.querySelector('.live-grid .live-tile'), 5000); console.log('\n[the stream\'s page]'); await page.evaluate(() => document.querySelector('.live-grid .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('.media-mode[data-mode="all"]').click()); await waitFor(() => !!document.querySelector('.notes-feed .live-card'), 5000); 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);