clone

tiny dependency-free nostr libraries for plain html, each under 5kb and named for an element. public domain

Add hydrogen and xenon: the relay pool, family hooks, and nostr in HTML

Dami committed 31a637a
commit
31a637a3b00c74d7856e5540f8ed46dd84ac71be
parent
86c250b8556925e7fda3ea5d3639777290c5f57c
author
Dami <dami.etoile@gmail.com>
7 files changed, +422 -0
Ademo/index.html94++++++++++++++++++++++++++++++
Ademo/relay.js6++
Ahydrogen/README.md40+++++++++++++
Ahydrogen/hydrogen.js73+++++++++++++++++++++++
Atest/e2e.test.js84+++++++++++++++++++++++++++
Axenon/README.md47+++++++++++++++
Axenon/xenon.js78+++++++++++++++++++++++++
A demo/index.html +94 -0
@@ -0,0 +1,94 @@
1+<!doctype html>2+<html lang="en">3+<head>4+<meta charset="utf-8">5+<meta name="viewport" content="width=device-width, initial-scale=1">6+<title>nobel demo</title>7+<script src="relay.js"></script>8+<script src="../neon/neon.js"></script>9+<script src="../krypton/krypton.js"></script>10+<script src="../argon/argon.js"></script>11+<script src="../hydrogen/hydrogen.js"></script>12+<script src="../xenon/xenon.js"></script>13+<script src="../radon/radon.js"></script>14+<style>15+ :root { --ink:#1d1b16; --paper:#f4efe4; --line:#d8cfbd; --gas:#c2410c; --muted:#7a7263; }16+ * { box-sizing:border-box; }17+ body { margin:0; background:var(--paper); color:var(--ink); font:15px/1.5 ui-serif, Georgia, serif; }18+ main { max-width:640px; margin:0 auto; padding:40px 20px 80px; }19+ h1 { font:600 13px/1 ui-monospace, Menlo, monospace; letter-spacing:.12em; text-transform:uppercase; margin:0 0 4px; }20+ h1 b { color:var(--gas); }21+ .sub { color:var(--muted); margin:0 0 28px; font-style:italic; }22+ .me { display:flex; gap:12px; align-items:center; font:13px ui-monospace, Menlo, monospace; color:var(--muted); margin-bottom:16px; }23+ .me b { color:var(--ink); }24+ form { display:flex; gap:8px; margin:0 0 12px; }25+ input, textarea, button { font:inherit; color:inherit; background:#fffdf8; border:1px solid var(--line); border-radius:6px; padding:8px 10px; }26+ textarea { flex:1; resize:vertical; min-height:44px; }27+ button { cursor:pointer; font:12px ui-monospace, Menlo, monospace; text-transform:uppercase; letter-spacing:.08em; }28+ button:hover { border-color:var(--gas); color:var(--gas); }29+ ol { list-style:none; padding:0; margin:28px 0 0; border-top:1px solid var(--line); }30+ li { padding:14px 0; border-bottom:1px solid var(--line); }31+ li header { display:flex; gap:8px; align-items:baseline; font:12px ui-monospace, Menlo, monospace; color:var(--muted); }32+ li header b { color:var(--ink); max-width:14ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }33+ li header [nrn-verified]::after { content:" ✓"; color:var(--gas); }34+ li p { margin:6px 0; white-space:pre-wrap; overflow-wrap:anywhere; }35+ li p img { display:block; max-width:100%; border-radius:6px; margin-top:6px; }36+ li a { color:var(--gas); }37+ li button { padding:2px 8px; font-size:11px; }38+ .tag { color:var(--gas); }39+ .log { font:12px ui-monospace, Menlo, monospace; color:var(--muted); min-height:1.5em; }40+</style>41+</head>42+<body>43+<main>44+ <h1><b>nobel</b> demo</h1>45+ <p class="sub">xenon + krypton + argon + neon + radon, in plain HTML.</p>46+47+ <div class="me">signed in as <b id="me" nrn-field="name">…</b> <span id="npub"></span></div>48+49+ <form id="profile" nxe-publish nxe-kind="0">50+ <input name="name" placeholder="your name" required>51+ <button>Set name</button>52+ </form>53+54+ <form id="compose" nxe-publish>55+ <textarea name="content" placeholder="say something (links, images and nostr: mentions render)" required></textarea>56+ <input name="#t" placeholder="#tag" size="8">57+ <button>Post</button>58+ </form>59+ <div class="log" id="log"></div>60+61+ <ol id="feed" nxe-filter='{"kinds":[1],"limit":50}'>62+ <template>63+ <li>64+ <header>65+ <b nrn-profile="{pubkey}" nrn-field="name">{npub}</b>66+ <time nne-ago="{created_at}">{ago}</time>67+ <span class="tag">{#t}</span>68+ </header>69+ <p>{content}</p>70+ <button nxe-publish nxe-kind="7" nxe-content="+" nxe-tags='[["e","{id}"],["p","{pubkey}"]]'>+ like</button>71+ </li>72+ </template>73+ </ol>74+</main>75+<script>76+ // Profiles are kind 0 events whose content is JSON, so build it from the form.77+ profile.addEventListener("nhy:config", (evt)=>{78+ evt.detail.cfg.event.content = JSON.stringify({name:new FormData(profile).get("name")})79+ })80+ document.addEventListener("nxe:published", (evt)=>{81+ let {event, results} = evt.detail.cfg82+ log.textContent = `kind ${event.kind} ${event.id.slice(0, 8)}: ` + results.map((r)=>`${r.ok ? "ok" : "failed"} ${r.relay}`).join(", ")83+ })84+ document.addEventListener("nxe:error", (evt)=>log.textContent = String(evt.detail.error))85+ // Unfilled tag placeholders read better empty.86+ document.addEventListener("nhy:event", (evt)=>evt.detail.fields["#t"] ??= "")87+ let who = (window.nostr ?? krypton.signer).getPublicKey()88+ who.then((pk)=>{89+ me.setAttribute("nrn-profile", pk)90+ npub.textContent = neon.encode("npub", pk).slice(0, 16) + "…"91+ })92+</script>93+</body>94+</html>
A demo/relay.js +6 -0
A hydrogen/README.md +40 -0
@@ -0,0 +1,40 @@
1+# 💧 hydrogen.js - relays and hooks2+3+hydrogen.js is the element everything else is made of: a miniature relay pool with the family's shared hooks. It keeps one connection per relay, reconnects, dedupes events, and runs every subscription, publish and signature through three DOM events that the other libraries listen to. It has no attributes of its own besides `nhy-relay`, and runs in Deno too.4+5+```js6+let sub = hydrogen.req({filters:[{kinds:[0], authors:[pk]}], onevent:(event, from)=>{}})7+let signed = await hydrogen.sign({event:{kind:1, created_at, tags:[], content:"hi"}})8+let results = await hydrogen.pub(signed, hydrogen.relays()) // [{relay, ok, msg}]9+```10+11+That subscribes, signs with whatever signer is present, and publishes to the page's relays.12+13+## Attributes14+15+| attribute | description |16+|---|---|17+| `nhy-relay` | Space-separated relay URLs, inherited from the closest ancestor. Falls back to `window.nhyCfg.relays` |18+19+`window.nhyCfg = {relays, timeout}`: default relays, and publish timeout in ms (default 8000).20+21+## Events (the family hooks)22+23+| event | on | cancel to | detail |24+|---|---|---|---|25+| `nhy:config` | `cfg.elt` or document | skip the request or signature | `cfg`: live. Subscriptions carry `filters`, `relays`, `verify`, `map`, `onevent`. Signatures carry `event` and `sign` |26+| `nhy:event` | `cfg.elt` or document | skip `cfg.onevent` | `cfg`, `event`, `from` (relay URL, or a cache name), `fields` (escaped, for templates) |27+| `nhy:message` | document | skip hydrogen's own handling | `url`, `relay`, `msg`: every raw relay message, before anything else |28+29+Signers (krypton, chlorine, an extension shim) set `cfg.sign` in `nhy:config`. Caches (argon) answer subscriptions there and store from `nhy:event`. Formatters (neon) add fields in `nhy:event`. Relay AUTH (gold) works through `nhy:message`.30+31+## API32+33+| function | returns |34+|---|---|35+| `hydrogen.req(cfg)` | the live `cfg` with `close()`, or nothing if `nhy:config` was cancelled. `cfg.elt` scopes events and relays; `cfg.verify(event)` and `cfg.map(event)` run before `nhy:event` |36+| `hydrogen.sign(cfg)` | the signed `cfg.event`, after `nhy:config`. The default signer is `window.nostr` |37+| `hydrogen.pub(event, relays)` | `[{relay, ok, msg}]` per relay |38+| `hydrogen.relays(elt?)` | relay URLs for an element |39+| `hydrogen.relay(url)` | the pooled connection: `send(msg)`, `subs`, `oks`, `ws` |40+| `hydrogen.esc(text)` | escaped HTML |
A hydrogen/hydrogen.js +73 -0
@@ -0,0 +1,73 @@
1+(()=>{2+ let g = globalThis, d = g.document, T = d ?? g3+ if (g.hydrogen) return4+ let pool = {}, n = 05+ let send = (el, type, detail)=>el.dispatchEvent(new CustomEvent("nhy:" + type, {detail, cancelable:1, bubbles:1, composed:1}))6+ let esc = (s)=>(s + "").replace(/[&<>"']/g, (c)=>`&#${c.charCodeAt()};`)7+ let up = (r)=>r.ws.readyState == 18+ let relays = (el)=>el?.closest?.("[nhy-relay]")?.getAttribute("nhy-relay").split(/\s+/) ?? g.nhyCfg?.relays ?? []9+ let relay = (url)=>{10+ let r = pool[url]11+ if (r) return r12+ r = pool[url] = {url, subs:new Map(), oks:new Map(), q:[]}13+ r.send = (m)=>up(r) ? r.ws.send(JSON.stringify(m)) : r.q.push(m)14+ let connect = (ws)=>{15+ ws = r.ws = new WebSocket(url)16+ ws.onopen = ()=>{17+ r.subs.forEach((c, id)=>r.send(["REQ", id, ...c.filters]))18+ r.q.splice(0).forEach(r.send)19+ }20+ ws.onmessage = (m)=>{21+ try {22+ let msg = JSON.parse(m.data), [t, a, b, c] = msg, s = r.subs.get(a)23+ if (!send(T, "message", {url, relay:r, msg})) return24+ if (t == "EVENT") s?.deliver(b, url)25+ if (t == "EOSE") s?.oneose?.(url)26+ if (t == "OK") r.oks.get(a)?.(b, c)27+ } catch {}28+ }29+ ws.onclose = ()=>r.subs.size || r.q.length ? setTimeout(connect, 3e3) : delete pool[url]30+ }31+ connect()32+ return r33+ }34+ let req = (cfg)=>{35+ let id = "h" + ++n, seen = new Set(), el = cfg.elt ?? T, rs = []36+ cfg.relays ??= relays(cfg.elt)37+ cfg.deliver = async(event, from)=>{38+ if (el.isConnected === false) cfg.close()39+ if (seen.has(event.id) || !cfg.open) return40+ seen.add(event.id)41+ if (cfg.verify && !await cfg.verify(event)) return42+ if (cfg.map && !(event = await cfg.map(event))) return43+ let fields = {}44+ for (let k in event) fields[k] = esc(event[k])45+ for (let [k, v] of event.tags) fields["#" + k] ??= esc(v ?? "")46+ if (send(el, "event", {cfg, event, from, fields})) cfg.onevent?.(event, from, fields)47+ }48+ cfg.close = ()=>{49+ cfg.open = 050+ rs.forEach((r)=>r.subs.delete(id) && up(r) && r.send(["CLOSE", id]))51+ }52+ if (!send(el, "config", {cfg})) return53+ cfg.open = 154+ for (let r of rs = cfg.relays.map(relay)){55+ r.subs.set(id, cfg)56+ if (up(r)) r.send(["REQ", id, ...cfg.filters])57+ }58+ return cfg59+ }60+ let pub = (event, list)=>Promise.all(list.map((url)=>new Promise((res)=>{61+ let r = relay(url), t = setTimeout(()=>done(false, "timeout"), g.nhyCfg?.timeout ?? 8e3)62+ let done = (ok, msg)=>res({relay:url, ok, msg}, clearTimeout(t), r.oks.delete(event.id))63+ r.oks.set(event.id, done)64+ r.send(["EVENT", done.event = event])65+ })))66+ // The family signing hook: nhy:config lets krypton, chlorine or anything else replace cfg.sign.67+ let sign = async(cfg)=>{68+ cfg.sign = (e)=>g.nostr.signEvent(e)69+ cfg.relays ??= relays(cfg.elt)70+ if (send(cfg.elt ?? T, "config", {cfg})) return cfg.event = await cfg.sign(cfg.event)71+ }72+ g.hydrogen = {req, pub, sign, relay, relays, esc, pool}73+})()
A test/e2e.test.js +84 -0
@@ -0,0 +1,84 @@
1+// Drives the demo in headless Chromium against the dev server's in-memory relay.2+import { assert, assertEquals } from "jsr:@std/assert@1";3+import { chromium } from "npm:playwright@1.58";4+import { serve } from "../server.ts";5+6+// Use CHROMIUM if set, else the newest headless shell already in the Playwright cache.7+async function chromiumPath() {8+ if (Deno.env.get("CHROMIUM")) return Deno.env.get("CHROMIUM");9+ const cache = `${Deno.env.get("HOME")}/Library/Caches/ms-playwright`;10+ try {11+ const dirs = [...Deno.readDirSync(cache)].map((d) => d.name).filter((n) => n.startsWith("chromium_headless_shell-")).sort();12+ const dir = `${cache}/${dirs.at(-1)}`;13+ const arch = [...Deno.readDirSync(dir)][0].name;14+ return `${dir}/${arch}/chrome-headless-shell`;15+ } catch { return undefined; }16+}17+18+Deno.test({ name: "demo: post, render, profile, like, cache", sanitizeResources: false, sanitizeOps: false }, async (t) => {19+ const server = serve(0);20+ const url = `http://localhost:${server.addr.port}/demo/`;21+ const browser = await chromium.launch({ executablePath: await chromiumPath() });22+ const page = await browser.newPage();23+ const errors = [];24+ page.on("pageerror", (e) => errors.push(e.message));25+ try {26+ await page.goto(url);27+28+ await t.step("krypton signs a note, xenon publishes it and renders it", async () => {29+ await page.fill("#compose textarea", "hello <b>nobel</b> https://example.com/x");30+ await page.fill("#compose input", "nobel");31+ await page.click("#compose button");32+ await page.waitForSelector("#feed li");33+ const li = page.locator("#feed li").first();34+ assertEquals(await li.locator("p b").count(), 0, "content must be escaped");35+ assertEquals(await li.locator("p a").getAttribute("href"), "https://example.com/x");36+ assertEquals(await li.locator(".tag").textContent(), "nobel");37+ assertEquals(await li.locator("time").textContent(), "now");38+ assertEquals(await page.inputValue("#compose textarea"), "", "form resets after an OK");39+ });40+41+ await t.step("radon fills names after a kind 0 is published", async () => {42+ await page.fill("#profile input", "Ada");43+ await page.click("#profile button");44+ await page.waitForFunction(() => document.querySelector("#feed li b")?.textContent === "Ada");45+ await page.waitForFunction(() => document.querySelector("#me")?.textContent === "Ada");46+ });47+48+ await t.step("newest events sort to the top", async () => {49+ await page.fill("#compose textarea", "second");50+ await page.click("#compose button");51+ await page.waitForFunction(() => document.querySelectorAll("#feed li").length === 2);52+ await page.waitForFunction(() => document.querySelector("#feed li p").textContent === "second");53+ });54+55+ await t.step("template buttons publish reactions", async () => {56+ await page.locator("#feed li button").first().click();57+ await page.waitForFunction(() => /kind 7 .*ok/.test(document.querySelector("#log").textContent), null, { timeout: 5000 })58+ .catch(async (e) => { throw new Error(`status: ${await page.textContent("#log")}`, { cause: e }); });59+ });60+61+ await t.step("argon serves the feed from IndexedDB on reload", async () => {62+ await page.addInitScript(() => {63+ window.__from = [];64+ document.addEventListener("nhy:event", (e) => e.detail.event.kind === 1 && window.__from.push(e.detail.from));65+ });66+ await page.reload();67+ await page.waitForFunction(() => document.querySelectorAll("#feed li").length === 2);68+ await page.waitForTimeout(300);69+ const from = await page.evaluate(() => window.__from);70+ assertEquals(from.slice(0, 2), ["argon", "argon"], "cache delivers first");71+ assert(await page.evaluate(async () => (await argon.query([{ kinds: [1] }])).length === 2));72+ });73+74+ await t.step("identical events from cache and relay render once", async () => {75+ await page.waitForTimeout(300);76+ assertEquals(await page.locator("#feed li").count(), 2);77+ });78+79+ assertEquals(errors, []);80+ } finally {81+ await browser.close();82+ await server.shutdown();83+ }84+});
A xenon/README.md +47 -0
@@ -0,0 +1,47 @@
1+# 🛰️ xenon.js - nostr in HTML2+3+xenon.js is a miniature nostr client, in the way fixi.js is a miniature htmx. Any element can subscribe to relays and place incoming events anywhere in the document, and any form or button can publish one. It is the HTML layer over hydrogen.js, which owns relays, signing and the family's hooks.4+5+```html6+<script src="hydrogen.js"></script>7+<script src="xenon.js"></script>8+9+<ol nhy-relay="wss://relay.example" nxe-filter='{"kinds":[1],"limit":20}'>10+ <template><li>{content}</li></template>11+</ol>12+```13+14+The list opens a subscription and inserts each note, newest first, as it arrives. Removing the list closes it.15+16+## Attributes17+18+| attribute | default | description |19+|---|---|---|20+| `nxe-filter` | | JSON filter (or array of filters). Opens a subscription on this element |21+| `nxe-template` | inner `<template>` | Selector of the template to render each event with |22+| `nxe-target` | this element | Selector of where rendered events go |23+| `nxe-swap` | `newest` | `newest` or `oldest` (sorted by `created_at`), any `insertAdjacentHTML` position, or a property such as `innerHTML` |24+| `nxe-publish` | | Publishes on submit (forms) or click (anything else) |25+| `nxe-trigger` | `submit` / `click` | Event that publishes |26+| `nxe-kind` | `1` | Kind of the published event |27+| `nxe-content` | form field `content` | Content of the published event |28+| `nxe-tags` | `[]` | JSON tags. Form fields named `#x` add `["x", value]` |29+| `nxe-ignore` | | xenon skips this element and its descendants |30+31+Relays come from hydrogen: the closest `nhy-relay`, else `nhyCfg.relays`.32+33+Templates interpolate `{id}`, `{pubkey}`, `{kind}`, `{created_at}`, `{content}`, `{sig}` and the first value of any tag as `{#t}`. Values are HTML-escaped. Listeners of hydrogen's `nhy:event` can add or replace fields (neon.js does).34+35+## Events36+37+| event | cancel to | detail |38+|---|---|---|39+| `nxe:init` | skip the element | |40+| `nxe:swapped` | | `cfg`, `event` |41+| `nxe:eose` | | `cfg`, `from` |42+| `nxe:publish` | skip sending | `cfg` (signed `cfg.event`) |43+| `nxe:published` | | `cfg` (`cfg.results` per relay) |44+| `nxe:error` | | `cfg`, `error` |45+| `nxe:process` | | dispatch on an element to initialize new content |46+47+Before any of these, hydrogen's `nhy:config` carries the live `cfg`: change `cfg.relays`, `cfg.filters`, `cfg.template`, `cfg.swap` (a function works too), `cfg.onevent`, or for publishes `cfg.event` and `cfg.sign`.
A xenon/xenon.js +78 -0
@@ -0,0 +1,78 @@
1+(()=>{2+ let doc = document3+ if (doc.__nxe_mo) return4+ let live = new Set()5+ doc.__nxe_mo = new MutationObserver((recs)=>recs.forEach((r)=>{6+ r.addedNodes.forEach(process)7+ if (r.removedNodes.length) live.forEach((c)=>c.elt.isConnected || (c.close(), live.delete(c)))8+ }))9+ let send = (el, type, detail)=>el.dispatchEvent(new CustomEvent("nxe:" + type, {detail, cancelable:1, bubbles:1, composed:1}))10+ let attr = (el, name, d)=>el.getAttribute(name) || d11+ let swap = (cfg, html, event)=>{12+ let s = cfg.swap, t = cfg.target, at = event.created_at13+ if (s instanceof Function) s(cfg, html, event)14+ else if (/^(newest|oldest)$/.test(s)){15+ let tp = doc.createElement("template")16+ tp.innerHTML = html17+ tp.content.firstElementChild?.setAttribute("nxe-at", at)18+ t.insertBefore(tp.content, [...t.children].find((c)=>{19+ let x = c.getAttribute("nxe-at")20+ return x != null && (s == "newest" ? x <= at : x > at)21+ }))22+ }23+ else if (/(before|after)(begin|end)/.test(s)) t.insertAdjacentHTML(s, html)24+ else if (s in t) t[s] = html25+ }26+ let subscribe = (el)=>{27+ let tpl = doc.querySelector(attr(el, "nxe-template")) ?? el.querySelector(":scope>template")28+ let cfg = {29+ elt:el,30+ filters:[].concat(JSON.parse(attr(el, "nxe-filter"))),31+ target:doc.querySelector(attr(el, "nxe-target")) ?? el,32+ swap:attr(el, "nxe-swap", "newest"),33+ template:tpl?.innerHTML ?? "<p>{content}</p>",34+ onevent:(event, from, fields)=>{35+ swap(cfg, cfg.template.replace(/\{(#?\w+)\}/g, (m, k)=>fields[k] ?? m), event)36+ send(el, "swapped", {cfg, event})37+ },38+ oneose:(from)=>send(el, "eose", {cfg, from})39+ }40+ if (hydrogen.req(cfg)) live.add(cfg)41+ }42+ let publish = async(el, form, evt)=>{43+ evt.preventDefault()44+ let fd = form ? new FormData(el, evt.submitter) : new FormData()45+ let tags = JSON.parse(attr(el, "nxe-tags", "[]"))46+ for (let [k, v] of fd) if (k[0] == "#" && v) tags.push([k.slice(1), v])47+ let cfg = {elt:el, event:{kind:+attr(el, "nxe-kind", 1), created_at:Date.now() / 1e3 | 0, tags, content:attr(el, "nxe-content", fd.get("content") ?? "")}}48+ try {49+ if (!await hydrogen.sign(cfg) || !send(el, "publish", {cfg})) return50+ let rs = cfg.results = await hydrogen.pub(cfg.event, cfg.relays)51+ if (form && (!rs.length || rs.some((r)=>r.ok))) el.reset()52+ send(el, "published", {cfg})53+ } catch(error) {54+ send(el, "error", {cfg, error})55+ }56+ }57+ let init = (el)=>{58+ if (el.__nxe || el.closest("[nxe-ignore]") || !send(el, "init", {})) return59+ el.__nxe = 160+ if (el.hasAttribute("nxe-filter")) subscribe(el)61+ if (el.hasAttribute("nxe-publish")){62+ let form = el.tagName == "FORM"63+ el.addEventListener(attr(el, "nxe-trigger", form ? "submit" : "click"), (evt)=>publish(el, form, evt))64+ }65+ }66+ let sel = "[nxe-filter],[nxe-publish]"67+ let process = (n)=>{68+ if (n.matches?.(sel)) init(n)69+ n.querySelectorAll?.(sel).forEach(init)70+ }71+ window.xenon = {process}72+ doc.addEventListener("nxe:process", (evt)=>process(evt.target))73+ let boot = ()=>{74+ doc.__nxe_mo.observe(doc.documentElement, {childList:1, subtree:1})75+ process(doc.body)76+ }77+ doc.readyState == "loading" ? doc.addEventListener("DOMContentLoaded", boot) : boot()78+})()