aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--feed/note-page.js4
-rw-r--r--feed/profile-page.js214
-rw-r--r--feed/server.js19
-rw-r--r--src/features/messages.js37
-rw-r--r--tools/profile-page-test.js126
5 files changed, 391 insertions, 9 deletions
diff --git a/feed/note-page.js b/feed/note-page.js
index f4dc08e..3145f14 100644
--- a/feed/note-page.js
+++ b/feed/note-page.js
@@ -117,9 +117,9 @@ export function createThreadCache({ build, now = Date.now }) {
return { get, refresh };
}
-const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+export const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// JSON inside a script tag: nothing in it may close the tag or open a comment
-const scriptJson = (v) => JSON.stringify(v).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
+export const scriptJson = (v) => JSON.stringify(v).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
function nameOf(data, pk) {
const p = (data.profiles || []).find((e) => e.pubkey === pk);
diff --git a/feed/profile-page.js b/feed/profile-page.js
new file mode 100644
index 0000000..a30c2ab
--- /dev/null
+++ b/feed/profile-page.js
@@ -0,0 +1,214 @@
+// /npub1…, /nprofile1… and /<coinos name> rendered with the profile already
+// in the page — the same trick note-page.js plays for shared posts.
+//
+// A profile link opened in a fresh browser showed a punk, "–" for the follow
+// counts, and no posts for nine seconds: the page looked up the author's
+// relay list, waited on every relay, then held the first screen for its
+// media. This server does that once, keeps the answer, and inlines it —
+// kind 0, latest posts, follow counts, and the faces of anyone the posts
+// mention — so the page paints whole on its first frame. Stale answers are
+// served while a refresh runs; a cold one gets BUDGET_MS before the plain
+// page goes out (and the build carries on into the cache).
+import { decode } from 'nostr-tools/nip19';
+import { verifyEvent } from 'nostr-tools/pure';
+import { NOTE_RELAYS, esc, scriptJson } from './note-page.js';
+
+export const PROFILE_PATH = /^\/((?:npub1|nprofile1)[a-z0-9]+|[A-Za-z0-9._-]{1,30})\/?$/;
+// single-segment app routes and files that are never a person
+const RESERVED = new Set(['chat', 'pos', 'feed', 'flash', 'index.html', 'sw.js', 'manifest.webmanifest', 'favicon.ico', 'robots.txt']);
+const INDEX_RELAYS = ['wss://purplepag.es', 'wss://user.kindpag.es'];
+// relays that keep history (the big ones prune within days)
+const ARCHIVE_RELAYS = ['wss://relay.ditto.pub', 'wss://nostr21.com', 'wss://offchain.pub'];
+const PROFILE_KINDS = [1, 1068, 30023];
+const MAX_NOTES = 30;
+const MAX_FACES = 30;
+const MAX_EVENT = 32_000;
+export const PROFILE_BUDGET_MS = 2000;
+const IDLE_MS = 400;
+
+const okEvent = (e) => e && typeof e.id === 'string' && JSON.stringify(e).length <= MAX_EVENT;
+const newest = (evs, kind, pk) => (evs || []).filter((e) => e.kind === kind && e.pubkey === pk && okEvent(e))
+ .sort((a, b) => b.created_at - a.created_at)[0] || null;
+
+// What the path names: a key outright, or a coinos name for the registrar.
+export function parseProfileRef(path) {
+ const m = PROFILE_PATH.exec(path);
+ if (!m || RESERVED.has(m[1].toLowerCase()) || /^(note|nevent|naddr)1/i.test(m[1])) return null;
+ const seg = m[1];
+ if (/^(npub1|nprofile1)/i.test(seg)) {
+ try {
+ const d = decode(seg.toLowerCase());
+ if (d.type === 'npub') return { pk: d.data, relays: [], path: seg };
+ if (d.type === 'nprofile') {
+ const relays = (d.data.relays || []).filter((u) => /^wss:\/\/[^\s]{1,200}$/.test(u)).slice(0, 3);
+ return { pk: d.data.pubkey, relays, path: seg };
+ }
+ } catch {}
+ return null;
+ }
+ // a dotted segment is a file the server didn't have, not a name
+ if (seg.includes('.')) return null;
+ return { name: seg.toLowerCase(), relays: [], path: seg };
+}
+
+// Everything the profile page shows first.
+export async function buildProfile(ref, { query: ask, primal = async () => null }) {
+ const pk = ref.pk;
+ // answers settle once they stop coming (see server.js's query)
+ const query = (relays, filter, wait) => ask(relays, filter, wait, { idle: IDLE_MS });
+ const home = [...new Set([...ref.relays, ...NOTE_RELAYS])];
+ const notesFrom = (relays, wait) => query(relays, { kinds: PROFILE_KINDS, authors: [pk], limit: MAX_NOTES }, wait).catch(() => []);
+ // their own write relays join in as soon as their list turns up
+ const theirs = query([...home, ...INDEX_RELAYS], { kinds: [10002], authors: [pk] }, 1500).catch(() => []).then((evs) => {
+ const list = newest(evs, 10002, pk);
+ const own = list ? [...new Set(list.tags.filter((t) => t[0] === 'r' && /^wss:\/\//.test(t[1] || '') && t[2] !== 'read')
+ .map((t) => t[1].replace(/\/$/, '')))].filter((u) => !home.includes(u)).slice(0, 4) : [];
+ return own.length ? notesFrom(own, 2500) : [];
+ });
+ // faces the posts mention, so names read right on the first paint — asked
+ // as soon as the first batch of posts lands, beside the author's own relays
+ const mentionsOf = (evs) => {
+ const out = new Set();
+ for (const e of evs) {
+ if (e.pubkey !== pk) continue;
+ for (const t of e.tags || []) if (t[0] === 'p' && /^[0-9a-f]{64}$/.test(t[1] || '')) out.add(t[1]);
+ for (const m of String(e.content || '').matchAll(/nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)/g)) {
+ try { const d = decode(m[1]); out.add(d.type === 'npub' ? d.data : d.data.pubkey); } catch {}
+ }
+ }
+ out.delete(pk);
+ return [...out].slice(0, MAX_FACES);
+ };
+ const first = notesFrom([...home, ...ARCHIVE_RELAYS], 2500);
+ const facesP = first.then((evs) => {
+ const want = mentionsOf(evs);
+ return want.length
+ ? query([...NOTE_RELAYS, ...INDEX_RELAYS], { kinds: [0], authors: want }, 1500).catch(() => [])
+ .then((faces) => want.map((x) => newest(faces, 0, x)).filter(Boolean))
+ : [];
+ });
+ const [kind0s, stats, a, b, profiles] = await Promise.all([
+ query([...home, ...INDEX_RELAYS], { kinds: [0], authors: [pk] }, 2000).catch(() => []),
+ primal(pk).catch(() => null),
+ first,
+ theirs,
+ facesP,
+ ]);
+ const profile = newest([...kind0s, ...(stats?.profile ? [stats.profile] : [])], 0, pk);
+ // an edited article is a new event at the same address: keep the newest
+ const byId = new Map();
+ const articles = new Map();
+ for (const e of [...a, ...b]) {
+ if (e.pubkey !== pk || !PROFILE_KINDS.includes(e.kind) || !okEvent(e)) continue;
+ if (e.kind === 30023) {
+ const d = (e.tags.find((t) => t[0] === 'd') || [])[1] || '';
+ const cur = articles.get(d);
+ if (cur && cur.created_at >= e.created_at) continue;
+ if (cur) byId.delete(cur.id);
+ articles.set(d, e);
+ }
+ byId.set(e.id, e);
+ }
+ const notes = [...byId.values()].sort((x, y) => y.created_at - x.created_at).slice(0, MAX_NOTES);
+ if (!profile && !notes.length) return null; // nobody there: the client looks for itself
+ const counts = stats && (stats.follows != null || stats.followers != null)
+ ? { follows: stats.follows, followers: stats.followers } : null;
+ return { v: 1, pk, profile, notes, counts, profiles };
+}
+
+// Primal's cache answers a profile and its follow counts in one request
+// (what the app's own social line asks). Its events are signature-checked.
+export function createPrimal({ url = 'wss://cache2.primal.net/v1', timeoutMs = 2000, WS = globalThis.WebSocket } = {}) {
+ let n = 0;
+ return (pk) => new Promise((resolve) => {
+ const sid = 'pp' + ++n;
+ const evs = [];
+ let ws;
+ const done = () => {
+ clearTimeout(timer);
+ try { ws.close(); } catch {}
+ const stats = (() => { try { return JSON.parse(evs.find((e) => e.kind === 10000105)?.content || 'null'); } catch { return null; } })();
+ const num = (v) => (Number.isFinite(v) && v >= 0 ? v : null);
+ const profile = evs.find((e) => e.kind === 0 && e.pubkey === pk && verifyEvent(e)) || null;
+ resolve(stats || profile ? { profile, follows: num(stats?.follows_count), followers: num(stats?.followers_count) } : null);
+ };
+ const timer = setTimeout(done, timeoutMs);
+ try {
+ ws = new WS(url);
+ ws.onopen = () => ws.send(JSON.stringify(['REQ', sid, { cache: ['user_profile', { pubkey: pk }] }]));
+ ws.onmessage = (m) => {
+ try {
+ const d = JSON.parse(m.data);
+ if (d[1] !== sid) return;
+ if (d[0] === 'EVENT' && d[2]) evs.push(d[2]);
+ if (d[0] === 'EOSE' || d[0] === 'CLOSED') done();
+ } catch {}
+ };
+ ws.onerror = done;
+ } catch { done(); }
+ });
+}
+
+// coinos names resolve through the registrar, remembered for a while
+export function createNameResolver({ url = 'https://names.coinos.io', domain = 'coinos.io', ttl = 600_000, now = Date.now } = {}) {
+ const known = new Map(); // name -> { pk, at }
+ return async (name) => {
+ const k = known.get(name);
+ if (k && now() - k.at < ttl) return k.pk;
+ let pk = null;
+ try {
+ const j = await fetch(`${url}/.well-known/nostr.json?name=${encodeURIComponent(name)}&domain=${domain}`,
+ { signal: AbortSignal.timeout(1000) }).then((r) => r.json());
+ const v = (j.names || {})[name];
+ pk = /^[0-9a-f]{64}$/.test(v || '') ? v : null;
+ } catch { return k ? k.pk : null; }
+ known.set(name, { pk, at: now() });
+ if (known.size > 2000) known.delete(known.keys().next().value);
+ return pk;
+ };
+}
+
+const fields = (ev) => { try { const m = JSON.parse(ev.content); return m && typeof m === 'object' ? m : {}; } catch { return {}; } };
+
+export function renderProfilePage(html, data, ref, origin) {
+ const m = data.profile ? fields(data.profile) : {};
+ const str = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
+ const name = str(m.display_name, 80) || str(m.name, 80) || ref.name || null;
+ const picture = /^https:\/\//.test(m.picture || '') ? m.picture : null;
+ const banner = /^https:\/\//.test(m.banner || '') ? m.banner : null;
+ const about = (str(m.about, 2000) || '').replace(/\s+/g, ' ');
+ const desc = about.length > 200 ? about.slice(0, 199) + '…' : about;
+ const title = (name || 'A profile') + ' on Coinos';
+ const head = [
+ `<meta property="og:type" content="profile">`,
+ `<meta property="og:site_name" content="Coinos">`,
+ `<meta property="og:title" content="${esc(title)}">`,
+ desc ? `<meta property="og:description" content="${esc(desc)}">` : '',
+ desc ? `<meta name="description" content="${esc(desc)}">` : '',
+ origin ? `<meta property="og:url" content="${esc(origin + '/' + ref.path)}">` : '',
+ picture ? `<meta property="og:image" content="${esc(picture)}">` : '',
+ `<meta name="twitter:card" content="summary">`,
+ // the face and the cover start downloading with the page
+ picture ? `<link rel="preload" as="image" href="${esc(picture)}">` : '',
+ banner ? `<link rel="preload" as="image" href="${esc(banner)}">` : '',
+ `<script type="application/json" id="boot-profile">${scriptJson({ ...data, path: ref.path })}</script>`,
+ ].filter(Boolean).join('\n');
+ let out = html.replace(/<title>[^<]*<\/title>/i, `<title>${esc(title)}</title>`);
+ out = out.includes('</head>') ? out.replace('</head>', head + '\n</head>') : head + out;
+ return out;
+}
+
+export async function profilePageResponse(req, { index, profiles, resolveName }) {
+ const u = new URL(req.url);
+ const ref = parseProfileRef(u.pathname);
+ const html = await index();
+ const headers = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache, must-revalidate' };
+ if (!ref) return new Response(html, { headers });
+ if (!ref.pk) ref.pk = await resolveName(ref.name);
+ if (!ref.pk) return new Response(html, { headers });
+ ref.id = ref.pk; // the cache's key
+ const data = await profiles.get(ref, PROFILE_BUDGET_MS).catch(() => null);
+ const proto = req.headers.get('x-forwarded-proto') || 'https';
+ const host = req.headers.get('x-forwarded-host') || req.headers.get('host');
+ return new Response(data ? renderProfilePage(html, data, ref, host ? proto + '://' + host : '') : html, { headers });
+}
diff --git a/feed/server.js b/feed/server.js
index 12ff850..9566447 100644
--- a/feed/server.js
+++ b/feed/server.js
@@ -2,26 +2,34 @@ import { SimplePool } from 'nostr-tools/pool';
import { startFeedCache } from './service.js';
import { snapshotResponse } from './cache.js';
import { NOTE_PATH, buildThread, createThreadCache, createIndexSource, notePageResponse } from './note-page.js';
+import { PROFILE_PATH, buildProfile, createPrimal, createNameResolver, profilePageResponse } from './profile-page.js';
const cache = await startFeedCache();
// SimplePool verifies signatures and matches filters before accepting events.
const pool = new SimplePool();
// Every relay's answer, or maxWait — but an ask by id is done the moment
-// every id has turned up, rather than waiting out the slowest relay.
-const query = (relays, filter, maxWait) => new Promise((resolve) => {
+// every id has turned up, rather than waiting out the slowest relay. With
+// `idle`, the answer is also done once events have been arriving and then
+// stop for that long: one relay that never says EOSE no longer holds a
+// profile page for the whole maxWait.
+const query = (relays, filter, maxWait, { idle = 0 } = {}) => new Promise((resolve) => {
const got = new Map();
- let done = false, sub = null;
- const finish = () => { if (done) return; done = true; try { sub && sub.close(); } catch {} resolve([...got.values()]); };
+ let done = false, sub = null, quiet = null;
+ const finish = () => { if (done) return; done = true; clearTimeout(quiet); try { sub && sub.close(); } catch {} resolve([...got.values()]); };
sub = pool.subscribeManyEose(relays, filter, {
maxWait,
onevent(e) {
got.set(e.id, e);
if (filter.ids && filter.ids.every((id) => got.has(id))) finish();
+ if (idle) { clearTimeout(quiet); quiet = setTimeout(finish, idle); }
},
onclose: finish,
});
if (done) try { sub.close(); } catch {}
});
const threads = createThreadCache({ build: (ref) => buildThread(ref, query) });
+const primal = createPrimal();
+const profiles = createThreadCache({ build: (ref) => buildProfile(ref, { query, primal }) });
+const resolveName = createNameResolver({ url: process.env.NAMES_URL || 'https://names.coinos.io' });
const index = createIndexSource(process.env.INDEX_URL || 'http://bitcoin-wallet/index.html');
const server = Bun.serve({
hostname: process.env.HOST || '127.0.0.1',
@@ -33,6 +41,9 @@ const server = Bun.serve({
if (NOTE_PATH.test(path) && ['GET', 'HEAD'].includes(req.method)) {
try { return await notePageResponse(req, { index, threads }); } catch { return new Response('Unavailable', { status: 502 }); }
}
+ if (PROFILE_PATH.test(path) && ['GET', 'HEAD'].includes(req.method)) {
+ try { return await profilePageResponse(req, { index, profiles, resolveName }); } catch { return new Response('Unavailable', { status: 502 }); }
+ }
return new Response('Not found', { status: 404 });
},
});
diff --git a/src/features/messages.js b/src/features/messages.js
index 3ab64d9..39cc7b1 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -2164,6 +2164,29 @@ export function messagesFeature(ctx) {
}, 0);
}
+ // (runs after construction: the caches it fills are declared further down)
+ function seedBootProfile(path) {
+ try {
+ const el = typeof document !== 'undefined' && document.getElementById('boot-profile');
+ const d = el && JSON.parse(el.textContent);
+ if (!d || d.v !== 1 || !/^[0-9a-f]{64}$/.test(d.pk || '') || String(d.path || '').toLowerCase() !== path.toLowerCase()) return null;
+ const pk = d.pk;
+ for (const ev of [...(d.profiles || []), d.profile]) {
+ if (ev && ev.kind === 0 && /^[0-9a-f]{64}$/.test(ev.pubkey || '')) applyProfile(ev.pubkey, ev);
+ }
+ // the page's own copy (not marked fetched: the relays still freshen it)
+ if (d.profile && d.profile.pubkey === pk && !fullProfiles.has(pk)) {
+ try { const m = JSON.parse(d.profile.content); if (m && typeof m === 'object' && !Array.isArray(m)) fullProfiles.set(pk, m); } catch {}
+ }
+ const notes = (d.notes || []).filter((e) => e && e.pubkey === pk && PROFILE_KINDS.includes(e.kind));
+ if (notes.length && !notesCache.has(pk)) notesCache.set(pk, { status: 'ready', notes, fetchedAt: 0 });
+ if (d.counts && !socialCounts.get(pk)?.at) {
+ const num = (n) => (Number.isFinite(n) && n >= 0 ? n : null);
+ socialCounts.set(pk, { follows: num(d.counts.follows), followers: num(d.counts.followers), at: Date.now() });
+ }
+ return pk;
+ } catch { return null; }
+ }
if (urlProfile) {
// Claim the first paint synchronously: these are set before the boot
// render, so the visitor sees a profile shell from the first frame —
@@ -2172,7 +2195,12 @@ export function messagesFeature(ctx) {
ui.pubProfPending = urlProfile;
(async () => {
await new Promise((r) => setTimeout(r, 0)); // never render mid-boot
- let pk = parseNostrPubkey(urlProfile);
+ // The server renders profile links with the page already in them
+ // (feed/profile-page.js): kind 0, latest posts, follow counts and the
+ // faces the posts mention. Into the caches the page reads they go, so
+ // it paints whole on its first frame; the usual fetches still freshen
+ // it behind. A coinos name arrives already resolved.
+ let pk = seedBootProfile(urlProfile) || parseNostrPubkey(urlProfile);
if (!pk && /^[a-z0-9._-]{1,30}$/i.test(urlProfile)) {
// same registrar + domain rule the names feature uses
const domain = getNetwork() === 'mutinynet' ? 'staging.coinos.io' : 'coinos.io';
@@ -3571,11 +3599,14 @@ export function messagesFeature(ctx) {
// after a miss; the request state lets
// it fall back once we've finished looking.
const sendFace = cls.includes('send-avatar');
+ // A profile page's own face is the same: the one big picture on the
+ // page, never punk art while that person's lookup is still running.
+ const pageFace = cls.includes('profile-avatar');
const waiting = (p === null || (p && p.loading && !p.picture))
- && (sendFace ? p === null || profQueue.has(pk) || profInFlight.has(pk) : faceWaiting(pk));
+ && (sendFace || pageFace ? p === null || profQueue.has(pk) || profInFlight.has(pk) || fullInFlight.has(pk) : faceWaiting(pk));
const node = feedPaint && !p.picture
? h('div', { class: cls + ' fallback' }, (p.name || npubOf(pk) || '??').slice(0, 2))
- : waiting && (sendFace || p || ui.noteThread || (ui.chatOpen && ui.msgView === 'room'))
+ : waiting && (sendFace || pageFace || p || ui.noteThread || (ui.chatOpen && ui.msgView === 'room'))
? h('div', { class: cls + ' fallback loading', style: sendFace ? 'visibility:hidden' : undefined })
: p && p.loading && !p.picture
? fallbackAvatar(h, pk, p.name, cls)
diff --git a/tools/profile-page-test.js b/tools/profile-page-test.js
new file mode 100644
index 0000000..19b8b0b
--- /dev/null
+++ b/tools/profile-page-test.js
@@ -0,0 +1,126 @@
+// A shared profile link (/npub1…, /<coinos name>): the server renders the
+// person's kind 0, latest posts and follow counts into the page, and the app
+// paints all of it on its first frame — no punk, no "–" counts, no empty
+// column while relays answer. Relays are blackholed here, so whatever shows
+// came from the page.
+// Run: bun tools/profile-page-test.js
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { npubEncode } from 'nostr-tools/nip19';
+import { parseProfileRef, buildProfile, renderProfilePage } from '../feed/profile-page.js';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const check = (name, fn) => Promise.resolve(fn()).then(() => console.log('✓ ' + name));
+
+const A = generateSecretKey(), B = generateSecretKey();
+const pkA = getPublicKey(A), pkB = getPublicKey(B);
+const now = Math.floor(Date.now() / 1000);
+const kind0 = (sk, m, at = now - 9000) => finalizeEvent({ kind: 0, created_at: at, tags: [], content: JSON.stringify(m) }, sk);
+const posts = [
+ finalizeEvent({ kind: 1, created_at: now - 60, tags: [], content: 'Sunrise over the ridge </script><b>not markup</b>' }, A),
+ finalizeEvent({ kind: 1, created_at: now - 120, tags: [['p', pkB]], content: 'Thanks nostr:' + npubEncode(pkB) + ' for the tip' }, A),
+];
+const events = [kind0(A, { name: 'vesper', display_name: 'Vesper', picture: '/punks/18.webp', about: 'Climber & coder' }),
+ kind0(A, { name: 'old' }, now - 99999), kind0(B, { name: 'Rae' }), ...posts];
+const match = (f) => events.filter((e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)));
+const query = async (relays, f) => match(f);
+const primal = async () => ({ profile: null, follows: 901, followers: 589 });
+
+const npub = npubEncode(pkA);
+await check('profile paths parse; files, app routes and notes do not', () => {
+ assert.equal(parseProfileRef('/' + npub).pk, pkA);
+ assert.equal(parseProfileRef('/Asoltys').name, 'asoltys');
+ for (const p of ['/sw.js', '/chat', '/feed', '/note1abc', '/a/b', '/', '/npub1garbage']) assert.equal(parseProfileRef(p), null, p);
+});
+const ref = { ...parseProfileRef('/' + npub) };
+const data = await buildProfile(ref, { query, primal });
+await check('the newest kind 0, the posts, the counts and the mentioned faces', () => {
+ assert.equal(JSON.parse(data.profile.content).display_name, 'Vesper');
+ assert.deepEqual(data.notes.map((e) => e.id), posts.map((e) => e.id));
+ assert.deepEqual(data.counts, { follows: 901, followers: 589 });
+ assert.deepEqual(data.profiles.map((e) => e.pubkey), [pkB]);
+});
+await check('nobody there is left to the client', async () => {
+ assert.equal(await buildProfile({ pk: 'e'.repeat(64), relays: [] }, { query, primal: async () => null }), null);
+});
+
+const html = await buildHtml({ minify: true, pwa: false });
+const page1 = renderProfilePage(html, data, { ...ref, path: npub }, 'https://v3.coinos.io');
+await check('preview tags name the person; the JSON cannot close its tag', () => {
+ assert.match(page1, /<title>Vesper on Coinos<\/title>/);
+ assert.match(page1, /<meta property="og:description" content="Climber &amp; coder">/);
+ const json = page1.slice(page1.indexOf('id="boot-profile">'));
+ assert(!json.slice(0, json.indexOf('</script>')).includes('</script'), 'no closing tag inside the JSON');
+});
+
+const server = Bun.serve({ port: 0, fetch: (req) => {
+ const u = new URL(req.url), p = u.pathname;
+ if (p.startsWith('/punks') || p === '/verify-worker.js') return new Response(Bun.file('dist' + p));
+ if (p === '/' + npub && !u.searchParams.has('plain')) return new Response(page1, { headers: { 'content-type': 'text/html' } });
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage();
+ await page.setViewport({ width: 390, height: 844 });
+ await page.evaluateOnNewDocument(() => {
+ window.WebSocket = function () { const ws = new EventTarget(); ws.readyState = 0; ws.send = () => {}; ws.close = () => {}; return ws; };
+ window.__frames = [];
+ const tick = () => {
+ try {
+ const a = document.querySelector('.profile-avatar');
+ if (!a) return;
+ window.__frames.push({
+ punk: !!a.querySelector('.punk'), img: a.classList.contains('ava-img'),
+ social: (document.querySelector('.social-line')?.textContent || '').replace(/\s+/g, ' '),
+ posts: document.querySelectorAll('.notes-feed > *').length,
+ });
+ } catch {}
+ };
+ setInterval(tick, 16);
+ });
+ const errs = [];
+ page.on('pageerror', (e) => errs.push(String(e)));
+ await page.goto(server.url.href + npub, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.__frames.length > 5, { timeout: 8000 });
+ await sleep(300);
+ const frames = await page.evaluate(() => window.__frames);
+ await check('the first frame with anything in it is complete: face, counts, posts', () => {
+ const i = frames.findIndex((f) => f.img || f.social || f.posts);
+ // before it, only the blank boot shell (a frame or so): no placeholder face, counts or posts
+ assert(frames.slice(0, i).every((f) => !f.img && !f.social && !f.posts && !f.punk));
+ const f = frames[i];
+ assert.equal(f.img, true, 'picture');
+ assert.match(f.social, /901 Following/);
+ assert.match(f.social, /589 Followers/);
+ assert(f.posts >= 2, 'posts: ' + f.posts);
+ });
+ await check('no punk at any point', () => assert(frames.every((f) => !f.punk)));
+ await check('mentions read as names', async () => {
+ const text = await page.evaluate(() => document.body.innerText);
+ assert.match(text, /Vesper/);
+ assert.match(text, /Rae/);
+ assert.match(text, /Sunrise over the ridge <\/script><b>not markup<\/b>/);
+ });
+ await check('no page errors', () => assert.deepEqual(errs, []));
+ await page.close();
+
+ // the plain page (server down or slow): a quiet circle, never the punk,
+ // while the (here: silent) relays are asked
+ const plain = await browser.newPage();
+ await plain.evaluateOnNewDocument(() => {
+ window.WebSocket = function () { const ws = new EventTarget(); ws.readyState = 0; ws.send = () => {}; ws.close = () => {}; return ws; };
+ window.__punk = false;
+ setInterval(() => { if (document.querySelector('.profile-avatar .punk')) window.__punk = true; }, 16);
+ });
+ await plain.goto(server.url.href + npub + '?plain', { waitUntil: 'domcontentloaded' });
+ await plain.waitForSelector('.profile-avatar', { timeout: 8000 });
+ await sleep(1200);
+ await check('without the server: no punk while the lookup runs', async () => {
+ assert.equal(await plain.evaluate(() => window.__punk), false);
+ });
+ await plain.close();
+} finally { await browser.close(); server.stop(true); }
+console.log('\n✅ profile pages');
+process.exit(0);