openvibers/openvibe.events Commits
SEO: serve the events home to crawlers and render the public event list (SEO-5) (#145)
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 (`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.++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, publishers and when each last arrived, rendered on the server as search-readable cards with a real `<time datetime>` and an `ItemList` of `Event` nodes in the JSON-LD. Only counts and names leave the reader, never a payload, an actor, a subject id or a principal. A section with nothing to say shows a clear empty state; if the five-minute stats read fails or exceeds its 1.5 s bound, the page still renders its shell and that empty state rather than waiting on the database. `GET /` is the HTML page by default (a browser, a crawler or a plain `curl`); the text/plain API index is kept for `Accept: text/plain` and `?format=text`. Its questions are a FAQPage in its JSON-LD.## Purpose@@@ -10,7 +10,8 @@## events.openvibe.network address was retired on 2026-10-10 (its last service caller stopped 09-30).#### The Node service (port 4300) answers here:--## / the product home (server/home.js) for a browser, the text/plain API index otherwise++## / the server-rendered product home (server/home.js) for browsers, crawlers and plain++## fetchers; the text/plain API index only for `Accept: text/plain` or `?format=text`## /shared/ the OpenVibe Frame's pinned browser files (openvibe-shared/serve)## /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt, /<INDEXNOW_KEY>.txt## discovery for the product domain (server/discovery.js; the IndexNow key file)@@@ -108,10 +108,11 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo// from INDEXNOW_KEY in server/index.js). It answers its own path and passes everything else on.if (indexnow && indexnow.enabled) app.use(indexnow.keyFile);-- // GET / on the product domain: the home page for a browser (server/home.js, openvibe.events), the-- // API index for curl and API clients (`*/*`, no Accept, or any non-HTML Accept). Vary: Accept so a-- // shared cache never serves one to the other. The home carries its own CSP (HOME_CSP); the only-- // global security header is X-Content-Type-Options above, kept on every route.++ // GET / on the product domain: the server-rendered home page (server/home.js, openvibe.events) for browsers,++ // crawlers and plain fetchers (`*/*`, no Accept, or any non-text Accept); the text/plain API index only when++ // the caller asks for it (`Accept: text/plain` or ?format=text). Vary: Accept so a shared cache never serves++ // one to the other. The home carries its own CSP (HOME_CSP); the only global security header is++ // X-Content-Type-Options above, kept on every route.// GET /updates: what shipped on this site (the shared update log), for browsers; the vhost passes GET through.app.get('/updates', (req, res) => {res.type('html').set('Content-Security-Policy', HOME_CSP).set('Cache-Control', 'public, max-age=300')@@@ -122,7 +123,8 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLoconst 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') {++ const wantsText = req.query.format === 'text' || req.accepts(['text/html', 'text/plain']) === 'text/plain';++ if (!wantsText) {try {const stats = await homeStats.read();return res.type('html')@@@ -132,7 +134,8 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo.send(renderHome({ siteUrl: config.siteUrl, apiUrl: config.baseUrl, stats }));} catch (e) { return next(e); }}-- res.type('text/plain').send([++ // The explicit text variant is never shared-cached either (Cloudflare ignores Vary): HTML is the cached page.++ res.type('text/plain').set('Cache-Control', 'private, no-store').send(['OpenVibe.Events: durable events, subscriptions, signed delivery, dead letters and replay.','','POST /api/v1/events publish (service token, events.event.publish; app token, events.app.publish)',@@@ -5,8 +5,9 @@* 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. 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.++ * the numbers, who published this week and the public event types with when each last arrived. Count and publisher++ * sections are left out when empty; the public event list always renders, with a clear empty state when the store++ * has no public events or does not answer within its 1.5 s bound.** renderHome({ siteUrl, apiUrl, stats }) the whole document (siteUrl: the canonical product origin; apiUrl: the API* origin; stats: home-stats read(), or null)@@@ -72,21 +73,110 @@ const TYPE_WORDS = {'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); };++const serviceUrl = (ns) => (SERVICES[ns] || [])[1] || null;++const nsOf = (type) => String(type).split('.')[0];++const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');++const eventTitle = (type) => TYPE_WORDS[type] || `An event of type ${type}`;++const eventDescription = (type) => `Published by ${serviceName(nsOf(type))} as ${type}. Every subscriber receives it as a signed webhook; any reader can pull it with a cursor.`;++const isoDate = (ms) => { const n = Number(ms); return Number.isFinite(n) && n > 0 ? new Date(n).toISOString() : null; };++const humanDate = (ms) => {++ const d = new Date(Number(ms));++ if (!Number.isFinite(d.getTime())) return '';++ return `${d.toLocaleString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'UTC', hour12: false })} UTC`;++};++++/** One public event type as a search-readable card: title, real arrival time, place and publisher, description, count. */++function eventCard(r) {++ const type = String(r.type);++ const ns = nsOf(type);++ const iso = isoDate(r.last);++ const url = serviceUrl(ns);++ const category = url ? `<a href="${esc(url)}">${esc(ns)}</a>` : esc(ns);++ const when = iso ? `<time datetime="${iso}">${esc(humanDate(r.last))}</time>` : '<span class="hm-event-undated">date not recorded</span>';++ return `<li class="hm-event" id="event-${esc(slug(type))}">++<p class="hm-event-cat">Category: ${category}</p>++<h3 class="hm-event-title">${esc(eventTitle(type))}</h3>++<p class="hm-event-meta">${when} · Online, from ${serviceLink(ns)} · ${esc(fmt(r.n))} this week</p>++<p class="hm-event-desc">${esc(eventDescription(type))}</p>++<p class="hm-event-type"><code>${esc(type)}</code></p>++</li>`;++}++++/** The category links above the list: one per publishing service, to its site (plain text when it has none). */++function categoryLinks(events) {++ const seen = [];++ for (const r of events) { const ns = nsOf(r.type); if (!seen.includes(ns)) seen.push(ns); }++ return `<nav class="hm-cats" aria-label="Event categories">${seen.map((ns) => {++ const url = serviceUrl(ns);++ return url ? `<a href="${esc(url)}">${esc(serviceName(ns))}</a>` : `<span>${esc(serviceName(ns))}</span>`;++ }).join('')}</nav>`;++}++++/**++ * The public event types anyone may read, newest arrival first. The first page (24 types) is rendered here, on the++ * server, so a crawler reads it without running any JavaScript; there is no client-side list to duplicate it. Each++ * card carries a real <time datetime> from the last arrival the store recorded, never a guessed date. A slow or failed++ * stats read leaves a clear empty state instead of an empty page.++ */++function eventsSection(stats) {++ const publicTypes = (stats && stats.public) || [];++ const list = publicTypes.slice().sort((a, b) => Number(b.last) - Number(a.last)).slice(0, 24);++ let inner;++ if (!stats) {++ inner = '<p class="sc-lede">The live bus figures are unavailable right now, so this page is showing the product shell. The API, webhooks and the realtime stream described below keep running.</p>';++ } else if (!list.length) {++ inner = '<p class="sc-lede">No public events have arrived this week. The bus is running; public event types appear here as they arrive.</p>';++ } else {++ inner = `<p class="sc-lede">The public event types anyone may read, newest arrival first: when each last arrived, who published it and what it means. Subscribe to a pattern such as <code>live.stream.*</code> to get them as signed webhooks.</p>++${categoryLinks(list)}++<ul class="hm-events">${list.map(eventCard).join('')}</ul>++${publicTypes.length > list.length ? `<p class="hm-note">And ${esc(fmt(publicTypes.length - list.length))} more public event types this week.</p>` : ''}`;++ }++ return `<section class="sc-sec" id="events" aria-labelledby="h-events"><h2 id="h-events">Public events this week</h2>${inner}</section>`;++}--/** The numbers, who published and the public types (each left out when empty). */++/** The ItemList of public events, each with a real startDate only where the store recorded an arrival. */++function eventsJsonLd({ siteUrl, stats }) {++ const publicTypes = (stats && stats.public) || [];++ const list = publicTypes.slice().sort((a, b) => Number(b.last) - Number(a.last)).slice(0, 24);++ if (!list.length) return null;++ return {++ '@context': 'https://schema.org',++ '@type': 'ItemList',++ name: 'Public events on the OpenVibe.Events bus this week',++ url: `${siteUrl}/#events`,++ numberOfItems: list.length,++ itemListElement: list.map((r, i) => {++ const type = String(r.type);++ const ns = nsOf(type);++ const iso = isoDate(r.last);++ const url = serviceUrl(ns);++ return {++ '@type': 'ListItem',++ position: i + 1,++ item: {++ '@type': 'Event',++ name: eventTitle(type),++ description: eventDescription(type),++ url: `${siteUrl}/#event-${slug(type)}`,++ // The last recorded arrival of this public event type: real data, never an invented schedule.++ ...(iso ? { startDate: iso } : {}),++ eventAttendanceMode: 'https://schema.org/OnlineEventAttendanceMode',++ location: { '@type': 'VirtualLocation', url: url || siteUrl },++ organizer: { '@type': 'Organization', name: serviceName(ns), ...(url ? { url } : {}) },++ isAccessibleForFree: true,++ },++ };++ }),++ };++}++++/** The numbers, who published and the public events (the events section always renders, with its empty state). */function activity(stats) {-- if (!stats) return '';-- const now = stats.measuredAt || Date.now();++ const events = eventsSection(stats);++ if (!stats) return { statRow: '', who: '', events };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` : ''}`],@@@ -99,12 +189,7 @@ function activity(stats) {<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 };++ return { statRow, who, events };}/** Questions people ask first, answered from the README and the code. */@@@ -119,7 +204,7 @@ const FAQ = [];function sections({ apiUrl, stats = null }) {-- const live = activity(stats) || { statRow: '', who: '', types: '' };++ const live = activity(stats);const publish = [`curl -X POST ${apiUrl}/api/v1/events \\`,' -H "Authorization: Bearer $TOKEN" -H \'Content-Type: application/json\' \\',@@@ -146,7 +231,7 @@ function sections({ apiUrl, stats = null }) {+ `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.`,-- }) + '<section class="sc-sec sc-part-of"><p class="sc-note">Part of <a href="https://openvibe.services/">OpenVibe Services</a>: the building blocks every OpenVibe product runs on, with one console, one account and one SDK.</p></section>' + live.statRow + live.who + live.types + showcase.features({++ }) + '<section class="sc-sec sc-part-of"><p class="sc-note">Part of <a href="https://openvibe.services/">OpenVibe Services</a>: the building blocks every OpenVibe product runs on, with one console, one account and one SDK.</p></section>' + live.statRow + live.events + live.who + 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.' },@@@ -180,9 +265,9 @@ function sections({ apiUrl, stats = null }) {// 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.--// 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).++// The data sections' rules follow: the stat tiles, the per-service bars, the public event cards 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)}'++ + '.page{--hm-accent-text:color-mix(in srgb,var(--accent-light,#60a5fa) 45%,var(--text-primary,#e6edf7))}.sc-eyebrow{color:var(--hm-accent-text)}'+ '.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)}'@@@ -191,15 +276,18 @@ const HOME_STYLE = '<style>body{margin:0}.page .sc-code{grid-template-columns:mi+ '.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-cats{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0 20px}.hm-cats a,.hm-cats span{display:inline-block;padding:4px 11px;border:1px solid var(--border,rgba(255,255,255,.08));border-radius:999px;font-size:13px;color:var(--hm-accent-text);text-decoration:none}'++ + '.hm-cats span{color:var(--text-secondary,#96a7c2)}.hm-cats a:hover{text-decoration:underline}'++ + '.hm-events{list-style:none;margin:0;padding:0;display:grid;gap:12px 28px;grid-template-columns:repeat(auto-fill,minmax(min(100%,440px),1fr))}'++ + '.hm-event{padding:4px 0 16px;border-bottom:1px solid var(--border,rgba(255,255,255,.08))}.hm-event-cat{margin:0;font-size:12.5px;letter-spacing:.04em;text-transform:uppercase;color:var(--text-secondary,#96a7c2)}'++ + '.hm-event-cat a,.hm-event-meta a{color:var(--hm-accent-text);text-decoration:none}.hm-event-cat a:hover,.hm-event-meta a:hover{text-decoration:underline}'++ + '.hm-event-title{margin:4px 0 6px;font-size:17px}.hm-event-meta{margin:0 0 6px;font-size:13.5px;line-height:1.5;color:var(--text-secondary,#96a7c2)}'++ + '.hm-event-desc{margin:0 0 6px;font-size:14.5px;line-height:1.6;color:var(--text-secondary,#96a7c2)}.hm-event-type{margin:0}.hm-event-type code{font-size:12.5px;overflow-wrap:anywhere;color:var(--text-secondary,#96a7c2)}'+ '.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)}}'-- + ''++ // On a phone each public event is a full-width card: the category, the title, then the date, place and count.++ + '.hm-events{grid-template-columns:minmax(0,1fr)}.hm-event-title{font-size:16px}}'+ '@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' };@@@ -245,7 +333,8 @@ function renderHome({ siteUrl, apiUrl, stats = null }) {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 }))),-- ],++ eventsJsonLd({ siteUrl, stats }),++ ].filter(Boolean),navbar: nav, footer, home: '/', navLinks: [{ label: 'Guide', href: `${SOURCE}#readme` }],head: [appIcon.headTags({ site: 'events' }),@@@ -1,7 +1,7 @@'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.++// the public events with when each last arrived, never a payload, internal types never named, a clear empty state on++// an empty or failed 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');@@@ -13,11 +13,11 @@ const section = (page, id) => (new RegExp(`<section class="sc-sec"[^>]*aria-labet('boot', async () => { h = await boot(); });--t('an empty bus: the live sections are left out, the rest of the page stays', async () => {++t('an empty bus: the counts are left out, the public event list says so, 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, /id="h-events">Public events this week<\/h2><p class="sc-lede">No public events have arrived this week\./);assert.match(page, /Events your services/);assert.match(page, /id="h-faq">Questions</);});@@@ -43,13 +43,24 @@ t('published events show as counts by service, public types and the latest publiconst { renderHome } = require('../server/home');const page = renderHome({ siteUrl: 'https://openvibe.events', apiUrl: 'https://openvibe.events', stats });++ const events = section(page, 'h-events');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.match(events, /A channel went live on OpenVibe\.Live/);++ assert.match(events, /<time datetime="\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z">/);++ assert.match(events, /Online, from <a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a> · 2 this week/);++ assert.match(events, /<code>live\.stream\.started<\/code>/);++ assert.match(events, /A post was published on OpenVibe\.Blog/);++ assert.match(events, /<code>blog\.post\.published<\/code>/);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\./);++ const ld = [...page.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++ const list = ld.find((x) => x['@type'] === 'ItemList');++ assert.ok(list, 'the public events are an ItemList');++ assert.strictEqual(list.numberOfItems, 2);++ assert.ok(list.itemListElement.every((x) => x.item['@type'] === 'Event' && x.item.startDate));++ assert.ok(!JSON.stringify(list).includes('SECRET-TITLE'));});t('JSON-LD: WebSite, the app, and a FAQPage with exactly the questions shown', async () => {@@@ -64,6 +75,19 @@ t('JSON-LD: WebSite, the app, and a FAQPage with exactly the questions shown', aassert.ok(description.length <= 160, `description ${description.length} chars`);});++t('a stalled store resolves to no stats within the 1.5 s bound, so the page falls back to its empty state', async () => {++ const pending = () => new Promise(() => {});++ const stalled = {++ db: { prepare: () => ({ get: pending, all: pending }) },++ deliveryCounts: pending,++ };++ const started = Date.now();++ const stats = await createHomeStats({ store: stalled, log: { warn() {} } }).read();++ const elapsed = Date.now() - started;++ assert.strictEqual(stats, null);++ assert.ok(elapsed >= 1400 && elapsed < 2500, `resolved in ${elapsed} ms`);++});++t('stop', async () => { await h.stop(); });t.run();@@@ -1,8 +1,9 @@'use strict';--// The product home (plan T7: openvibe.events is the API and product origin): it renders server/home.js--// for a browser and keeps the text/plain API index for curl and API clients, serves the pinned OpenVibe--// Frame at /shared, answers robots.txt, sitemap.xml, llms.txt and llms-full.txt, and names its own origin everywhere.--// A parse-only check of the nginx vhost too.++// The product home (plan T7: openvibe.events is the API and product origin): it renders server/home.js for++// every caller by default — browser, crawler, or a plain curl with no Accept — with the public event list,++// one h1, a canonical and JSON-LD; the text/plain API index is kept for an explicit text/plain (or ?format=text).++// It also serves the pinned OpenVibe Frame at /shared, answers robots.txt, sitemap.xml, llms.txt and++// llms-full.txt, and names its own origin everywhere. A parse-only check of the nginx vhost too.const assert = require('assert');function assertSitemapLastmods(xml, tag = 'url') {@@@ -15,16 +16,20 @@ function assertSitemapLastmods(xml, tag = 'url') {return entries;}++/** The readable text of a page: scripts, styles and tags removed (what a crawler or an AI reader gets). */++const readable = (html) => html.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ')++ .replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();++const fs = require('fs');const path = require('path');const nodeHttp = require('http');const { boot, suite } = require('./helpers');const { load } = require('../server/config');--const { HOME_CSP } = require('../server/home');++const { HOME_CSP, renderHome } = require('../server/home');const SITE = 'https://openvibe.events';--// GET / without an Accept a browser would send: the API index, byte for byte, as it was before the home.++// GET / for a caller that explicitly asks for text: the API index, byte for byte, as it was before the home.const INDEX = ['OpenVibe.Events: durable events, subscriptions, signed delivery, dead letters and replay.','',@@@ -80,6 +85,23 @@ t('GET / as a browser: the home page, its CSP and its cache policy', async () =>assert.ok(r.body.includes(`The API is at ${SITE}`), 'the hero note names the API origin');assert.ok(r.body.includes(`${SITE}/api/v1/events`), 'the API examples use the API origin');assert.ok(!r.body.includes('events.openvibe.network'), 'the home names no other origin');++ assert.strictEqual((r.body.match(/<link rel="canonical"/g) || []).length, 1, 'one canonical');++ assert.match(r.body, /<html lang="en">/);++});++++t('GET / for a crawler or a plain curl: the server-rendered HTML, with real readable text', async () => {++ // `*/*` and no Accept at all are what curl, fetch() and many crawlers send. Both must get the page, not the++ // text API index: otherwise the HTML that reaches a crawler has no title, h1, canonical or event list.++ for (const headers of [{}, { Accept: '*/*' }, { Accept: 'application/json' }]) {++ const r = await raw(h.base, '/', headers);++ assert.strictEqual(r.status, 200, JSON.stringify(headers));++ assert.match(r.headers['content-type'], /^text\/html/, JSON.stringify(headers));++ assert.ok(readable(r.body).length > 2000, `${readable(r.body).length} readable chars for ${JSON.stringify(headers)}`);++ assert.strictEqual((r.body.match(/<h1\b/g) || []).length, 1, JSON.stringify(headers));++ assert.ok(r.body.includes(`<link rel="canonical" href="${SITE}/">`), JSON.stringify(headers));++ const ld = [...r.body.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++ assert.ok(ld.some((x) => x['@type'] === 'WebSite'), 'the WebSite node');++ }});t('the home CSP lets Cloudflare Web Analytics load and report (it injects its beacon on this zone)', () => {@@@ -117,17 +139,58 @@ t('the home links the pinned Frame stylesheet, and /shared serves it', async ()assert.ok(sheet.body.includes('.ov-'), 'the showcase stylesheet');});--t('GET / for curl and API clients: the text/plain index, byte for byte', async () => {-- // `*/*`, no Accept at all, and a JSON client: all three get the API index.-- for (const headers of [{ Accept: '*/*' }, {}, { Accept: 'application/json' }]) {-- const r = await raw(h.base, '/', headers);-- assert.strictEqual(r.status, 200, JSON.stringify(headers));-- assert.match(r.headers['content-type'], /^text\/plain/, JSON.stringify(headers));-- assert.strictEqual(r.headers.vary, 'Accept', JSON.stringify(headers));-- assert.strictEqual(r.body, INDEX, JSON.stringify(headers));++t('GET / for a caller that asks for text: the API index, byte for byte', async () => {++ // Only an explicit text/plain (header or `?format=text`) gets the API index now: the HTML page is the default.++ for (const [p, headers] of [['/', { Accept: 'text/plain' }], ['/?format=text', {}], ['/?format=text', { Accept: '*/*' }]]) {++ const r = await raw(h.base, p, headers);++ assert.strictEqual(r.status, 200, `${p} ${JSON.stringify(headers)}`);++ assert.match(r.headers['content-type'], /^text\/plain/, `${p} ${JSON.stringify(headers)}`);++ assert.strictEqual(r.headers.vary, 'Accept', `${p} ${JSON.stringify(headers)}`);++ assert.strictEqual(r.headers['cache-control'], 'private, no-store', `${p} ${JSON.stringify(headers)}`);++ assert.strictEqual(r.body, INDEX, `${p} ${JSON.stringify(headers)}`);}});++t('the server-rendered event list: title, real <time datetime>, place, description, category links and JSON-LD', () => {++ const last = Date.parse('2026-10-29T09:41:00Z');++ const stats = {++ day: 4, week: 4, types: 1, services: [{ ns: 'live', n: 3 }, { ns: 'blog', n: 1 }], subscriptions: 0, consumers: 0,++ delivered: 0, dead: 0, measuredAt: last,++ public: [++ { type: 'live.stream.started', n: 3, last },++ { type: 'blog.post.published', n: 1, last: 0 }, // no arrival recorded: no startDate may be invented++ ],++ };++ const page = renderHome({ siteUrl: SITE, apiUrl: SITE, stats });++ assert.ok(readable(page).length > 2000, `${readable(page).length} readable chars`);++ assert.strictEqual((page.match(/<h1\b/g) || []).length, 1);++ assert.strictEqual((page.match(/<link rel="canonical"/g) || []).length, 1);++ const card = /<li class="hm-event"[\s\S]*?<\/li>/.exec(page)[0];++ assert.match(card, /A channel went live on OpenVibe\.Live/, 'the event title');++ assert.match(card, /<time datetime="2026-10-29T09:41:00\.000Z">/, 'a real <time datetime>');++ assert.match(card, /Online, from <a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a>/, 'the place and publisher');++ assert.match(card, /Published by OpenVibe\.Live as live\.stream\.started/, 'the short description');++ assert.match(card, /3 this week/, 'the count');++ assert.match(card, /Category: <a href="https:\/\/openvibe\.live">live<\/a>/, 'a category link');++ assert.match(page, /<nav class="hm-cats"[^>]*><a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a>/, 'the category nav');++ assert.match(page, /<code>live\.stream\.started<\/code>/, 'the exact event type');++++ const ld = [...page.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++ assert.ok(ld.some((x) => x['@type'] === 'WebSite'), 'the WebSite node');++ const list = ld.find((x) => x['@type'] === 'ItemList');++ assert.ok(list, 'an ItemList of the events');++ assert.strictEqual(list.numberOfItems, 2);++ assert.strictEqual(list.itemListElement[0].item['@type'], 'Event');++ assert.strictEqual(list.itemListElement[0].item.startDate, '2026-10-29T09:41:00.000Z');++ assert.ok(!('startDate' in list.itemListElement[1].item), 'no date invented when none was recorded');++++ // A stats failure renders the shell, the h1, the description and a clear empty state — never a blank page.++ const shell = renderHome({ siteUrl: SITE, apiUrl: SITE, stats: null });++ assert.match(shell, /The live bus figures are unavailable right now/);++ assert.strictEqual((shell.match(/<h1\b/g) || []).length, 1);++ assert.ok(shell.includes(`<link rel="canonical" href="${SITE}/">`));++});++t('robots.txt welcomes the home and keeps the API out, and names the sitemap', async () => {const r = await raw(h.base, '/robots.txt');assert.strictEqual(r.status, 200);
@@ -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 (`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.
+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, publishers and when each last arrived, rendered on the server as search-readable cards with a real `<time datetime>` and an `ItemList` of `Event` nodes in the JSON-LD. Only counts and names leave the reader, never a payload, an actor, a subject id or a principal. A section with nothing to say shows a clear empty state; if the five-minute stats read fails or exceeds its 1.5 s bound, the page still renders its shell and that empty state rather than waiting on the database. `GET /` is the HTML page by default (a browser, a crawler or a plain `curl`); the text/plain API index is kept for `Accept: text/plain` and `?format=text`. Its questions are a FAQPage in its JSON-LD.
## Purpose
## Purpose
@@ -10,7 +10,8 @@
@@ -10,7 +10,8 @@
## events.openvibe.network address was retired on 2026-10-10 (its last service caller stopped 09-30).
## events.openvibe.network address was retired on 2026-10-10 (its last service caller stopped 09-30).
##
##
## The Node service (port 4300) answers here:
## The Node service (port 4300) answers here:
-## / the product home (server/home.js) for a browser, the text/plain API index otherwise
+## / the server-rendered product home (server/home.js) for browsers, crawlers and plain
+## fetchers; the text/plain API index only for `Accept: text/plain` or `?format=text`
## /shared/ the OpenVibe Frame's pinned browser files (openvibe-shared/serve)
## /shared/ the OpenVibe Frame's pinned browser files (openvibe-shared/serve)
## /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt, /<INDEXNOW_KEY>.txt
## /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt, /<INDEXNOW_KEY>.txt
## discovery for the product domain (server/discovery.js; the IndexNow key file)
## discovery for the product domain (server/discovery.js; the IndexNow key file)
@@ -108,10 +108,11 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
@@ -108,10 +108,11 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
// from INDEXNOW_KEY in server/index.js). It answers its own path and passes everything else on.
// from INDEXNOW_KEY in server/index.js). It answers its own path and passes everything else on.
if (indexnow && indexnow.enabled) app.use(indexnow.keyFile);
if (indexnow && indexnow.enabled) app.use(indexnow.keyFile);
- // GET / on the product domain: the home page for a browser (server/home.js, openvibe.events), the
- // API index for curl and API clients (`*/*`, no Accept, or any non-HTML Accept). Vary: Accept so a
- // shared cache never serves one to the other. The home carries its own CSP (HOME_CSP); the only
- // global security header is X-Content-Type-Options above, kept on every route.
+ // GET / on the product domain: the server-rendered home page (server/home.js, openvibe.events) for browsers,
+ // crawlers and plain fetchers (`*/*`, no Accept, or any non-text Accept); the text/plain API index only when
+ // the caller asks for it (`Accept: text/plain` or ?format=text). Vary: Accept so a shared cache never serves
+ // one to the other. The home carries its own CSP (HOME_CSP); the only global security header is
+ // X-Content-Type-Options above, kept on every route.
// GET /updates: what shipped on this site (the shared update log), for browsers; the vhost passes GET through.
// GET /updates: what shipped on this site (the shared update log), for browsers; the vhost passes GET through.
app.get('/updates', (req, res) => {
app.get('/updates', (req, res) => {
res.type('html').set('Content-Security-Policy', HOME_CSP).set('Cache-Control', 'public, max-age=300')
res.type('html').set('Content-Security-Policy', HOME_CSP).set('Cache-Control', 'public, max-age=300')
@@ -122,7 +123,8 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
@@ -122,7 +123,8 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
const homeStats = createHomeStats({ store, now: () => (clock ? clock.now() : Date.now()), log });
const homeStats = createHomeStats({ store, now: () => (clock ? clock.now() : Date.now()), log });
app.get('/', async (req, res, next) => {
app.get('/', async (req, res, next) => {
res.vary('Accept');
res.vary('Accept');
- if (req.accepts(['text/plain', 'text/html']) === 'text/html') {
+ const wantsText = req.query.format === 'text' || req.accepts(['text/html', 'text/plain']) === 'text/plain';
+ if (!wantsText) {
try {
try {
const stats = await homeStats.read();
const stats = await homeStats.read();
return res.type('html')
return res.type('html')
@@ -132,7 +134,8 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
@@ -132,7 +134,8 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
.send(renderHome({ siteUrl: config.siteUrl, apiUrl: config.baseUrl, stats }));
.send(renderHome({ siteUrl: config.siteUrl, apiUrl: config.baseUrl, stats }));
} catch (e) { return next(e); }
} catch (e) { return next(e); }
}
}
- res.type('text/plain').send([
+ // The explicit text variant is never shared-cached either (Cloudflare ignores Vary): HTML is the cached page.
+ res.type('text/plain').set('Cache-Control', 'private, no-store').send([
'OpenVibe.Events: durable events, subscriptions, signed delivery, dead letters and replay.',
'OpenVibe.Events: durable events, subscriptions, signed delivery, dead letters and replay.',
'',
'',
'POST /api/v1/events publish (service token, events.event.publish; app token, events.app.publish)',
'POST /api/v1/events publish (service token, events.event.publish; app token, events.app.publish)',
@@ -5,8 +5,9 @@
@@ -5,8 +5,9 @@
* 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. The bus at work comes from server/home-stats.js (counts and names only, never a payload):
* 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.
+ * the numbers, who published this week and the public event types with when each last arrived. Count and publisher
+ * sections are left out when empty; the public event list always renders, with a clear empty state when the store
+ * has no public events or does not answer within its 1.5 s bound.
*
*
* renderHome({ siteUrl, apiUrl, stats }) the whole document (siteUrl: the canonical product origin; apiUrl: the API
* renderHome({ siteUrl, apiUrl, stats }) the whole document (siteUrl: the canonical product origin; apiUrl: the API
* origin; stats: home-stats read(), or null)
* origin; stats: home-stats read(), or null)
@@ -72,21 +73,110 @@ const TYPE_WORDS = {
@@ -72,21 +73,110 @@ const TYPE_WORDS = {
'trade.observation.created': 'A market observation on OpenVibe.Trade',
'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 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); };
const serviceLink = (ns) => { const [name, url] = SERVICES[ns] || [ns, null]; return url ? `<a href="${esc(url)}">${esc(name)}</a>` : esc(name); };
+const serviceUrl = (ns) => (SERVICES[ns] || [])[1] || null;
+const nsOf = (type) => String(type).split('.')[0];
+const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
+const eventTitle = (type) => TYPE_WORDS[type] || `An event of type ${type}`;
+const eventDescription = (type) => `Published by ${serviceName(nsOf(type))} as ${type}. Every subscriber receives it as a signed webhook; any reader can pull it with a cursor.`;
+const isoDate = (ms) => { const n = Number(ms); return Number.isFinite(n) && n > 0 ? new Date(n).toISOString() : null; };
+const humanDate = (ms) => {
+ const d = new Date(Number(ms));
+ if (!Number.isFinite(d.getTime())) return '';
+ return `${d.toLocaleString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'UTC', hour12: false })} UTC`;
+};
+
+/** One public event type as a search-readable card: title, real arrival time, place and publisher, description, count. */
+function eventCard(r) {
+ const type = String(r.type);
+ const ns = nsOf(type);
+ const iso = isoDate(r.last);
+ const url = serviceUrl(ns);
+ const category = url ? `<a href="${esc(url)}">${esc(ns)}</a>` : esc(ns);
+ const when = iso ? `<time datetime="${iso}">${esc(humanDate(r.last))}</time>` : '<span class="hm-event-undated">date not recorded</span>';
+ return `<li class="hm-event" id="event-${esc(slug(type))}">
+<p class="hm-event-cat">Category: ${category}</p>
+<h3 class="hm-event-title">${esc(eventTitle(type))}</h3>
+<p class="hm-event-meta">${when} · Online, from ${serviceLink(ns)} · ${esc(fmt(r.n))} this week</p>
+<p class="hm-event-desc">${esc(eventDescription(type))}</p>
+<p class="hm-event-type"><code>${esc(type)}</code></p>
+</li>`;
+}
+
+/** The category links above the list: one per publishing service, to its site (plain text when it has none). */
+function categoryLinks(events) {
+ const seen = [];
+ for (const r of events) { const ns = nsOf(r.type); if (!seen.includes(ns)) seen.push(ns); }
+ return `<nav class="hm-cats" aria-label="Event categories">${seen.map((ns) => {
+ const url = serviceUrl(ns);
+ return url ? `<a href="${esc(url)}">${esc(serviceName(ns))}</a>` : `<span>${esc(serviceName(ns))}</span>`;
+ }).join('')}</nav>`;
+}
+
+/**
+ * The public event types anyone may read, newest arrival first. The first page (24 types) is rendered here, on the
+ * server, so a crawler reads it without running any JavaScript; there is no client-side list to duplicate it. Each
+ * card carries a real <time datetime> from the last arrival the store recorded, never a guessed date. A slow or failed
+ * stats read leaves a clear empty state instead of an empty page.
+ */
+function eventsSection(stats) {
+ const publicTypes = (stats && stats.public) || [];
+ const list = publicTypes.slice().sort((a, b) => Number(b.last) - Number(a.last)).slice(0, 24);
+ let inner;
+ if (!stats) {
+ inner = '<p class="sc-lede">The live bus figures are unavailable right now, so this page is showing the product shell. The API, webhooks and the realtime stream described below keep running.</p>';
+ } else if (!list.length) {
+ inner = '<p class="sc-lede">No public events have arrived this week. The bus is running; public event types appear here as they arrive.</p>';
+ } else {
+ inner = `<p class="sc-lede">The public event types anyone may read, newest arrival first: when each last arrived, who published it and what it means. Subscribe to a pattern such as <code>live.stream.*</code> to get them as signed webhooks.</p>
+${categoryLinks(list)}
+<ul class="hm-events">${list.map(eventCard).join('')}</ul>
+${publicTypes.length > list.length ? `<p class="hm-note">And ${esc(fmt(publicTypes.length - list.length))} more public event types this week.</p>` : ''}`;
+ }
+ return `<section class="sc-sec" id="events" aria-labelledby="h-events"><h2 id="h-events">Public events this week</h2>${inner}</section>`;
+}
-/** The numbers, who published and the public types (each left out when empty). */
+/** The ItemList of public events, each with a real startDate only where the store recorded an arrival. */
+function eventsJsonLd({ siteUrl, stats }) {
+ const publicTypes = (stats && stats.public) || [];
+ const list = publicTypes.slice().sort((a, b) => Number(b.last) - Number(a.last)).slice(0, 24);
+ if (!list.length) return null;
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'ItemList',
+ name: 'Public events on the OpenVibe.Events bus this week',
+ url: `${siteUrl}/#events`,
+ numberOfItems: list.length,
+ itemListElement: list.map((r, i) => {
+ const type = String(r.type);
+ const ns = nsOf(type);
+ const iso = isoDate(r.last);
+ const url = serviceUrl(ns);
+ return {
+ '@type': 'ListItem',
+ position: i + 1,
+ item: {
+ '@type': 'Event',
+ name: eventTitle(type),
+ description: eventDescription(type),
+ url: `${siteUrl}/#event-${slug(type)}`,
+ // The last recorded arrival of this public event type: real data, never an invented schedule.
+ ...(iso ? { startDate: iso } : {}),
+ eventAttendanceMode: 'https://schema.org/OnlineEventAttendanceMode',
+ location: { '@type': 'VirtualLocation', url: url || siteUrl },
+ organizer: { '@type': 'Organization', name: serviceName(ns), ...(url ? { url } : {}) },
+ isAccessibleForFree: true,
+ },
+ };
+ }),
+ };
+}
+
+/** The numbers, who published and the public events (the events section always renders, with its empty state). */
function activity(stats) {
function activity(stats) {
- if (!stats) return '';
- const now = stats.measuredAt || Date.now();
+ const events = eventsSection(stats);
+ if (!stats) return { statRow: '', who: '', events };
const tiles = [
const tiles = [
[stats.day, 'events in the last 24 hours'], [stats.week, 'events this week'], [stats.services.length, 'services published this week'],
[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.types, 'event types this week'], [stats.subscriptions, `active subscriptions${stats.consumers ? ` from ${fmt(stats.consumers)} consumers` : ''}`],
@@ -99,12 +189,7 @@ function activity(stats) {
@@ -99,12 +189,7 @@ function activity(stats) {
<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>
<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>
<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>` : '';
${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 };
+ return { statRow, who, events };
}
}
/** Questions people ask first, answered from the README and the code. */
/** Questions people ask first, answered from the README and the code. */
@@ -119,7 +204,7 @@ const FAQ = [
@@ -119,7 +204,7 @@ const FAQ = [
];
];
function sections({ apiUrl, stats = null }) {
function sections({ apiUrl, stats = null }) {
- const live = activity(stats) || { statRow: '', who: '', types: '' };
+ const live = activity(stats);
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\' \\',
@@ -146,7 +231,7 @@ function sections({ apiUrl, stats = null }) {
@@ -146,7 +231,7 @@ function sections({ apiUrl, stats = null }) {
+ `retried with backoff, and replayable after a failure.${publishers ? ` ${publishers} OpenVibe services published to it this week.` : ''}`,
+ `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.`,
- }) + '<section class="sc-sec sc-part-of"><p class="sc-note">Part of <a href="https://openvibe.services/">OpenVibe Services</a>: the building blocks every OpenVibe product runs on, with one console, one account and one SDK.</p></section>' + live.statRow + live.who + live.types + showcase.features({
+ }) + '<section class="sc-sec sc-part-of"><p class="sc-note">Part of <a href="https://openvibe.services/">OpenVibe Services</a>: the building blocks every OpenVibe product runs on, with one console, one account and one SDK.</p></section>' + live.statRow + live.events + live.who + 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.' },
@@ -180,9 +265,9 @@ function sections({ apiUrl, stats = null }) {
@@ -180,9 +265,9 @@ function sections({ apiUrl, stats = null }) {
// 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.
-// 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).
+// The data sections' rules follow: the stat tiles, the per-service bars, the public event cards 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}'
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)}'
+ + '.page{--hm-accent-text:color-mix(in srgb,var(--accent-light,#60a5fa) 45%,var(--text-primary,#e6edf7))}.sc-eyebrow{color:var(--hm-accent-text)}'
+ '.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-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 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-stats b{font-size:28px;line-height:1.1;letter-spacing:-.02em}.hm-stats span{font-size:13.5px;color:var(--text-secondary,#96a7c2)}'
@@ -191,15 +276,18 @@ const HOME_STYLE = '<style>body{margin:0}.page .sc-code{grid-template-columns:mi
@@ -191,15 +276,18 @@ const HOME_STYLE = '<style>body{margin:0}.page .sc-code{grid-template-columns:mi
+ '.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-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-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-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-cats{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0 20px}.hm-cats a,.hm-cats span{display:inline-block;padding:4px 11px;border:1px solid var(--border,rgba(255,255,255,.08));border-radius:999px;font-size:13px;color:var(--hm-accent-text);text-decoration:none}'
+ + '.hm-cats span{color:var(--text-secondary,#96a7c2)}.hm-cats a:hover{text-decoration:underline}'
+ + '.hm-events{list-style:none;margin:0;padding:0;display:grid;gap:12px 28px;grid-template-columns:repeat(auto-fill,minmax(min(100%,440px),1fr))}'
+ + '.hm-event{padding:4px 0 16px;border-bottom:1px solid var(--border,rgba(255,255,255,.08))}.hm-event-cat{margin:0;font-size:12.5px;letter-spacing:.04em;text-transform:uppercase;color:var(--text-secondary,#96a7c2)}'
+ + '.hm-event-cat a,.hm-event-meta a{color:var(--hm-accent-text);text-decoration:none}.hm-event-cat a:hover,.hm-event-meta a:hover{text-decoration:underline}'
+ + '.hm-event-title{margin:4px 0 6px;font-size:17px}.hm-event-meta{margin:0 0 6px;font-size:13.5px;line-height:1.5;color:var(--text-secondary,#96a7c2)}'
+ + '.hm-event-desc{margin:0 0 6px;font-size:14.5px;line-height:1.6;color:var(--text-secondary,#96a7c2)}.hm-event-type{margin:0}.hm-event-type code{font-size:12.5px;overflow-wrap:anywhere;color:var(--text-secondary,#96a7c2)}'
+ '.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-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)}'
+ '.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}'
+ '@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)}}'
- + ''
+ // On a phone each public event is a full-width card: the category, the title, then the date, place and count.
+ + '.hm-events{grid-template-columns:minmax(0,1fr)}.hm-event-title{font-size:16px}}'
+ '@media (max-width:420px){.sc-hero,.sc-sec,.sc-cta{padding-left:16px;padding-right:16px}}</style>';
+ '@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' };
@@ -245,7 +333,8 @@ function renderHome({ siteUrl, apiUrl, stats = null }) {
@@ -245,7 +333,8 @@ function renderHome({ siteUrl, apiUrl, stats = null }) {
seo.jsonLd.website({ name: SITE_NAME, url: siteUrl, description: DESCRIPTION }),
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.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 }))),
seo.jsonLd.faq(FAQ.map(([q, a]) => ({ q, a }))),
- ],
+ eventsJsonLd({ siteUrl, stats }),
+ ].filter(Boolean),
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' }),
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
// The home page's live sections (server/home-stats.js, server/home.js): counts and names read from Events' own tables,
// 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.
+// the public events with when each last arrived, never a payload, internal types never named, a clear empty state on
+// an empty or failed bus, and a FAQPage that holds exactly the questions the page shows.
const assert = require('assert');
const assert = require('assert');
const { boot, request, serviceToken, envelope, suite } = require('./helpers');
const { boot, request, serviceToken, envelope, suite } = require('./helpers');
const { createHomeStats } = require('../server/home-stats');
const { createHomeStats } = require('../server/home-stats');
@@ -13,11 +13,11 @@ const section = (page, id) => (new RegExp(`<section class="sc-sec"[^>]*aria-labe
@@ -13,11 +13,11 @@ const section = (page, id) => (new RegExp(`<section class="sc-sec"[^>]*aria-labe
t('boot', async () => { h = await boot(); });
t('boot', async () => { h = await boot(); });
-t('an empty bus: the live sections are left out, the rest of the page stays', async () => {
+t('an empty bus: the counts are left out, the public event list says so, the rest of the page stays', async () => {
const page = await html();
const page = await html();
assert.ok(!page.includes('aria-labelledby="h-who"'));
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.ok(!page.includes('class="hm-stats"'), 'no tiles of zeros');
+ assert.match(page, /id="h-events">Public events this week<\/h2><p class="sc-lede">No public events have arrived this week\./);
assert.match(page, /Events your services/);
assert.match(page, /Events your services/);
assert.match(page, /id="h-faq">Questions</);
assert.match(page, /id="h-faq">Questions</);
});
});
@@ -43,13 +43,24 @@ t('published events show as counts by service, public types and the latest publi
@@ -43,13 +43,24 @@ t('published events show as counts by service, public types and the latest publi
const { renderHome } = require('../server/home');
const { renderHome } = require('../server/home');
const page = renderHome({ siteUrl: 'https://openvibe.events', apiUrl: 'https://openvibe.events', stats });
const page = renderHome({ siteUrl: 'https://openvibe.events', apiUrl: 'https://openvibe.events', stats });
+ const events = section(page, 'h-events');
assert.match(section(page, 'h-who'), /<a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a>/);
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.match(events, /A channel went live on OpenVibe\.Live/);
+ assert.match(events, /<time datetime="\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z">/);
+ assert.match(events, /Online, from <a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a> · 2 this week/);
+ assert.match(events, /<code>live\.stream\.started<\/code>/);
+ assert.match(events, /A post was published on OpenVibe\.Blog/);
+ assert.match(events, /<code>blog\.post\.published<\/code>/);
assert.ok(!page.includes('SECRET-TITLE'));
assert.ok(!page.includes('SECRET-TITLE'));
assert.ok(!page.includes('live.internal.tick'));
assert.ok(!page.includes('live.internal.tick'));
assert.match(page, /<b>4<\/b><span>events this week<\/span>/);
assert.match(page, /<b>4<\/b><span>events this week<\/span>/);
assert.match(page, / 2 OpenVibe services published to it this week\./);
assert.match(page, / 2 OpenVibe services published to it this week\./);
+ const ld = [...page.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+ const list = ld.find((x) => x['@type'] === 'ItemList');
+ assert.ok(list, 'the public events are an ItemList');
+ assert.strictEqual(list.numberOfItems, 2);
+ assert.ok(list.itemListElement.every((x) => x.item['@type'] === 'Event' && x.item.startDate));
+ assert.ok(!JSON.stringify(list).includes('SECRET-TITLE'));
});
});
t('JSON-LD: WebSite, the app, and a FAQPage with exactly the questions shown', async () => {
t('JSON-LD: WebSite, the app, and a FAQPage with exactly the questions shown', async () => {
@@ -64,6 +75,19 @@ t('JSON-LD: WebSite, the app, and a FAQPage with exactly the questions shown', a
@@ -64,6 +75,19 @@ t('JSON-LD: WebSite, the app, and a FAQPage with exactly the questions shown', a
assert.ok(description.length <= 160, `description ${description.length} chars`);
assert.ok(description.length <= 160, `description ${description.length} chars`);
});
});
+t('a stalled store resolves to no stats within the 1.5 s bound, so the page falls back to its empty state', async () => {
+ const pending = () => new Promise(() => {});
+ const stalled = {
+ db: { prepare: () => ({ get: pending, all: pending }) },
+ deliveryCounts: pending,
+ };
+ const started = Date.now();
+ const stats = await createHomeStats({ store: stalled, log: { warn() {} } }).read();
+ const elapsed = Date.now() - started;
+ assert.strictEqual(stats, null);
+ assert.ok(elapsed >= 1400 && elapsed < 2500, `resolved in ${elapsed} ms`);
+});
+
t('stop', async () => { await h.stop(); });
t('stop', async () => { await h.stop(); });
t.run();
t.run();
@@ -1,8 +1,9 @@
@@ -1,8 +1,9 @@
'use strict';
'use strict';
-// The product home (plan T7: openvibe.events is the API and product origin): it renders server/home.js
-// for a browser and keeps the text/plain API index for curl and API clients, serves the pinned OpenVibe
-// Frame at /shared, answers robots.txt, sitemap.xml, llms.txt and llms-full.txt, and names its own origin everywhere.
-// A parse-only check of the nginx vhost too.
+// The product home (plan T7: openvibe.events is the API and product origin): it renders server/home.js for
+// every caller by default — browser, crawler, or a plain curl with no Accept — with the public event list,
+// one h1, a canonical and JSON-LD; the text/plain API index is kept for an explicit text/plain (or ?format=text).
+// It also serves the pinned OpenVibe Frame at /shared, answers robots.txt, sitemap.xml, llms.txt and
+// llms-full.txt, and names its own origin everywhere. A parse-only check of the nginx vhost too.
const assert = require('assert');
const assert = require('assert');
function assertSitemapLastmods(xml, tag = 'url') {
function assertSitemapLastmods(xml, tag = 'url') {
@@ -15,16 +16,20 @@ function assertSitemapLastmods(xml, tag = 'url') {
@@ -15,16 +16,20 @@ function assertSitemapLastmods(xml, tag = 'url') {
return entries;
return entries;
}
}
+/** The readable text of a page: scripts, styles and tags removed (what a crawler or an AI reader gets). */
+const readable = (html) => html.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ')
+ .replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
+
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const nodeHttp = require('http');
const nodeHttp = require('http');
const { boot, suite } = require('./helpers');
const { boot, suite } = require('./helpers');
const { load } = require('../server/config');
const { load } = require('../server/config');
-const { HOME_CSP } = require('../server/home');
+const { HOME_CSP, renderHome } = require('../server/home');
const SITE = 'https://openvibe.events';
const SITE = 'https://openvibe.events';
-// GET / without an Accept a browser would send: the API index, byte for byte, as it was before the home.
+// GET / for a caller that explicitly asks for text: the API index, byte for byte, as it was before the home.
const INDEX = [
const INDEX = [
'OpenVibe.Events: durable events, subscriptions, signed delivery, dead letters and replay.',
'OpenVibe.Events: durable events, subscriptions, signed delivery, dead letters and replay.',
'',
'',
@@ -80,6 +85,23 @@ t('GET / as a browser: the home page, its CSP and its cache policy', async () =>
@@ -80,6 +85,23 @@ t('GET / as a browser: the home page, its CSP and its cache policy', async () =>
assert.ok(r.body.includes(`The API is at ${SITE}`), 'the hero note names the API origin');
assert.ok(r.body.includes(`The API is at ${SITE}`), 'the hero note names the API origin');
assert.ok(r.body.includes(`${SITE}/api/v1/events`), 'the API examples use the API origin');
assert.ok(r.body.includes(`${SITE}/api/v1/events`), 'the API examples use the API origin');
assert.ok(!r.body.includes('events.openvibe.network'), 'the home names no other origin');
assert.ok(!r.body.includes('events.openvibe.network'), 'the home names no other origin');
+ assert.strictEqual((r.body.match(/<link rel="canonical"/g) || []).length, 1, 'one canonical');
+ assert.match(r.body, /<html lang="en">/);
+});
+
+t('GET / for a crawler or a plain curl: the server-rendered HTML, with real readable text', async () => {
+ // `*/*` and no Accept at all are what curl, fetch() and many crawlers send. Both must get the page, not the
+ // text API index: otherwise the HTML that reaches a crawler has no title, h1, canonical or event list.
+ for (const headers of [{}, { Accept: '*/*' }, { Accept: 'application/json' }]) {
+ const r = await raw(h.base, '/', headers);
+ assert.strictEqual(r.status, 200, JSON.stringify(headers));
+ assert.match(r.headers['content-type'], /^text\/html/, JSON.stringify(headers));
+ assert.ok(readable(r.body).length > 2000, `${readable(r.body).length} readable chars for ${JSON.stringify(headers)}`);
+ assert.strictEqual((r.body.match(/<h1\b/g) || []).length, 1, JSON.stringify(headers));
+ assert.ok(r.body.includes(`<link rel="canonical" href="${SITE}/">`), JSON.stringify(headers));
+ const ld = [...r.body.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+ assert.ok(ld.some((x) => x['@type'] === 'WebSite'), 'the WebSite node');
+ }
});
});
t('the home CSP lets Cloudflare Web Analytics load and report (it injects its beacon on this zone)', () => {
t('the home CSP lets Cloudflare Web Analytics load and report (it injects its beacon on this zone)', () => {
@@ -117,17 +139,58 @@ t('the home links the pinned Frame stylesheet, and /shared serves it', async ()
@@ -117,17 +139,58 @@ t('the home links the pinned Frame stylesheet, and /shared serves it', async ()
assert.ok(sheet.body.includes('.ov-'), 'the showcase stylesheet');
assert.ok(sheet.body.includes('.ov-'), 'the showcase stylesheet');
});
});
-t('GET / for curl and API clients: the text/plain index, byte for byte', async () => {
- // `*/*`, no Accept at all, and a JSON client: all three get the API index.
- for (const headers of [{ Accept: '*/*' }, {}, { Accept: 'application/json' }]) {
- const r = await raw(h.base, '/', headers);
- assert.strictEqual(r.status, 200, JSON.stringify(headers));
- assert.match(r.headers['content-type'], /^text\/plain/, JSON.stringify(headers));
- assert.strictEqual(r.headers.vary, 'Accept', JSON.stringify(headers));
- assert.strictEqual(r.body, INDEX, JSON.stringify(headers));
+t('GET / for a caller that asks for text: the API index, byte for byte', async () => {
+ // Only an explicit text/plain (header or `?format=text`) gets the API index now: the HTML page is the default.
+ for (const [p, headers] of [['/', { Accept: 'text/plain' }], ['/?format=text', {}], ['/?format=text', { Accept: '*/*' }]]) {
+ const r = await raw(h.base, p, headers);
+ assert.strictEqual(r.status, 200, `${p} ${JSON.stringify(headers)}`);
+ assert.match(r.headers['content-type'], /^text\/plain/, `${p} ${JSON.stringify(headers)}`);
+ assert.strictEqual(r.headers.vary, 'Accept', `${p} ${JSON.stringify(headers)}`);
+ assert.strictEqual(r.headers['cache-control'], 'private, no-store', `${p} ${JSON.stringify(headers)}`);
+ assert.strictEqual(r.body, INDEX, `${p} ${JSON.stringify(headers)}`);
}
}
});
});
+t('the server-rendered event list: title, real <time datetime>, place, description, category links and JSON-LD', () => {
+ const last = Date.parse('2026-10-29T09:41:00Z');
+ const stats = {
+ day: 4, week: 4, types: 1, services: [{ ns: 'live', n: 3 }, { ns: 'blog', n: 1 }], subscriptions: 0, consumers: 0,
+ delivered: 0, dead: 0, measuredAt: last,
+ public: [
+ { type: 'live.stream.started', n: 3, last },
+ { type: 'blog.post.published', n: 1, last: 0 }, // no arrival recorded: no startDate may be invented
+ ],
+ };
+ const page = renderHome({ siteUrl: SITE, apiUrl: SITE, stats });
+ assert.ok(readable(page).length > 2000, `${readable(page).length} readable chars`);
+ assert.strictEqual((page.match(/<h1\b/g) || []).length, 1);
+ assert.strictEqual((page.match(/<link rel="canonical"/g) || []).length, 1);
+ const card = /<li class="hm-event"[\s\S]*?<\/li>/.exec(page)[0];
+ assert.match(card, /A channel went live on OpenVibe\.Live/, 'the event title');
+ assert.match(card, /<time datetime="2026-10-29T09:41:00\.000Z">/, 'a real <time datetime>');
+ assert.match(card, /Online, from <a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a>/, 'the place and publisher');
+ assert.match(card, /Published by OpenVibe\.Live as live\.stream\.started/, 'the short description');
+ assert.match(card, /3 this week/, 'the count');
+ assert.match(card, /Category: <a href="https:\/\/openvibe\.live">live<\/a>/, 'a category link');
+ assert.match(page, /<nav class="hm-cats"[^>]*><a href="https:\/\/openvibe\.live">OpenVibe\.Live<\/a>/, 'the category nav');
+ assert.match(page, /<code>live\.stream\.started<\/code>/, 'the exact event type');
+
+ const ld = [...page.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+ assert.ok(ld.some((x) => x['@type'] === 'WebSite'), 'the WebSite node');
+ const list = ld.find((x) => x['@type'] === 'ItemList');
+ assert.ok(list, 'an ItemList of the events');
+ assert.strictEqual(list.numberOfItems, 2);
+ assert.strictEqual(list.itemListElement[0].item['@type'], 'Event');
+ assert.strictEqual(list.itemListElement[0].item.startDate, '2026-10-29T09:41:00.000Z');
+ assert.ok(!('startDate' in list.itemListElement[1].item), 'no date invented when none was recorded');
+
+ // A stats failure renders the shell, the h1, the description and a clear empty state — never a blank page.
+ const shell = renderHome({ siteUrl: SITE, apiUrl: SITE, stats: null });
+ assert.match(shell, /The live bus figures are unavailable right now/);
+ assert.strictEqual((shell.match(/<h1\b/g) || []).length, 1);
+ assert.ok(shell.includes(`<link rel="canonical" href="${SITE}/">`));
+});
+
t('robots.txt welcomes the home and keeps the API out, and names the sitemap', async () => {
t('robots.txt welcomes the home and keeps the API out, and names the sitemap', async () => {
const r = await raw(h.base, '/robots.txt');
const r = await raw(h.base, '/robots.txt');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);