openvibers/openvibe.events Commits
The openvibe.events home shows the bus at work: events today and this week, who published, the public event types with their counts and last arrival, plus the questions people ask (#104)
Changes
@@@ -7,7 +7,7 @@**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §6 and §6.3.**License:** AGPL-3.0 (same as every OpenVibe service).--The home page sitemap entry uses the release commit time as lastmod.++The home page sitemap entry uses the release commit time as lastmod. The home page (`server/home.js`) shows the bus at work from this service's own tables (`server/home-stats.js`, kept five minutes): events in the last 24 hours and this week, how many services and event types published this week, active subscriptions and their consumers, deliveries made in the 30-day hot window and an empty dead-letter queue when it is empty; who published this week (events by the first segment of their type); and the public event types (visibility `public`) with their counts and when each last arrived. Only counts and names leave the reader, never a payload, an actor, a subject id or a principal; a section with nothing to show is left out. Its questions are a FAQPage in its JSON-LD.## Purpose@@@ -16,6 +16,7 @@ const { resourceControlRouter } = require('./api/resource-control');const { mountLimits } = require('./limits');const { createLimits } = require('./actor-limits');const { renderHome, renderUpdates, renderNotFound, HOME_CSP } = require('./home');++const { createHomeStats } = require('./home-stats');const appIcon = require('openvibe-shared/app-icon');const { createDiscoveryRoutes } = require('./discovery');const ovServe = require('openvibe-shared/serve');@@@ -117,14 +118,19 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo.send(renderUpdates({ siteUrl: config.siteUrl }));});-- app.get('/', (req, res) => {++ // The home's live sections: counts and names from this service's own tables, kept five minutes (server/home-stats.js).++ const homeStats = createHomeStats({ store, now: () => (clock ? clock.now() : Date.now()), log });++ app.get('/', async (req, res, next) => {res.vary('Accept');if (req.accepts(['text/plain', 'text/html']) === 'text/html') {-- return res.type('html')-- .set('Content-Security-Policy', HOME_CSP)-- // private: Cloudflare caches by URL and ignores Vary: Accept, so a shared copy could reach an API client.-- .set('Cache-Control', 'private, max-age=300')-- .send(renderHome({ siteUrl: config.siteUrl, apiUrl: config.baseUrl }));++ try {++ const stats = await homeStats.read();++ return res.type('html')++ .set('Content-Security-Policy', HOME_CSP)++ // private: Cloudflare caches by URL and ignores Vary: Accept, so a shared copy could reach an API client.++ .set('Cache-Control', 'private, max-age=300')++ .send(renderHome({ siteUrl: config.siteUrl, apiUrl: config.baseUrl, stats }));++ } catch (e) { return next(e); }}res.type('text/plain').send(['OpenVibe.Events: durable events, subscriptions, signed delivery, dead letters and replay.',nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++++/**++ * What the home page shows of the bus at work, read from this service's own tables and kept a few minutes:++ *++ * createHomeStats({ store, now, ttlMs, log }) → { read() }++ * read() → { day, week, types, services: [{ ns, n }], public: [{ type, n, last }],++ * subscriptions, consumers, delivered, dead } | null++ *++ * Only counts and names leave this module: never a payload, an actor, a subject id or a publisher's principal. The++ * per-type list is the events whose visibility is `public` (the Contracts registry's own word); the++ * per-service counts are every event, by the first segment of its type. read() never throws: a failed read keeps the++ * last answer (null before the first), so the page leaves the sections out instead of failing.++ */++const DAY_MS = 24 * 60 * 60 * 1000;++++function createHomeStats({ store, now = () => Date.now(), ttlMs = 5 * 60 * 1000, waitMs = 1500, log = console } = {}) {++ let cached = null;++ let at = 0;++ let pending = null;++ const db = store && store.db;++++ async function load() {++ const t = now();++ const day = t - DAY_MS;++ const week = t - 7 * DAY_MS;++ const [dayRow, byNs, typeRow, pub, subs, counts] = await Promise.all([++ db.prepare('SELECT COUNT(*) AS n FROM events WHERE received_at > ?').get(day),++ db.prepare("SELECT split_part(event_type, '.', 1) AS ns, COUNT(*) AS n FROM events WHERE received_at > ? GROUP BY 1 ORDER BY 2 DESC, 1").all(week),++ db.prepare('SELECT COUNT(DISTINCT event_type) AS n FROM events WHERE received_at > ?').get(week),++ db.prepare("SELECT event_type, COUNT(*) AS n, MAX(received_at) AS last FROM events WHERE received_at > ? AND visibility = 'public' GROUP BY 1 ORDER BY 2 DESC, 1").all(week),++ db.prepare('SELECT COUNT(*) AS n, COUNT(DISTINCT consumer) AS c FROM subscriptions WHERE enabled = 1').get(),++ store.deliveryCounts(),++ ]);++ const services = byNs.map((r) => ({ ns: String(r.ns), n: Number(r.n) })).filter((r) => r.ns && r.n > 0);++ return {++ day: Number(dayRow.n),++ week: services.reduce((sum, r) => sum + r.n, 0),++ types: Number(typeRow.n),++ services,++ public: pub.map((r) => ({ type: String(r.event_type), n: Number(r.n), last: Number(r.last) })),++ subscriptions: Number(subs.n),++ consumers: Number(subs.c),++ delivered: Number(counts.delivered || 0),++ dead: Number(counts.dead || 0),++ measuredAt: t,++ };++ }++++ function refresh() {++ if (!pending) {++ pending = load()++ .then((v) => { cached = v; at = now(); return v; })++ .catch((e) => { log.warn(`[events] home stats: ${e.message}`); at = now() - ttlMs + 60 * 1000; return cached; })++ .finally(() => { pending = null; });++ }++ return pending;++ }++++ async function read() {++ if (!db) return null;++ // Fresh, or a failure less than a minute ago (at is moved back so the next try waits a minute, not a request).++ if (at && now() - at < ttlMs) return cached;++ const p = refresh();++ if (cached) return cached;++ return Promise.race([p, new Promise((resolve) => setTimeout(() => resolve(null), waitMs).unref())]);++ }++++ return { read };++}++++module.exports = { createHomeStats };@@@ -4,9 +4,12 @@* The product home of OpenVibe.Events (openvibe.events, plan T7: the API origin is the product origin too).* The OpenVibe Frame (openvibe-shared/shell) around openvibe-shared/showcase sections* for developers. Every claim restates the README (Publishing, Subscriptions and delivery, Developer apps,-- * Realtime) and the code; nothing here reads data.++ * Realtime) and the code. The bus at work comes from server/home-stats.js (counts and names only, never a payload):++ * the numbers, who published this week and the public event types with when each last arrived; each section is left++ * out when there is nothing to show.*-- * renderHome({ siteUrl, apiUrl }) the whole document (siteUrl: the canonical product origin; apiUrl: the API origin)++ * renderHome({ siteUrl, apiUrl, stats }) the whole document (siteUrl: the canonical product origin; apiUrl: the API++ * origin; stats: home-stats read(), or null)* HOME_CSP its Content-Security-Policy (the Frame's scripts and calls to the Network)*/const shell = require('openvibe-shared/shell');@@@ -14,12 +17,13 @@ const frame = require('openvibe-shared/frame');const showcase = require('openvibe-shared/showcase');const ovServe = require('openvibe-shared/serve');const appIcon = require('openvibe-shared/app-icon');++const seo = require('openvibe-shared/seo');const SITE_NAME = 'OpenVibe.Events';const NETWORK_URL = 'https://openvibe.network';const SOURCE = 'https://github.com/OpenVibers/OpenVibe.Events';--const DESCRIPTION = 'Durable events for the OpenVibe network: publish once, and every subscriber gets it signed, retried and replayable. '-- + 'Webhooks, pull with a cursor, dead letters, replay and realtime streams for browsers.';++const DESCRIPTION = 'The OpenVibe network\'s event bus: publish once and every subscriber gets it signed, retried and replayable, '++ + 'by webhook, pull or realtime stream.';const HOME_CSP = ["default-src 'self'",@@@ -37,7 +41,85 @@ const HOME_CSP = ["form-action 'self' https://openvibe.network",].join('; ');--function sections({ apiUrl }) {++const esc = showcase.esc;++const fmt = (n) => Number(n).toLocaleString('en-US');++// Where each publishing service lives (the first segment of its event types). One not listed is shown by name only.++const SERVICES = {++ live: ['OpenVibe.Live', 'https://openvibe.live'], search: ['OpenVibe.Search', 'https://search.openvibe.network'], sources: ['OpenVibe.Sources', null],++ reviews: ['OpenVibe.Reviews', 'https://openvibe.reviews'], ai: ['OpenVibe AI', 'https://ai.openvibe.services'], work: ['OpenVibe.Work', 'https://openvibe.work'],++ trade: ['OpenVibe.Trade', 'https://openvibe.trade'], media: ['OpenVibe.Media', 'https://openvibe.media'], deals: ['OpenVibe.Deals', 'https://openvibe.deals'],++ community: ['OpenVibe.Community', 'https://openvibe.community'], help: ['OpenVibe.Help', 'https://openvibe.help'], chat: ['OpenVibe.Chat', 'https://openvibe.chat'],++ rent: ['OpenVibe.Rent', 'https://openvibe.rent'], host: ['OpenVibe.Host', 'https://openvibe.host'], network: ['OpenVibe.Network', 'https://openvibe.network'],++ news: ['OpenVibe.News', 'https://openvibe.news'], coupons: ['OpenVibe.Coupons', 'https://openvibe.coupons'], games: ['OpenVibe.Games', 'https://openvibe.games'],++ tools: ['OpenVibe.Tools', 'https://openvibe.tools'], wiki: ['OpenVibe.Wiki', 'https://openvibe.wiki'], blog: ['OpenVibe.Blog', 'https://openvibe.blog'],++ quest: ['OpenVibe.Quest', 'https://openvibe.quest'], inventory: ['OpenVibe.Inventory', 'https://inventory.openvibe.network'],++ services: ['OpenVibe.Services', 'https://openvibe.services'], events: ['OpenVibe.Events', 'https://openvibe.events'], bot: ['OpenVibe.Bot', 'https://openvibe.bot'],++ actor: ['OpenVibe.Actor', 'https://openvibe.actor'], watch: ['OpenVibe.Watch', 'https://openvibe.watch'], codes: ['OpenVibe.Codes', 'https://openvibe.codes'],++ space: ['OpenVibe.Space', 'https://openvibe.space'], run: ['OpenVibe.Run', 'https://openvibe.run'], zone: ['OpenVibe.Zone', 'https://openvibe.zone'],++ food: ['OpenVibe.Food', 'https://openvibe.food'], download: ['OpenVibe.Download', 'https://openvibe.download'], mediahub: ['OpenVibe.Video', 'https://openvibe.video'],++ openre: ['OpenRestream', 'https://openre.stream'], play: ['OpenVibe Play', 'https://play.openvibe.network'], billing: ['OpenVibe.Billing', null],++ app: ['Developer apps', null],++};++// Plain words for the public event types people meet most; any other type is shown by its name alone.++const TYPE_WORDS = {++ 'live.stream.started': 'A channel went live on OpenVibe.Live', 'live.stream.ended': 'A stream ended on OpenVibe.Live',++ 'blog.post.published': 'A post was published on OpenVibe.Blog', 'wiki.page.published': 'A page was published on OpenVibe.Wiki',++ 'chat.message.created': 'A message in a public chat room', 'community.paste.created': 'A paste was shared on OpenVibe.Community',++ 'community.paste.updated': 'A paste was edited on OpenVibe.Community', 'community.thread.created': 'A new forum thread on OpenVibe.Community',++ 'coupons.coupon.created': 'A coupon was added on OpenVibe.Coupons', 'deals.offer.created': 'A deal was added on OpenVibe.Deals',++ 'deals.offer.updated': 'A deal changed on OpenVibe.Deals', 'games.world.published': 'A creator world was published on OpenVibe.Games',++ 'services.app.published': 'A developer app was published', 'reviews.signal.added': 'A review signal was added on OpenVibe.Reviews',++ 'trade.observation.created': 'A market observation on OpenVibe.Trade',++};++++function ago(ms, now) {++ const s = Math.max(0, Math.round((now - ms) / 1000));++ if (s < 60) return 'just now';++ if (s < 3600) return `${Math.round(s / 60)} min ago`;++ if (s < 86400) return `${Math.round(s / 3600)} h ago`;++ const d = Math.round(s / 86400);++ return `${d} day${d === 1 ? '' : 's'} ago`;++}++const serviceName = (ns) => (SERVICES[ns] || [ns])[0];++const serviceLink = (ns) => { const [name, url] = SERVICES[ns] || [ns, null]; return url ? `<a href="${esc(url)}">${esc(name)}</a>` : esc(name); };++++/** The numbers, who published and the public types (each left out when empty). */++function activity(stats) {++ if (!stats) return '';++ const now = stats.measuredAt || Date.now();++ const tiles = [++ [stats.day, 'events in the last 24 hours'], [stats.week, 'events this week'], [stats.services.length, 'services published this week'],++ [stats.types, 'event types this week'], [stats.subscriptions, `active subscriptions${stats.consumers ? ` from ${fmt(stats.consumers)} consumers` : ''}`],++ [stats.delivered, 'deliveries made in the last 30 days'],++ ].filter(([n]) => n > 0);++ const statRow = tiles.length ? `<section class="sc-sec hm-stats-sec" aria-label="The bus right now"><ul class="hm-stats">${tiles.map(([n, label]) => `<li><b>${esc(fmt(n))}</b><span>${esc(label)}</span></li>`).join('')}</ul>${stats.dead === 0 && stats.delivered ? '<p class="hm-note">No deliveries are waiting in the dead-letter queue.</p>' : ''}</section>` : '';++ const top = stats.services.slice(0, 16);++ const max = top.length ? top[0].n : 1;++ const who = top.length ? `<section class="sc-sec" aria-labelledby="h-who"><h2 id="h-who">Who published this week</h2>++<p class="sc-lede">Events by the service that published them over the last seven days. Every one was stored before any subscriber saw it.</p>++<ul class="hm-bars">${top.map((r) => `<li><span class="hm-bar-name">${serviceLink(r.ns)}</span><span class="hm-bar" aria-hidden="true"><i style="width:${Math.max(2, Math.round((r.n / max) * 100))}%"></i></span><span class="hm-bar-n">${esc(fmt(r.n))}</span></li>`).join('')}</ul>++${stats.services.length > top.length ? `<p class="hm-note">And ${esc(stats.services.length - top.length)} more.</p>` : ''}</section>` : '';++ const types = stats.public.length ? `<section class="sc-sec" aria-labelledby="h-types"><h2 id="h-types">Public events this week</h2>++<p class="sc-lede">The event types anyone may read, with how many arrived in the last seven days. Subscribe to a pattern such as live.stream.* to get them as signed webhooks.</p>++<div class="sc-table-wrap" tabindex="0" role="region" aria-labelledby="h-types"><table class="sc-table hm-table"><thead><tr><th scope="col">Event type</th><th scope="col">What happened</th><th scope="col">This week</th><th scope="col">Last one</th></tr></thead><tbody>++${stats.public.map((r) => `<tr><th scope="row"><code>${esc(r.type)}</code></th><td>${esc(TYPE_WORDS[r.type] || `From ${serviceName(r.type.split('.')[0])}`)}</td><td>${esc(fmt(r.n))}</td><td>${esc(ago(r.last, now))}</td></tr>`).join('')}++</tbody></table></div></section>` : '';++ return { statRow, who, types };++}++++/** Questions people ask first, answered from the README and the code. */++const FAQ = [++ ['What is OpenVibe.Events?', 'The event bus of the OpenVibe network. A service publishes an event once; Events stores it, then delivers it to every subscriber as a signed webhook, keeps it for pulls with a cursor, and streams public ones to browsers.'],++ ['Who can publish?', 'OpenVibe services with the events.event.publish capability, and developer apps under their own project\'s names (app.<project_key>.*) with an app token from OpenVibe.Network.'],++ ['How do I know a webhook really came from Events?', 'Every delivery is signed over its timestamp and raw body (X-OpenVibe-Signature-V2). Check it with verifyDeliveryV2 from openvibe-sdk/events and refuse anything more than five minutes old.'],++ ['What if my endpoint is down?', 'The delivery is retried with backoff. One that keeps failing waits in the dead-letter queue, where it can be replayed, and a new subscription can catch up from a point in history.'],++ ['How long are events kept?', 'Thirty days in the hot store for delivery, pulls and replay, then a year in the replay tier for pulls and stream resumes.'],++ ['Can a browser listen?', 'Yes. Server-sent events at /realtime/stream, authorized per topic with a one-use ticket from OpenVibe.Network, and resumable after a reconnect.'],++ ['Are sandbox and production separate?', 'Yes. A developer app\'s sandbox events never reach production apps or subscriptions, and are pruned after seven days.'],++];++++function sections({ apiUrl, stats = null }) {++ const live = activity(stats) || { statRow: '', who: '', types: '' };const publish = [`curl -X POST ${apiUrl}/api/v1/events \\`,' -H "Authorization: Bearer $TOKEN" -H \'Content-Type: application/json\' \\',@@@ -56,14 +138,15 @@ function sections({ apiUrl }) {`curl "${apiUrl}/api/v1/events?topic=app.<project_key>.*&limit=100" -H "Authorization: Bearer $TOKEN"`,'# then the same with &after=<next_cursor> from the previous page',].join('\n');++ const publishers = stats && stats.services.filter((r) => r.ns !== 'app').length;return showcase.hero({eyebrow: 'OpenVibe.Events · alpha',title: 'Events your services', accent: 'can count on.',lede: 'The event backbone of the OpenVibe network. Publish an event once and every subscriber gets it: stored durably, signed, '-- + 'retried with backoff, and replayable after a failure. Live, Media, Chat, Games, Network and OpenRestream publish to it in production.',++ + `retried with backoff, and replayable after a failure.${publishers ? ` ${publishers} OpenVibe services published to it this week.` : ''}`,actions: [{ label: 'Read the guide', href: `${SOURCE}#readme`, primary: true }, { label: 'See an example', href: '#examples' }],note: `Alpha. The API is at ${apiUrl}; developer apps sign in through OpenVibe.Network.`,-- }) + showcase.features({++ }) + live.statRow + live.who + live.types + showcase.features({title: 'What you get',items: [{ icon: 'ov:upload', title: 'Publish once', text: 'One event or up to a hundred at a time, stored atomically and numbered in order. Sending the same event id twice never stores it twice.' },@@@ -90,13 +173,34 @@ function sections({ apiUrl }) {{ title: 'Answer quickly', text: 'Reply 2xx once the event is safely stored; anything else is retried, then dead-lettered.' },{ title: 'Expect repeats', text: 'A retry can bring the same event again: keep its event id and handle it once.' },],-- });++ }) + `<section class="sc-sec" id="faq" aria-labelledby="h-faq"><h2 id="h-faq">Questions</h2>++<div class="hm-faq">${FAQ.map(([q, a]) => `<div class="hm-qa"><h3>${esc(q)}</h3><p>${esc(a)}</p></div>`).join('')}</div></section>`;}// The page's own few rules (it has no stylesheet beyond the showcase's): no browser margin around the body, so the Frame's// footer meets the edges; each curl sample on its own full-width row (three columns cut every command off); the four// webhook steps two by two instead of three and one alone, each title level with its number.--const HOME_STYLE = '<style>body{margin:0}.page .sc-code{grid-template-columns:minmax(0,1fr)}.page .sc-steps{grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr))}.page .sc-steps li{align-items:start;align-content:start}.page .sc-steps b{line-height:30px}</style>';++// The data sections' rules follow: the stat tiles, the per-service bars, the public types table and the questions, in theme tokens (accent-light is drawn for dark themes, so text in it is mixed toward the text colour).++const HOME_STYLE = '<style>body{margin:0}.page .sc-code{grid-template-columns:minmax(0,1fr)}.page .sc-steps{grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr))}.page .sc-steps li{align-items:start;align-content:start}.page .sc-steps b{line-height:30px}'++ + '.page{--hm-accent-text:color-mix(in srgb,var(--accent-light,#60a5fa) 45%,var(--text-primary,#e6edf7))}.sc-eyebrow{color:var(--hm-accent-text)}.sc-table thead th{color:var(--text-secondary,#96a7c2)}'++ + '.hm-stats-sec{margin-top:40px}.hm-stats{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(min(130px,100%),1fr))}'++ + '.hm-stats li{display:grid;gap:2px;padding:14px 16px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}'++ + '.hm-stats b{font-size:28px;line-height:1.1;letter-spacing:-.02em}.hm-stats span{font-size:13.5px;color:var(--text-secondary,#96a7c2)}'++ + '.hm-note{margin:10px 0 0;font-size:14px;color:var(--text-secondary,#96a7c2)}'++ + '.hm-bars{list-style:none;margin:0;padding:0;display:grid;gap:8px 28px;grid-template-columns:repeat(auto-fill,minmax(min(100%,460px),1fr))}'++ + '.hm-bars li{display:grid;grid-template-columns:minmax(0,11em) minmax(0,1fr) auto;align-items:center;gap:12px;font-size:14.5px}'++ + '.hm-bar-name{overflow-wrap:anywhere}.hm-bar-name a{color:var(--hm-accent-text);text-decoration:none}.hm-bar-name a:hover{text-decoration:underline}.hm-bar{height:10px;border-radius:999px;background:var(--bg-tertiary,#172238);overflow:hidden}'++ + '.hm-bar i{display:block;height:100%;border-radius:999px;background:linear-gradient(90deg,var(--accent,#3b82f6),var(--accent-light,#60a5fa))}.hm-bar-n{font-variant-numeric:tabular-nums;color:var(--text-secondary,#96a7c2);text-align:right;min-width:4.5em}'++ + '.hm-table code{font-size:13px;overflow-wrap:anywhere}.hm-table td:nth-child(3),.hm-table td:nth-child(4){white-space:nowrap;font-variant-numeric:tabular-nums}'++ + '.hm-faq{display:grid;gap:12px 28px;grid-template-columns:repeat(auto-fill,minmax(min(380px,100%),1fr))}.hm-qa{padding:4px 0 14px;border-bottom:1px solid var(--border,rgba(255,255,255,.08))}'++ + '.hm-qa h3{margin:0 0 6px;font-size:16px}.hm-qa p{margin:0;font-size:14.5px;line-height:1.6;color:var(--text-secondary,#96a7c2)}'++ + '@media (max-width:520px){.page .sc-code pre{white-space:pre-wrap;overflow-wrap:anywhere}.hm-bars li{grid-template-columns:minmax(0,10.5em) minmax(0,1fr) auto;font-size:13.5px;gap:8px}.hm-bar-n{min-width:3.5em}'++ // On a phone each public type is a small card: the type across the top, what happened below it, the count and the last one on the right.++ + '.hm-table thead{display:none}.hm-table,.hm-table tbody{display:block}.hm-table tr{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 12px;padding:10px 14px;border-top:1px solid var(--border,rgba(255,255,255,.08))}'++ + '.hm-table th,.hm-table td{display:block;padding:0;border:0}.hm-table tbody th{grid-column:1/-1;grid-row:1;width:auto}.hm-table td:nth-child(2){grid-column:1;grid-row:2/span 2;font-size:13.5px;color:var(--text-secondary,#96a7c2)}'++ + '.hm-table td:nth-child(3){grid-column:2;grid-row:2;text-align:right}.hm-table td:nth-child(4){grid-column:2;grid-row:3;text-align:right;font-size:13px;color:var(--text-secondary,#96a7c2)}}'++ + ''++ + '@media (max-width:420px){.sc-hero,.sc-sec,.sc-cta{padding-left:16px;padding-right:16px}}</style>';const FOOTER = { service: 'events', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };@@@ -121,7 +225,7 @@ ${frame.updatesBody({ service: 'events', siteName: SITE_NAME })}});}--function renderHome({ siteUrl, apiUrl }) {++function renderHome({ siteUrl, apiUrl, stats = null }) {const nav = {service: 'events',apiBase: NETWORK_URL,@@@ -137,6 +241,11 @@ function renderHome({ siteUrl, apiUrl }) {summary: DESCRIPTION,canonical: `${siteUrl}/`,robots: 'index, follow',++ jsonLd: [++ seo.jsonLd.website({ name: SITE_NAME, url: siteUrl, description: DESCRIPTION }),++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: `${siteUrl}/`, description: DESCRIPTION, category: 'DeveloperApplication', keywords: ['event bus', 'webhooks', 'server-sent events', 'replay', 'dead-letter queue'] }),++ seo.jsonLd.faq(FAQ.map(([q, a]) => ({ q, a }))),++ ],navbar: nav, footer, home: '/', navLinks: [{ label: 'Guide', href: `${SOURCE}#readme` }],head: [appIcon.headTags({ site: 'events' }),@@@ -145,7 +254,7 @@ function renderHome({ siteUrl, apiUrl }) {].join('\n'),body: `<div id="navbar-mount"></div><main id="main" class="page">--${sections({ apiUrl })}++${sections({ apiUrl, stats })}</main>`,});}nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++// The home page's live sections (server/home-stats.js, server/home.js): counts and names read from Events' own tables,++// the public types with when each last arrived, never a payload, internal types never named, the sections left out on an++// empty bus, and a FAQPage that holds exactly the questions the page shows.++const assert = require('assert');++const { boot, request, serviceToken, envelope, suite } = require('./helpers');++const { createHomeStats } = require('../server/home-stats');++++const t = suite('home-stats');++let h;++const html = async () => (await request(h.base, 'GET', '/', { headers: { Accept: 'text/html' } })).text;++const section = (page, id) => (new RegExp(`<section class="sc-sec"[^>]*aria-labelledby="${id}"[\\s\\S]*?</section>`).exec(page) || [''])[0];++++t('boot', async () => { h = await boot(); });++++t('an empty bus: the live sections are left out, the rest of the page stays', async () => {++ const page = await html();++ assert.ok(!page.includes('aria-labelledby="h-who"'));++ assert.ok(!page.includes('aria-labelledby="h-types"'));++ assert.ok(!page.includes('class="hm-stats"'), 'no tiles of zeros');++ assert.match(page, /Events your services/);++ assert.match(page, /id="h-faq">Questions</);++});++++t('published events show as counts by service, public types and the latest public events, never a payload', async () => {++ const live = serviceToken('live', ['events.event.publish']);++ const blog = serviceToken('blog', ['events.event.publish']);++ const post = (token, body) => request(h.base, 'POST', '/api/v1/events', { token, body });++ for (const ev of [++ envelope('live', { event_type: 'live.stream.started', visibility: 'public', payload: { title: 'SECRET-TITLE' } }),++ envelope('live', { event_type: 'live.stream.started', visibility: 'public' }),++ envelope('live', { event_type: 'live.internal.tick', visibility: 'internal' }),++ ]) assert.ok([200, 201, 202].includes((await post(live, ev)).status));++ assert.ok([200, 201, 202].includes((await post(blog, envelope('blog', { event_type: 'blog.post.published', visibility: 'public', subject: { type: 'post', id: 'p1', revision: 1 } }))).status));++++ // A fresh reader (the app's own keeps its answer five minutes).++ const stats = await createHomeStats({ store: h.store, log: { warn() {} } }).read();++ assert.strictEqual(stats.week, 4);++ assert.deepStrictEqual(stats.services, [{ ns: 'live', n: 3 }, { ns: 'blog', n: 1 }]);++ assert.deepStrictEqual(stats.public.map((r) => [r.type, r.n]), [['live.stream.started', 2], ['blog.post.published', 1]]);++ assert.strictEqual(JSON.stringify(stats).includes('SECRET-TITLE'), false, 'no payload leaves the reader');++ assert.strictEqual(JSON.stringify(stats.public).includes('live.internal.tick'), false, 'an internal type is never named');++++ const { renderHome } = require('../server/home');++ const page = renderHome({ siteUrl: 'https://openvibe.events', apiUrl: 'https://openvibe.events', stats });++ assert.match(section(page, 'h-who'), /<a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a>/);++ assert.match(section(page, 'h-types'), /<code>live\.stream\.started<\/code><\/th><td>A channel went live on OpenVibe\.Live<\/td><td>2<\/td>/);++ assert.match(section(page, 'h-types'), /<code>blog\.post\.published<\/code><\/th><td>A post was published on OpenVibe\.Blog<\/td><td>1<\/td><td>just now<\/td>/);++ assert.ok(!page.includes('SECRET-TITLE'));++ assert.ok(!page.includes('live.internal.tick'));++ assert.match(page, /<b>4<\/b><span>events this week<\/span>/);++ assert.match(page, / 2 OpenVibe services published to it this week\./);++});++++t('JSON-LD: WebSite, the app, and a FAQPage with exactly the questions shown', async () => {++ const page = await html();++ const ld = [...page.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++ for (const type of ['WebSite', 'WebApplication', 'FAQPage']) assert.ok(ld.some((x) => x['@type'] === type), type);++ const faq = (/<section class="sc-sec" id="faq"[\s\S]*?<\/section>/.exec(page) || [''])[0];++ const shown = [...faq.matchAll(/<h3>([^<]+)<\/h3>/g)].map((m) => m[1].replace(/'/g, "'"));++ assert.ok(shown.length >= 6);++ assert.deepStrictEqual(ld.find((x) => x['@type'] === 'FAQPage').mainEntity.map((q) => q.name), shown);++ const description = /<meta name="description" content="([^"]*)"/.exec(page)[1];++ assert.ok(description.length <= 160, `description ${description.length} chars`);++});++++t('stop', async () => { await h.stop(); });++++t.run();@@@ -161,7 +161,7 @@ t('llms.txt is a plain-text map that starts with the site name', async () => {assert.strictEqual(r.status, 200);assert.match(r.headers['content-type'], /^text\/plain/);assert.ok(r.body.startsWith('# OpenVibe.Events'), r.body.slice(0, 40));-- assert.ok(r.body.includes('> Durable events for the OpenVibe network'), 'the home summary');++ assert.ok(r.body.includes("> The OpenVibe network's event bus"), 'the home summary');assert.ok(r.body.includes('https://github.com/OpenVibers/OpenVibe.Events#readme'), 'the guide');assert.ok(r.body.includes(`The API and the realtime gateway are at ${SITE}`), 'the discovery names the API origin');});@@@ -171,7 +171,7 @@ t('llms-full.txt is the same map in full text, starting with the site name', asyassert.strictEqual(r.status, 200);assert.match(r.headers['content-type'], /^text\/plain/);assert.ok(r.body.startsWith('# OpenVibe.Events'), r.body.slice(0, 40));-- assert.ok(r.body.includes('> Durable events for the OpenVibe network'), 'the home summary');++ assert.ok(r.body.includes("> The OpenVibe network's event bus"), 'the home summary');assert.ok(r.body.includes('## Start here'), 'the /llms.txt sections as pages');assert.ok(r.body.includes('### Guide'), 'each page with its full text');});
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §6 and §6.3.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §6 and §6.3.
**License:** AGPL-3.0 (same as every OpenVibe service).
**License:** AGPL-3.0 (same as every OpenVibe service).
-The home page sitemap entry uses the release commit time as lastmod.
+The home page sitemap entry uses the release commit time as lastmod. The home page (`server/home.js`) shows the bus at work from this service's own tables (`server/home-stats.js`, kept five minutes): events in the last 24 hours and this week, how many services and event types published this week, active subscriptions and their consumers, deliveries made in the 30-day hot window and an empty dead-letter queue when it is empty; who published this week (events by the first segment of their type); and the public event types (visibility `public`) with their counts and when each last arrived. Only counts and names leave the reader, never a payload, an actor, a subject id or a principal; a section with nothing to show is left out. Its questions are a FAQPage in its JSON-LD.
## Purpose
## Purpose
@@ -16,6 +16,7 @@ const { resourceControlRouter } = require('./api/resource-control');
@@ -16,6 +16,7 @@ const { resourceControlRouter } = require('./api/resource-control');
const { mountLimits } = require('./limits');
const { mountLimits } = require('./limits');
const { createLimits } = require('./actor-limits');
const { createLimits } = require('./actor-limits');
const { renderHome, renderUpdates, renderNotFound, HOME_CSP } = require('./home');
const { renderHome, renderUpdates, renderNotFound, HOME_CSP } = require('./home');
+const { createHomeStats } = require('./home-stats');
const appIcon = require('openvibe-shared/app-icon');
const appIcon = require('openvibe-shared/app-icon');
const { createDiscoveryRoutes } = require('./discovery');
const { createDiscoveryRoutes } = require('./discovery');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
@@ -117,14 +118,19 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
@@ -117,14 +118,19 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
.send(renderUpdates({ siteUrl: config.siteUrl }));
.send(renderUpdates({ siteUrl: config.siteUrl }));
});
});
- app.get('/', (req, res) => {
+ // The home's live sections: counts and names from this service's own tables, kept five minutes (server/home-stats.js).
+ const homeStats = createHomeStats({ store, now: () => (clock ? clock.now() : Date.now()), log });
+ app.get('/', async (req, res, next) => {
res.vary('Accept');
res.vary('Accept');
if (req.accepts(['text/plain', 'text/html']) === 'text/html') {
if (req.accepts(['text/plain', 'text/html']) === 'text/html') {
- return res.type('html')
- .set('Content-Security-Policy', HOME_CSP)
- // private: Cloudflare caches by URL and ignores Vary: Accept, so a shared copy could reach an API client.
- .set('Cache-Control', 'private, max-age=300')
- .send(renderHome({ siteUrl: config.siteUrl, apiUrl: config.baseUrl }));
+ try {
+ const stats = await homeStats.read();
+ return res.type('html')
+ .set('Content-Security-Policy', HOME_CSP)
+ // private: Cloudflare caches by URL and ignores Vary: Accept, so a shared copy could reach an API client.
+ .set('Cache-Control', 'private, max-age=300')
+ .send(renderHome({ siteUrl: config.siteUrl, apiUrl: config.baseUrl, stats }));
+ } catch (e) { return next(e); }
}
}
res.type('text/plain').send([
res.type('text/plain').send([
'OpenVibe.Events: durable events, subscriptions, signed delivery, dead letters and replay.',
'OpenVibe.Events: durable events, subscriptions, signed delivery, dead letters and replay.',
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+
+/**
+ * What the home page shows of the bus at work, read from this service's own tables and kept a few minutes:
+ *
+ * createHomeStats({ store, now, ttlMs, log }) → { read() }
+ * read() → { day, week, types, services: [{ ns, n }], public: [{ type, n, last }],
+ * subscriptions, consumers, delivered, dead } | null
+ *
+ * Only counts and names leave this module: never a payload, an actor, a subject id or a publisher's principal. The
+ * per-type list is the events whose visibility is `public` (the Contracts registry's own word); the
+ * per-service counts are every event, by the first segment of its type. read() never throws: a failed read keeps the
+ * last answer (null before the first), so the page leaves the sections out instead of failing.
+ */
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+function createHomeStats({ store, now = () => Date.now(), ttlMs = 5 * 60 * 1000, waitMs = 1500, log = console } = {}) {
+ let cached = null;
+ let at = 0;
+ let pending = null;
+ const db = store && store.db;
+
+ async function load() {
+ const t = now();
+ const day = t - DAY_MS;
+ const week = t - 7 * DAY_MS;
+ const [dayRow, byNs, typeRow, pub, subs, counts] = await Promise.all([
+ db.prepare('SELECT COUNT(*) AS n FROM events WHERE received_at > ?').get(day),
+ db.prepare("SELECT split_part(event_type, '.', 1) AS ns, COUNT(*) AS n FROM events WHERE received_at > ? GROUP BY 1 ORDER BY 2 DESC, 1").all(week),
+ db.prepare('SELECT COUNT(DISTINCT event_type) AS n FROM events WHERE received_at > ?').get(week),
+ db.prepare("SELECT event_type, COUNT(*) AS n, MAX(received_at) AS last FROM events WHERE received_at > ? AND visibility = 'public' GROUP BY 1 ORDER BY 2 DESC, 1").all(week),
+ db.prepare('SELECT COUNT(*) AS n, COUNT(DISTINCT consumer) AS c FROM subscriptions WHERE enabled = 1').get(),
+ store.deliveryCounts(),
+ ]);
+ const services = byNs.map((r) => ({ ns: String(r.ns), n: Number(r.n) })).filter((r) => r.ns && r.n > 0);
+ return {
+ day: Number(dayRow.n),
+ week: services.reduce((sum, r) => sum + r.n, 0),
+ types: Number(typeRow.n),
+ services,
+ public: pub.map((r) => ({ type: String(r.event_type), n: Number(r.n), last: Number(r.last) })),
+ subscriptions: Number(subs.n),
+ consumers: Number(subs.c),
+ delivered: Number(counts.delivered || 0),
+ dead: Number(counts.dead || 0),
+ measuredAt: t,
+ };
+ }
+
+ function refresh() {
+ if (!pending) {
+ pending = load()
+ .then((v) => { cached = v; at = now(); return v; })
+ .catch((e) => { log.warn(`[events] home stats: ${e.message}`); at = now() - ttlMs + 60 * 1000; return cached; })
+ .finally(() => { pending = null; });
+ }
+ return pending;
+ }
+
+ async function read() {
+ if (!db) return null;
+ // Fresh, or a failure less than a minute ago (at is moved back so the next try waits a minute, not a request).
+ if (at && now() - at < ttlMs) return cached;
+ const p = refresh();
+ if (cached) return cached;
+ return Promise.race([p, new Promise((resolve) => setTimeout(() => resolve(null), waitMs).unref())]);
+ }
+
+ return { read };
+}
+
+module.exports = { createHomeStats };
@@ -4,9 +4,12 @@
@@ -4,9 +4,12 @@
* The product home of OpenVibe.Events (openvibe.events, plan T7: the API origin is the product origin too).
* The product home of OpenVibe.Events (openvibe.events, plan T7: the API origin is the product origin too).
* The OpenVibe Frame (openvibe-shared/shell) around openvibe-shared/showcase sections
* The OpenVibe Frame (openvibe-shared/shell) around openvibe-shared/showcase sections
* for developers. Every claim restates the README (Publishing, Subscriptions and delivery, Developer apps,
* for developers. Every claim restates the README (Publishing, Subscriptions and delivery, Developer apps,
- * Realtime) and the code; nothing here reads data.
+ * Realtime) and the code. The bus at work comes from server/home-stats.js (counts and names only, never a payload):
+ * the numbers, who published this week and the public event types with when each last arrived; each section is left
+ * out when there is nothing to show.
*
*
- * renderHome({ siteUrl, apiUrl }) the whole document (siteUrl: the canonical product origin; apiUrl: the API origin)
+ * renderHome({ siteUrl, apiUrl, stats }) the whole document (siteUrl: the canonical product origin; apiUrl: the API
+ * origin; stats: home-stats read(), or null)
* HOME_CSP its Content-Security-Policy (the Frame's scripts and calls to the Network)
* HOME_CSP its Content-Security-Policy (the Frame's scripts and calls to the Network)
*/
*/
const shell = require('openvibe-shared/shell');
const shell = require('openvibe-shared/shell');
@@ -14,12 +17,13 @@ const frame = require('openvibe-shared/frame');
@@ -14,12 +17,13 @@ const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const appIcon = require('openvibe-shared/app-icon');
const appIcon = require('openvibe-shared/app-icon');
+const seo = require('openvibe-shared/seo');
const SITE_NAME = 'OpenVibe.Events';
const SITE_NAME = 'OpenVibe.Events';
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
const SOURCE = 'https://github.com/OpenVibers/OpenVibe.Events';
const SOURCE = 'https://github.com/OpenVibers/OpenVibe.Events';
-const DESCRIPTION = 'Durable events for the OpenVibe network: publish once, and every subscriber gets it signed, retried and replayable. '
- + 'Webhooks, pull with a cursor, dead letters, replay and realtime streams for browsers.';
+const DESCRIPTION = 'The OpenVibe network\'s event bus: publish once and every subscriber gets it signed, retried and replayable, '
+ + 'by webhook, pull or realtime stream.';
const HOME_CSP = [
const HOME_CSP = [
"default-src 'self'",
"default-src 'self'",
@@ -37,7 +41,85 @@ const HOME_CSP = [
@@ -37,7 +41,85 @@ const HOME_CSP = [
"form-action 'self' https://openvibe.network",
"form-action 'self' https://openvibe.network",
].join('; ');
].join('; ');
-function sections({ apiUrl }) {
+const esc = showcase.esc;
+const fmt = (n) => Number(n).toLocaleString('en-US');
+// Where each publishing service lives (the first segment of its event types). One not listed is shown by name only.
+const SERVICES = {
+ live: ['OpenVibe.Live', 'https://openvibe.live'], search: ['OpenVibe.Search', 'https://search.openvibe.network'], sources: ['OpenVibe.Sources', null],
+ reviews: ['OpenVibe.Reviews', 'https://openvibe.reviews'], ai: ['OpenVibe AI', 'https://ai.openvibe.services'], work: ['OpenVibe.Work', 'https://openvibe.work'],
+ trade: ['OpenVibe.Trade', 'https://openvibe.trade'], media: ['OpenVibe.Media', 'https://openvibe.media'], deals: ['OpenVibe.Deals', 'https://openvibe.deals'],
+ community: ['OpenVibe.Community', 'https://openvibe.community'], help: ['OpenVibe.Help', 'https://openvibe.help'], chat: ['OpenVibe.Chat', 'https://openvibe.chat'],
+ rent: ['OpenVibe.Rent', 'https://openvibe.rent'], host: ['OpenVibe.Host', 'https://openvibe.host'], network: ['OpenVibe.Network', 'https://openvibe.network'],
+ news: ['OpenVibe.News', 'https://openvibe.news'], coupons: ['OpenVibe.Coupons', 'https://openvibe.coupons'], games: ['OpenVibe.Games', 'https://openvibe.games'],
+ tools: ['OpenVibe.Tools', 'https://openvibe.tools'], wiki: ['OpenVibe.Wiki', 'https://openvibe.wiki'], blog: ['OpenVibe.Blog', 'https://openvibe.blog'],
+ quest: ['OpenVibe.Quest', 'https://openvibe.quest'], inventory: ['OpenVibe.Inventory', 'https://inventory.openvibe.network'],
+ services: ['OpenVibe.Services', 'https://openvibe.services'], events: ['OpenVibe.Events', 'https://openvibe.events'], bot: ['OpenVibe.Bot', 'https://openvibe.bot'],
+ actor: ['OpenVibe.Actor', 'https://openvibe.actor'], watch: ['OpenVibe.Watch', 'https://openvibe.watch'], codes: ['OpenVibe.Codes', 'https://openvibe.codes'],
+ space: ['OpenVibe.Space', 'https://openvibe.space'], run: ['OpenVibe.Run', 'https://openvibe.run'], zone: ['OpenVibe.Zone', 'https://openvibe.zone'],
+ food: ['OpenVibe.Food', 'https://openvibe.food'], download: ['OpenVibe.Download', 'https://openvibe.download'], mediahub: ['OpenVibe.Video', 'https://openvibe.video'],
+ openre: ['OpenRestream', 'https://openre.stream'], play: ['OpenVibe Play', 'https://play.openvibe.network'], billing: ['OpenVibe.Billing', null],
+ app: ['Developer apps', null],
+};
+// Plain words for the public event types people meet most; any other type is shown by its name alone.
+const TYPE_WORDS = {
+ 'live.stream.started': 'A channel went live on OpenVibe.Live', 'live.stream.ended': 'A stream ended on OpenVibe.Live',
+ 'blog.post.published': 'A post was published on OpenVibe.Blog', 'wiki.page.published': 'A page was published on OpenVibe.Wiki',
+ 'chat.message.created': 'A message in a public chat room', 'community.paste.created': 'A paste was shared on OpenVibe.Community',
+ 'community.paste.updated': 'A paste was edited on OpenVibe.Community', 'community.thread.created': 'A new forum thread on OpenVibe.Community',
+ 'coupons.coupon.created': 'A coupon was added on OpenVibe.Coupons', 'deals.offer.created': 'A deal was added on OpenVibe.Deals',
+ 'deals.offer.updated': 'A deal changed on OpenVibe.Deals', 'games.world.published': 'A creator world was published on OpenVibe.Games',
+ 'services.app.published': 'A developer app was published', 'reviews.signal.added': 'A review signal was added on OpenVibe.Reviews',
+ 'trade.observation.created': 'A market observation on OpenVibe.Trade',
+};
+
+function ago(ms, now) {
+ const s = Math.max(0, Math.round((now - ms) / 1000));
+ if (s < 60) return 'just now';
+ if (s < 3600) return `${Math.round(s / 60)} min ago`;
+ if (s < 86400) return `${Math.round(s / 3600)} h ago`;
+ const d = Math.round(s / 86400);
+ return `${d} day${d === 1 ? '' : 's'} ago`;
+}
+const serviceName = (ns) => (SERVICES[ns] || [ns])[0];
+const serviceLink = (ns) => { const [name, url] = SERVICES[ns] || [ns, null]; return url ? `<a href="${esc(url)}">${esc(name)}</a>` : esc(name); };
+
+/** The numbers, who published and the public types (each left out when empty). */
+function activity(stats) {
+ if (!stats) return '';
+ const now = stats.measuredAt || Date.now();
+ const tiles = [
+ [stats.day, 'events in the last 24 hours'], [stats.week, 'events this week'], [stats.services.length, 'services published this week'],
+ [stats.types, 'event types this week'], [stats.subscriptions, `active subscriptions${stats.consumers ? ` from ${fmt(stats.consumers)} consumers` : ''}`],
+ [stats.delivered, 'deliveries made in the last 30 days'],
+ ].filter(([n]) => n > 0);
+ const statRow = tiles.length ? `<section class="sc-sec hm-stats-sec" aria-label="The bus right now"><ul class="hm-stats">${tiles.map(([n, label]) => `<li><b>${esc(fmt(n))}</b><span>${esc(label)}</span></li>`).join('')}</ul>${stats.dead === 0 && stats.delivered ? '<p class="hm-note">No deliveries are waiting in the dead-letter queue.</p>' : ''}</section>` : '';
+ const top = stats.services.slice(0, 16);
+ const max = top.length ? top[0].n : 1;
+ const who = top.length ? `<section class="sc-sec" aria-labelledby="h-who"><h2 id="h-who">Who published this week</h2>
+<p class="sc-lede">Events by the service that published them over the last seven days. Every one was stored before any subscriber saw it.</p>
+<ul class="hm-bars">${top.map((r) => `<li><span class="hm-bar-name">${serviceLink(r.ns)}</span><span class="hm-bar" aria-hidden="true"><i style="width:${Math.max(2, Math.round((r.n / max) * 100))}%"></i></span><span class="hm-bar-n">${esc(fmt(r.n))}</span></li>`).join('')}</ul>
+${stats.services.length > top.length ? `<p class="hm-note">And ${esc(stats.services.length - top.length)} more.</p>` : ''}</section>` : '';
+ const types = stats.public.length ? `<section class="sc-sec" aria-labelledby="h-types"><h2 id="h-types">Public events this week</h2>
+<p class="sc-lede">The event types anyone may read, with how many arrived in the last seven days. Subscribe to a pattern such as live.stream.* to get them as signed webhooks.</p>
+<div class="sc-table-wrap" tabindex="0" role="region" aria-labelledby="h-types"><table class="sc-table hm-table"><thead><tr><th scope="col">Event type</th><th scope="col">What happened</th><th scope="col">This week</th><th scope="col">Last one</th></tr></thead><tbody>
+${stats.public.map((r) => `<tr><th scope="row"><code>${esc(r.type)}</code></th><td>${esc(TYPE_WORDS[r.type] || `From ${serviceName(r.type.split('.')[0])}`)}</td><td>${esc(fmt(r.n))}</td><td>${esc(ago(r.last, now))}</td></tr>`).join('')}
+</tbody></table></div></section>` : '';
+ return { statRow, who, types };
+}
+
+/** Questions people ask first, answered from the README and the code. */
+const FAQ = [
+ ['What is OpenVibe.Events?', 'The event bus of the OpenVibe network. A service publishes an event once; Events stores it, then delivers it to every subscriber as a signed webhook, keeps it for pulls with a cursor, and streams public ones to browsers.'],
+ ['Who can publish?', 'OpenVibe services with the events.event.publish capability, and developer apps under their own project\'s names (app.<project_key>.*) with an app token from OpenVibe.Network.'],
+ ['How do I know a webhook really came from Events?', 'Every delivery is signed over its timestamp and raw body (X-OpenVibe-Signature-V2). Check it with verifyDeliveryV2 from openvibe-sdk/events and refuse anything more than five minutes old.'],
+ ['What if my endpoint is down?', 'The delivery is retried with backoff. One that keeps failing waits in the dead-letter queue, where it can be replayed, and a new subscription can catch up from a point in history.'],
+ ['How long are events kept?', 'Thirty days in the hot store for delivery, pulls and replay, then a year in the replay tier for pulls and stream resumes.'],
+ ['Can a browser listen?', 'Yes. Server-sent events at /realtime/stream, authorized per topic with a one-use ticket from OpenVibe.Network, and resumable after a reconnect.'],
+ ['Are sandbox and production separate?', 'Yes. A developer app\'s sandbox events never reach production apps or subscriptions, and are pruned after seven days.'],
+];
+
+function sections({ apiUrl, stats = null }) {
+ const live = activity(stats) || { statRow: '', who: '', types: '' };
const publish = [
const publish = [
`curl -X POST ${apiUrl}/api/v1/events \\`,
`curl -X POST ${apiUrl}/api/v1/events \\`,
' -H "Authorization: Bearer $TOKEN" -H \'Content-Type: application/json\' \\',
' -H "Authorization: Bearer $TOKEN" -H \'Content-Type: application/json\' \\',
@@ -56,14 +138,15 @@ function sections({ apiUrl }) {
@@ -56,14 +138,15 @@ function sections({ apiUrl }) {
`curl "${apiUrl}/api/v1/events?topic=app.<project_key>.*&limit=100" -H "Authorization: Bearer $TOKEN"`,
`curl "${apiUrl}/api/v1/events?topic=app.<project_key>.*&limit=100" -H "Authorization: Bearer $TOKEN"`,
'# then the same with &after=<next_cursor> from the previous page',
'# then the same with &after=<next_cursor> from the previous page',
].join('\n');
].join('\n');
+ const publishers = stats && stats.services.filter((r) => r.ns !== 'app').length;
return showcase.hero({
return showcase.hero({
eyebrow: 'OpenVibe.Events · alpha',
eyebrow: 'OpenVibe.Events · alpha',
title: 'Events your services', accent: 'can count on.',
title: 'Events your services', accent: 'can count on.',
lede: 'The event backbone of the OpenVibe network. Publish an event once and every subscriber gets it: stored durably, signed, '
lede: 'The event backbone of the OpenVibe network. Publish an event once and every subscriber gets it: stored durably, signed, '
- + 'retried with backoff, and replayable after a failure. Live, Media, Chat, Games, Network and OpenRestream publish to it in production.',
+ + `retried with backoff, and replayable after a failure.${publishers ? ` ${publishers} OpenVibe services published to it this week.` : ''}`,
actions: [{ label: 'Read the guide', href: `${SOURCE}#readme`, primary: true }, { label: 'See an example', href: '#examples' }],
actions: [{ label: 'Read the guide', href: `${SOURCE}#readme`, primary: true }, { label: 'See an example', href: '#examples' }],
note: `Alpha. The API is at ${apiUrl}; developer apps sign in through OpenVibe.Network.`,
note: `Alpha. The API is at ${apiUrl}; developer apps sign in through OpenVibe.Network.`,
- }) + showcase.features({
+ }) + live.statRow + live.who + live.types + showcase.features({
title: 'What you get',
title: 'What you get',
items: [
items: [
{ icon: 'ov:upload', title: 'Publish once', text: 'One event or up to a hundred at a time, stored atomically and numbered in order. Sending the same event id twice never stores it twice.' },
{ icon: 'ov:upload', title: 'Publish once', text: 'One event or up to a hundred at a time, stored atomically and numbered in order. Sending the same event id twice never stores it twice.' },
@@ -90,13 +173,34 @@ function sections({ apiUrl }) {
@@ -90,13 +173,34 @@ function sections({ apiUrl }) {
{ title: 'Answer quickly', text: 'Reply 2xx once the event is safely stored; anything else is retried, then dead-lettered.' },
{ title: 'Answer quickly', text: 'Reply 2xx once the event is safely stored; anything else is retried, then dead-lettered.' },
{ title: 'Expect repeats', text: 'A retry can bring the same event again: keep its event id and handle it once.' },
{ title: 'Expect repeats', text: 'A retry can bring the same event again: keep its event id and handle it once.' },
],
],
- });
+ }) + `<section class="sc-sec" id="faq" aria-labelledby="h-faq"><h2 id="h-faq">Questions</h2>
+<div class="hm-faq">${FAQ.map(([q, a]) => `<div class="hm-qa"><h3>${esc(q)}</h3><p>${esc(a)}</p></div>`).join('')}</div></section>`;
}
}
// The page's own few rules (it has no stylesheet beyond the showcase's): no browser margin around the body, so the Frame's
// The page's own few rules (it has no stylesheet beyond the showcase's): no browser margin around the body, so the Frame's
// footer meets the edges; each curl sample on its own full-width row (three columns cut every command off); the four
// footer meets the edges; each curl sample on its own full-width row (three columns cut every command off); the four
// webhook steps two by two instead of three and one alone, each title level with its number.
// webhook steps two by two instead of three and one alone, each title level with its number.
-const HOME_STYLE = '<style>body{margin:0}.page .sc-code{grid-template-columns:minmax(0,1fr)}.page .sc-steps{grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr))}.page .sc-steps li{align-items:start;align-content:start}.page .sc-steps b{line-height:30px}</style>';
+// The data sections' rules follow: the stat tiles, the per-service bars, the public types table and the questions, in theme tokens (accent-light is drawn for dark themes, so text in it is mixed toward the text colour).
+const HOME_STYLE = '<style>body{margin:0}.page .sc-code{grid-template-columns:minmax(0,1fr)}.page .sc-steps{grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr))}.page .sc-steps li{align-items:start;align-content:start}.page .sc-steps b{line-height:30px}'
+ + '.page{--hm-accent-text:color-mix(in srgb,var(--accent-light,#60a5fa) 45%,var(--text-primary,#e6edf7))}.sc-eyebrow{color:var(--hm-accent-text)}.sc-table thead th{color:var(--text-secondary,#96a7c2)}'
+ + '.hm-stats-sec{margin-top:40px}.hm-stats{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(min(130px,100%),1fr))}'
+ + '.hm-stats li{display:grid;gap:2px;padding:14px 16px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}'
+ + '.hm-stats b{font-size:28px;line-height:1.1;letter-spacing:-.02em}.hm-stats span{font-size:13.5px;color:var(--text-secondary,#96a7c2)}'
+ + '.hm-note{margin:10px 0 0;font-size:14px;color:var(--text-secondary,#96a7c2)}'
+ + '.hm-bars{list-style:none;margin:0;padding:0;display:grid;gap:8px 28px;grid-template-columns:repeat(auto-fill,minmax(min(100%,460px),1fr))}'
+ + '.hm-bars li{display:grid;grid-template-columns:minmax(0,11em) minmax(0,1fr) auto;align-items:center;gap:12px;font-size:14.5px}'
+ + '.hm-bar-name{overflow-wrap:anywhere}.hm-bar-name a{color:var(--hm-accent-text);text-decoration:none}.hm-bar-name a:hover{text-decoration:underline}.hm-bar{height:10px;border-radius:999px;background:var(--bg-tertiary,#172238);overflow:hidden}'
+ + '.hm-bar i{display:block;height:100%;border-radius:999px;background:linear-gradient(90deg,var(--accent,#3b82f6),var(--accent-light,#60a5fa))}.hm-bar-n{font-variant-numeric:tabular-nums;color:var(--text-secondary,#96a7c2);text-align:right;min-width:4.5em}'
+ + '.hm-table code{font-size:13px;overflow-wrap:anywhere}.hm-table td:nth-child(3),.hm-table td:nth-child(4){white-space:nowrap;font-variant-numeric:tabular-nums}'
+ + '.hm-faq{display:grid;gap:12px 28px;grid-template-columns:repeat(auto-fill,minmax(min(380px,100%),1fr))}.hm-qa{padding:4px 0 14px;border-bottom:1px solid var(--border,rgba(255,255,255,.08))}'
+ + '.hm-qa h3{margin:0 0 6px;font-size:16px}.hm-qa p{margin:0;font-size:14.5px;line-height:1.6;color:var(--text-secondary,#96a7c2)}'
+ + '@media (max-width:520px){.page .sc-code pre{white-space:pre-wrap;overflow-wrap:anywhere}.hm-bars li{grid-template-columns:minmax(0,10.5em) minmax(0,1fr) auto;font-size:13.5px;gap:8px}.hm-bar-n{min-width:3.5em}'
+ // On a phone each public type is a small card: the type across the top, what happened below it, the count and the last one on the right.
+ + '.hm-table thead{display:none}.hm-table,.hm-table tbody{display:block}.hm-table tr{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 12px;padding:10px 14px;border-top:1px solid var(--border,rgba(255,255,255,.08))}'
+ + '.hm-table th,.hm-table td{display:block;padding:0;border:0}.hm-table tbody th{grid-column:1/-1;grid-row:1;width:auto}.hm-table td:nth-child(2){grid-column:1;grid-row:2/span 2;font-size:13.5px;color:var(--text-secondary,#96a7c2)}'
+ + '.hm-table td:nth-child(3){grid-column:2;grid-row:2;text-align:right}.hm-table td:nth-child(4){grid-column:2;grid-row:3;text-align:right;font-size:13px;color:var(--text-secondary,#96a7c2)}}'
+ + ''
+ + '@media (max-width:420px){.sc-hero,.sc-sec,.sc-cta{padding-left:16px;padding-right:16px}}</style>';
const FOOTER = { service: 'events', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
const FOOTER = { service: 'events', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
@@ -121,7 +225,7 @@ ${frame.updatesBody({ service: 'events', siteName: SITE_NAME })}
@@ -121,7 +225,7 @@ ${frame.updatesBody({ service: 'events', siteName: SITE_NAME })}
});
});
}
}
-function renderHome({ siteUrl, apiUrl }) {
+function renderHome({ siteUrl, apiUrl, stats = null }) {
const nav = {
const nav = {
service: 'events',
service: 'events',
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
@@ -137,6 +241,11 @@ function renderHome({ siteUrl, apiUrl }) {
@@ -137,6 +241,11 @@ function renderHome({ siteUrl, apiUrl }) {
summary: DESCRIPTION,
summary: DESCRIPTION,
canonical: `${siteUrl}/`,
canonical: `${siteUrl}/`,
robots: 'index, follow',
robots: 'index, follow',
+ jsonLd: [
+ seo.jsonLd.website({ name: SITE_NAME, url: siteUrl, description: DESCRIPTION }),
+ seo.jsonLd.softwareApp({ name: SITE_NAME, url: `${siteUrl}/`, description: DESCRIPTION, category: 'DeveloperApplication', keywords: ['event bus', 'webhooks', 'server-sent events', 'replay', 'dead-letter queue'] }),
+ seo.jsonLd.faq(FAQ.map(([q, a]) => ({ q, a }))),
+ ],
navbar: nav, footer, home: '/', navLinks: [{ label: 'Guide', href: `${SOURCE}#readme` }],
navbar: nav, footer, home: '/', navLinks: [{ label: 'Guide', href: `${SOURCE}#readme` }],
head: [
head: [
appIcon.headTags({ site: 'events' }),
appIcon.headTags({ site: 'events' }),
@@ -145,7 +254,7 @@ function renderHome({ siteUrl, apiUrl }) {
@@ -145,7 +254,7 @@ function renderHome({ siteUrl, apiUrl }) {
].join('\n'),
].join('\n'),
body: `<div id="navbar-mount"></div>
body: `<div id="navbar-mount"></div>
<main id="main" class="page">
<main id="main" class="page">
-${sections({ apiUrl })}
+${sections({ apiUrl, stats })}
</main>`,
</main>`,
});
});
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+// The home page's live sections (server/home-stats.js, server/home.js): counts and names read from Events' own tables,
+// the public types with when each last arrived, never a payload, internal types never named, the sections left out on an
+// empty bus, and a FAQPage that holds exactly the questions the page shows.
+const assert = require('assert');
+const { boot, request, serviceToken, envelope, suite } = require('./helpers');
+const { createHomeStats } = require('../server/home-stats');
+
+const t = suite('home-stats');
+let h;
+const html = async () => (await request(h.base, 'GET', '/', { headers: { Accept: 'text/html' } })).text;
+const section = (page, id) => (new RegExp(`<section class="sc-sec"[^>]*aria-labelledby="${id}"[\\s\\S]*?</section>`).exec(page) || [''])[0];
+
+t('boot', async () => { h = await boot(); });
+
+t('an empty bus: the live sections are left out, the rest of the page stays', async () => {
+ const page = await html();
+ assert.ok(!page.includes('aria-labelledby="h-who"'));
+ assert.ok(!page.includes('aria-labelledby="h-types"'));
+ assert.ok(!page.includes('class="hm-stats"'), 'no tiles of zeros');
+ assert.match(page, /Events your services/);
+ assert.match(page, /id="h-faq">Questions</);
+});
+
+t('published events show as counts by service, public types and the latest public events, never a payload', async () => {
+ const live = serviceToken('live', ['events.event.publish']);
+ const blog = serviceToken('blog', ['events.event.publish']);
+ const post = (token, body) => request(h.base, 'POST', '/api/v1/events', { token, body });
+ for (const ev of [
+ envelope('live', { event_type: 'live.stream.started', visibility: 'public', payload: { title: 'SECRET-TITLE' } }),
+ envelope('live', { event_type: 'live.stream.started', visibility: 'public' }),
+ envelope('live', { event_type: 'live.internal.tick', visibility: 'internal' }),
+ ]) assert.ok([200, 201, 202].includes((await post(live, ev)).status));
+ assert.ok([200, 201, 202].includes((await post(blog, envelope('blog', { event_type: 'blog.post.published', visibility: 'public', subject: { type: 'post', id: 'p1', revision: 1 } }))).status));
+
+ // A fresh reader (the app's own keeps its answer five minutes).
+ const stats = await createHomeStats({ store: h.store, log: { warn() {} } }).read();
+ assert.strictEqual(stats.week, 4);
+ assert.deepStrictEqual(stats.services, [{ ns: 'live', n: 3 }, { ns: 'blog', n: 1 }]);
+ assert.deepStrictEqual(stats.public.map((r) => [r.type, r.n]), [['live.stream.started', 2], ['blog.post.published', 1]]);
+ assert.strictEqual(JSON.stringify(stats).includes('SECRET-TITLE'), false, 'no payload leaves the reader');
+ assert.strictEqual(JSON.stringify(stats.public).includes('live.internal.tick'), false, 'an internal type is never named');
+
+ const { renderHome } = require('../server/home');
+ const page = renderHome({ siteUrl: 'https://openvibe.events', apiUrl: 'https://openvibe.events', stats });
+ assert.match(section(page, 'h-who'), /<a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a>/);
+ assert.match(section(page, 'h-types'), /<code>live\.stream\.started<\/code><\/th><td>A channel went live on OpenVibe\.Live<\/td><td>2<\/td>/);
+ assert.match(section(page, 'h-types'), /<code>blog\.post\.published<\/code><\/th><td>A post was published on OpenVibe\.Blog<\/td><td>1<\/td><td>just now<\/td>/);
+ assert.ok(!page.includes('SECRET-TITLE'));
+ assert.ok(!page.includes('live.internal.tick'));
+ assert.match(page, /<b>4<\/b><span>events this week<\/span>/);
+ assert.match(page, / 2 OpenVibe services published to it this week\./);
+});
+
+t('JSON-LD: WebSite, the app, and a FAQPage with exactly the questions shown', async () => {
+ const page = await html();
+ const ld = [...page.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+ for (const type of ['WebSite', 'WebApplication', 'FAQPage']) assert.ok(ld.some((x) => x['@type'] === type), type);
+ const faq = (/<section class="sc-sec" id="faq"[\s\S]*?<\/section>/.exec(page) || [''])[0];
+ const shown = [...faq.matchAll(/<h3>([^<]+)<\/h3>/g)].map((m) => m[1].replace(/'/g, "'"));
+ assert.ok(shown.length >= 6);
+ assert.deepStrictEqual(ld.find((x) => x['@type'] === 'FAQPage').mainEntity.map((q) => q.name), shown);
+ const description = /<meta name="description" content="([^"]*)"/.exec(page)[1];
+ assert.ok(description.length <= 160, `description ${description.length} chars`);
+});
+
+t('stop', async () => { await h.stop(); });
+
+t.run();
@@ -161,7 +161,7 @@ t('llms.txt is a plain-text map that starts with the site name', async () => {
@@ -161,7 +161,7 @@ t('llms.txt is a plain-text map that starts with the site name', async () => {
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.headers['content-type'], /^text\/plain/);
assert.match(r.headers['content-type'], /^text\/plain/);
assert.ok(r.body.startsWith('# OpenVibe.Events'), r.body.slice(0, 40));
assert.ok(r.body.startsWith('# OpenVibe.Events'), r.body.slice(0, 40));
- assert.ok(r.body.includes('> Durable events for the OpenVibe network'), 'the home summary');
+ assert.ok(r.body.includes("> The OpenVibe network's event bus"), 'the home summary');
assert.ok(r.body.includes('https://github.com/OpenVibers/OpenVibe.Events#readme'), 'the guide');
assert.ok(r.body.includes('https://github.com/OpenVibers/OpenVibe.Events#readme'), 'the guide');
assert.ok(r.body.includes(`The API and the realtime gateway are at ${SITE}`), 'the discovery names the API origin');
assert.ok(r.body.includes(`The API and the realtime gateway are at ${SITE}`), 'the discovery names the API origin');
});
});
@@ -171,7 +171,7 @@ t('llms-full.txt is the same map in full text, starting with the site name', asy
@@ -171,7 +171,7 @@ t('llms-full.txt is the same map in full text, starting with the site name', asy
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.headers['content-type'], /^text\/plain/);
assert.match(r.headers['content-type'], /^text\/plain/);
assert.ok(r.body.startsWith('# OpenVibe.Events'), r.body.slice(0, 40));
assert.ok(r.body.startsWith('# OpenVibe.Events'), r.body.slice(0, 40));
- assert.ok(r.body.includes('> Durable events for the OpenVibe network'), 'the home summary');
+ assert.ok(r.body.includes("> The OpenVibe network's event bus"), 'the home summary');
assert.ok(r.body.includes('## Start here'), 'the /llms.txt sections as pages');
assert.ok(r.body.includes('## Start here'), 'the /llms.txt sections as pages');
assert.ok(r.body.includes('### Guide'), 'each page with its full text');
assert.ok(r.body.includes('### Guide'), 'each page with its full text');
});
});