openvibers/openvibe.events Commits

SEO: serve the events home to crawlers and render the public event list (SEO-5) (#145)

OP OpenVibers committed 15 days ago · ed41ae98bd · 6 files, +238 −58

Changes

ddiff --git a/README.md b/README.md
iindex e7292f8..26d39e2 100644
---- a/README.md
++++ b/README.md
@@@ -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
ddiff --git a/deploy/nginx/openvibe.events.conf b/deploy/nginx/openvibe.events.conf
iindex 2e8faef..164961a 100644
---- a/deploy/nginx/openvibe.events.conf
++++ b/deploy/nginx/openvibe.events.conf
@@@ -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)
ddiff --git a/server/app.js b/server/app.js
iindex 3b594ff..968928d 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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, dnsLo
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') {
++ 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)',
ddiff --git a/server/home.js b/server/home.js
iindex 0660a03..22f7c2f 100644
---- a/server/home.js
++++ b/server/home.js
@@@ -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' }),
ddiff --git a/test/home-stats.test.js b/test/home-stats.test.js
iindex 2be8f3c..72b47c7 100644
---- a/test/home-stats.test.js
++++ b/test/home-stats.test.js
@@@ -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-labe
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();
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 publi
const { 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', a
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.run();
ddiff --git a/test/home.test.js b/test/home.test.js
iindex cfd153a..61ab28a 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -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);