openvibers/openvibe.run Commits
Serve llms.txt, llms-full.txt and an IndexNow key file when keyed
Changes
@@@ -90,3 +90,7 @@ OV_OAUTH_CLIENT_SECRET=#RUN_BILLING_AUDIENCE=openvibe.billing#RUN_BILLING_INTERVAL_MS=5000#RUN_BILLING_TIMEOUT_MS=8000++++# ── IndexNow (search engines are told when the public pages change) ──++# 8-128 letters and digits. Unset: nothing is sent and GET /<key>.txt is 404. When set, the key file is served there.++#INDEXNOW_KEY=@@@ -44,7 +44,7 @@ placed from the Fabric's offers and actually runs — the poller in `server/jobs- The public site at openvibe.run (`server/web/home.js`, `server/app.js`): the front page in the OpenVibe Frame (what ajob is and how it runs, the API in brief), the legal pages (`openvibe-shared/legal`), `robots.txt`, the sitemap,-- the icon and the web manifest. It says only what works today; `test/site.test.js` checks it.++ `/llms.txt` and `/llms-full.txt`, IndexNow (only with `INDEXNOW_KEY`: the key file and one ping at boot), the icon and the web manifest. It says only what works today; `test/site.test.js` checks it.- The job API (`server/api/v1.js`, one capability per route — the table below) and the job's lifecycle record(`run_jobs`, `migrations/0001_jobs.sql`): state, placement, timings, exit, result and the metered wall clock.- The project scope: a job's `project_id` and `requester` come from the caller's Network token@@@ -49,7 +49,8 @@"run_jobs (migrations/0001_jobs.sql) holds every job, its state, placement, timings, exit, result and the metered wall clock","Bot owns the node link and the per-second metering of the jobs it carries (plan T14 L1); Run holds a Billing token of its own (billing.usage.record, audience openvibe.billing) used only for the external managed-browser provider's jobs","plan T14 R4a built, not deployed: the managed-browser provider adapter (server/dispatch/browser-provider.js; RUN_BROWSER_PROVIDER_URL / _API_KEY / _TIMEOUT_MS, off while unset) places class browser jobs on kind=provider offers named browser-provider, validates every answer against browser-provider.job-create-result@1 / job-state@1 and never adopts a malformed one; Run meters those jobs itself, the provider's claimed wall time clamped to Run's observed duration + 5 s, and relays the readings from run_usage_outbox (migrations/0003_usage_outbox.sql) to Billing (RUN_BILLING_URL; unset, they wait and the jobs still run)",-- "truthful readiness (GET /api/ready: db required; valkey, Network JWKS, Events, Bot reachability and the poller's own status reported, never claimed) and the loopback-only /metrics gauges (jobs queued, active, ended, outbox backlog, streams)"++ "truthful readiness (GET /api/ready: db required; valkey, Network JWKS, Events, Bot reachability and the poller's own status reported, never claimed) and the loopback-only /metrics gauges (jobs queued, active, ended, outbox backlog, streams)",++ "plan T11 step 4 built, not deployed: /llms.txt and /llms-full.txt (server/web/discovery.js, public pages and docs only, never the API, admin, a job or a stream) and IndexNow (INDEXNOW_KEY: serves GET /<key>.txt and pings the public pages once at boot; unset, the key route is 404 and nothing is sent)"],"updated": "2026-10-13"}@@@ -99,6 +99,16 @@ function createApp(opts = {}) {const home = require('./web/home');const appIcon = require('openvibe-shared/app-icon');app.use('/shared', require('openvibe-shared/serve').handler());++ // IndexNow (openvibe-shared/indexnow): on only with INDEXNOW_KEY, which also mounts GET /<key>.txt. A malformed++ // key (8–128 alphanumerics) turns it off instead of stopping the service.++ const discovery = require('./web/discovery');++ let indexnow = opts.indexnow;++ if (!indexnow) {++ const { createIndexNow } = require('openvibe-shared/indexnow');++ try { indexnow = createIndexNow({ host: config.baseUrl, key: config.indexnow.key, fetch: fetchImpl }); }++ catch (err) { log.warn(`[Run] IndexNow disabled: ${err.message}`); indexnow = createIndexNow({ host: config.baseUrl, key: '' }); }++ }++ if (indexnow.enabled) app.use(indexnow.keyFile);app.get('/', (req, res) => {res.setHeader('Content-Security-Policy', home.HOME_CSP);res.setHeader('Cache-Control', 'public, max-age=300');@@@ -106,6 +116,8 @@ function createApp(opts = {}) {});{ const legal = require('openvibe-shared/legal');app.get(legal.PATHS, (req, res, next) => { res.setHeader('Content-Security-Policy', home.HOME_CSP); next(); }, legal.handler({ id: 'run', service: 'run', host: 'openvibe.run', name: home.SITE_NAME, profile: 'hosting' })); }++ app.get('/llms.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(discovery.llmsTxt(config)));++ app.get('/llms-full.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(discovery.llmsFull(config)));app.get('/robots.txt', (req, res) => res.type('text/plain').send(['User-agent: *', 'Allow: /$', 'Disallow: /api/', 'Disallow: /internal/', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));app.get('/sitemap.xml', (req, res) => res.type('application/xml').send(require('openvibe-shared/seo').sitemapXml(['/', '/terms', '/privacy', '/dmca'].map((p) => ({ loc: `${config.baseUrl}${p}` })))));app.get('/favicon.ico', (req, res) => res.type('image/svg+xml').set('Cache-Control', 'public, max-age=86400').send(appIcon.favicon({ site: 'run' })));@@@ -128,7 +140,7 @@ function createApp(opts = {}) {return res.status(500).type('text/plain').send('Something went wrong\n');});-- Object.assign(app.locals, { config, db, valkey, keys, outbox, events, stream, tickets, dispatch, apiAuth, metrics });++ Object.assign(app.locals, { config, db, indexnow, valkey, keys, outbox, events, stream, tickets, dispatch, apiAuth, metrics });return app;}@@@ -67,6 +67,8 @@ function loadConfig(env = process.env) {clientId: env.OV_OAUTH_CLIENT_ID || 'run',clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',},++ // IndexNow (openvibe-shared/indexnow): unset, nothing is sent and the key file is not served.++ indexnow: { key: (env.INDEXNOW_KEY || '').trim() },cookies: { secure: env.COOKIE_SECURE != null ? bool(env.COOKIE_SECURE) : isProduction },events: {@@@ -33,7 +33,7 @@ async function main() {const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });const app = createApp({ config, db, valkey, registry });-- const { keys, outbox, stream, tickets, dispatch } = app.locals;++ const { keys, outbox, stream, tickets, dispatch, indexnow } = app.locals;keys.start();const ticketKey = tickets.start();@@@ -50,6 +50,8 @@ async function main() {console.log(`[Run] jobs ${config.jobs.enabled ? 'on' : 'off (RUN_JOBS=off)'}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (run.job.* events wait in the outbox)'}; stream tickets ${ticketKey.enabled ? `on (${config.stream.keyId}${ticketKey.ephemeral ? ', ephemeral key' : ''})` : 'off (no stream key)'}; dispatcher ${dispatch.enabled ? `on (poll ${config.dispatch.pollMs} ms): ${dispatch.note}` : `off: ${dispatch.note}`}; ${dispatch.status().usage_relay.note}`);});server.keepAliveTimeout = 65_000;++ // The public pages change with a deploy: tell the engines once, and only with INDEXNOW_KEY set.++ require('./web/discovery').notify(indexnow, config);// systemd sends SIGTERM (SIGINT by hand); openvibe-sdk/service's gracefulStop takes the signal, runs the// stop steps in order (nothing new starts), drains the HTTP server — event streams are destroyed, so annew file mode 100644@@@ -0,0 +1,61 @@++'use strict';++/**++ * What openvibe.run tells crawlers about itself: /llms.txt and /llms-full.txt (openvibe-shared/seo) and the++ * IndexNow notifier. Only the public pages are listed — never the API, the admin routes, a job or its stream.++ */++const seo = require('openvibe-shared/seo');++const home = require('./home');++++const MAX_BYTES = 512 * 1024;++const REPO = 'https://github.com/OpenVibers/OpenVibe.Run';++const NODE_REPO = 'https://github.com/OpenVibers/OpenVibe.Node';++++// The public pages, in the order the sitemap lists them.++const PAGES = ['/', '/terms', '/privacy', '/dmca'];++++function sections(base) {++ return [++ { title: 'Pages', links: [++ { title: 'OpenVibe.Run', url: `${base}/`, note: 'what a job is, how it runs, and the API in brief' },++ { title: 'Terms', url: `${base}/terms` },++ { title: 'Privacy', url: `${base}/privacy` },++ { title: 'DMCA', url: `${base}/dmca` },++ ] },++ { title: 'Docs', links: [++ { title: 'OpenVibe.Run on GitHub', url: REPO, note: 'the source and the README: the job API, its states and its capabilities' },++ { title: 'OpenVibe Node', url: NODE_REPO, note: 'pair your own machine to run jobs' },++ ] },++ ];++}++++function llmsTxt(config) {++ return seo.llmsTxt({ name: home.SITE_NAME, summary: home.DESCRIPTION, sections: sections(config.baseUrl) });++}++++function llmsFull(config) {++ const page = home.renderHome({ config });++ const main = /<main id="main">([\s\S]*)<\/main>/.exec(page);++ return seo.llmsFull({++ site: home.SITE_NAME,++ summary: home.DESCRIPTION,++ base: config.baseUrl,++ maxBytes: MAX_BYTES,++ sections: [{ title: 'Pages', pages: [{ title: home.SITE_NAME, url: '/', html: main ? main[1] : '' }] }],++ });++}++++/**++ * Tells search engines the public pages changed (once at boot; a deploy is what changes them). Sends only++ * when IndexNow is enabled, i.e. INDEXNOW_KEY is set; returns whether anything was queued. Never throws.++ */++function notify(indexnow, config) {++ try {++ if (!indexnow || !indexnow.enabled) return false;++ return indexnow.pingSoon([...PAGES, '/llms.txt', '/sitemap.xml'].map((p) => `${config.baseUrl}${p}`)) > 0;++ } catch (err) {++ console.warn('[IndexNow] notify failed:', err.message);++ return false;++ }++}++++module.exports = { llmsTxt, llmsFull, notify, sections, MAX_BYTES, PAGES };nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++/**++ * What openvibe.run tells crawlers: /llms.txt, /llms-full.txt and IndexNow (the key file and the boot ping,++ * both only with INDEXNOW_KEY).++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++const { createIndexNow } = require('openvibe-shared/indexnow');++const discovery = require('../server/web/discovery');++++const KEY = require('crypto').randomBytes(16).toString('hex');++++(async () => {++ const t = await boot();++ try {++ await check('/llms.txt answers 200 text/plain with the site name, and lists no API, admin, job or stream URL', async () => {++ const r = await t.call('GET', '/llms.txt', { token: null });++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /^text\/plain/);++ assert.match(r.text, /^# OpenVibe\.Run\n/);++ assert.ok(r.text.includes('http://run.test/terms'));++ assert.ok(!/\/api\/|\/internal\/|\/admin|\/jobs|\/stream/.test(r.text), 'no machine route is listed');++ });++ await check('/llms-full.txt answers 200 text/plain within maxBytes and carries the front page text', async () => {++ const r = await t.call('GET', '/llms-full.txt', { token: null });++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /^text\/plain/);++ assert.ok(Buffer.byteLength(r.text) <= discovery.MAX_BYTES);++ assert.match(r.text, /^# OpenVibe\.Run\n/);++ assert.match(r.text, /How a job runs/);++ assert.ok(!/<[a-z]+[ >]/.test(r.text), 'plain text, no markup');++ assert.ok(!/\/internal\/|\/admin/.test(r.text));++ });++ await check('without INDEXNOW_KEY the key route is 404 and no notify call is made', async () => {++ assert.strictEqual(t.app.locals.indexnow.enabled, false);++ assert.strictEqual((await t.call('GET', `/${KEY}.txt`, { token: null })).status, 404);++ const sent = [];++ const off = createIndexNow({ host: 'https://run.test', key: '', fetch: async (...a) => { sent.push(a); return { status: 200 }; } });++ assert.strictEqual(discovery.notify(off, { baseUrl: 'https://run.test' }), false);++ await off.flush();++ await off.ping(['https://run.test/']);++ assert.strictEqual(sent.length, 0);++ });++ } finally {++ await t.close();++ }++++ const sent = [];++ const indexnow = createIndexNow({ host: 'https://run.test', key: KEY, fetch: async (url, init) => { sent.push(JSON.parse(init.body)); return { status: 200 }; } });++ const k = await boot({ env: { INDEXNOW_KEY: KEY, BASE_URL: 'https://run.test' }, appOpts: { indexnow } });++ try {++ await check('with INDEXNOW_KEY the key route serves the key, and notify sends the public pages in one batch', async () => {++ const r = await k.call('GET', `/${KEY}.txt`, { token: null });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.text, KEY);++ assert.strictEqual((await k.call('GET', '/other-key.txt', { token: null })).status, 404);++ assert.strictEqual(discovery.notify(k.app.locals.indexnow, k.app.locals.config), true);++ await indexnow.flush();++ assert.strictEqual(sent.length, 1);++ assert.strictEqual(sent[0].key, KEY);++ assert.strictEqual(sent[0].keyLocation, `https://run.test/${KEY}.txt`);++ assert.deepStrictEqual(sent[0].urlList.slice(0, 2), ['https://run.test/', 'https://run.test/terms']);++ assert.ok(sent[0].urlList.every((u) => !/\/api\/|\/internal\//.test(u)));++ });++ } finally {++ await k.close();++ }++ done();++})();
@@ -90,3 +90,7 @@ OV_OAUTH_CLIENT_SECRET=
@@ -90,3 +90,7 @@ OV_OAUTH_CLIENT_SECRET=
#RUN_BILLING_AUDIENCE=openvibe.billing
#RUN_BILLING_AUDIENCE=openvibe.billing
#RUN_BILLING_INTERVAL_MS=5000
#RUN_BILLING_INTERVAL_MS=5000
#RUN_BILLING_TIMEOUT_MS=8000
#RUN_BILLING_TIMEOUT_MS=8000
+
+# ── IndexNow (search engines are told when the public pages change) ──
+# 8-128 letters and digits. Unset: nothing is sent and GET /<key>.txt is 404. When set, the key file is served there.
+#INDEXNOW_KEY=
@@ -44,7 +44,7 @@ placed from the Fabric's offers and actually runs — the poller in `server/jobs
@@ -44,7 +44,7 @@ placed from the Fabric's offers and actually runs — the poller in `server/jobs
- The public site at openvibe.run (`server/web/home.js`, `server/app.js`): the front page in the OpenVibe Frame (what a
- The public site at openvibe.run (`server/web/home.js`, `server/app.js`): the front page in the OpenVibe Frame (what a
job is and how it runs, the API in brief), the legal pages (`openvibe-shared/legal`), `robots.txt`, the sitemap,
job is and how it runs, the API in brief), the legal pages (`openvibe-shared/legal`), `robots.txt`, the sitemap,
- the icon and the web manifest. It says only what works today; `test/site.test.js` checks it.
+ `/llms.txt` and `/llms-full.txt`, IndexNow (only with `INDEXNOW_KEY`: the key file and one ping at boot), the icon and the web manifest. It says only what works today; `test/site.test.js` checks it.
- The job API (`server/api/v1.js`, one capability per route — the table below) and the job's lifecycle record
- The job API (`server/api/v1.js`, one capability per route — the table below) and the job's lifecycle record
(`run_jobs`, `migrations/0001_jobs.sql`): state, placement, timings, exit, result and the metered wall clock.
(`run_jobs`, `migrations/0001_jobs.sql`): state, placement, timings, exit, result and the metered wall clock.
- The project scope: a job's `project_id` and `requester` come from the caller's Network token
- The project scope: a job's `project_id` and `requester` come from the caller's Network token
@@ -49,7 +49,8 @@
@@ -49,7 +49,8 @@
"run_jobs (migrations/0001_jobs.sql) holds every job, its state, placement, timings, exit, result and the metered wall clock",
"run_jobs (migrations/0001_jobs.sql) holds every job, its state, placement, timings, exit, result and the metered wall clock",
"Bot owns the node link and the per-second metering of the jobs it carries (plan T14 L1); Run holds a Billing token of its own (billing.usage.record, audience openvibe.billing) used only for the external managed-browser provider's jobs",
"Bot owns the node link and the per-second metering of the jobs it carries (plan T14 L1); Run holds a Billing token of its own (billing.usage.record, audience openvibe.billing) used only for the external managed-browser provider's jobs",
"plan T14 R4a built, not deployed: the managed-browser provider adapter (server/dispatch/browser-provider.js; RUN_BROWSER_PROVIDER_URL / _API_KEY / _TIMEOUT_MS, off while unset) places class browser jobs on kind=provider offers named browser-provider, validates every answer against browser-provider.job-create-result@1 / job-state@1 and never adopts a malformed one; Run meters those jobs itself, the provider's claimed wall time clamped to Run's observed duration + 5 s, and relays the readings from run_usage_outbox (migrations/0003_usage_outbox.sql) to Billing (RUN_BILLING_URL; unset, they wait and the jobs still run)",
"plan T14 R4a built, not deployed: the managed-browser provider adapter (server/dispatch/browser-provider.js; RUN_BROWSER_PROVIDER_URL / _API_KEY / _TIMEOUT_MS, off while unset) places class browser jobs on kind=provider offers named browser-provider, validates every answer against browser-provider.job-create-result@1 / job-state@1 and never adopts a malformed one; Run meters those jobs itself, the provider's claimed wall time clamped to Run's observed duration + 5 s, and relays the readings from run_usage_outbox (migrations/0003_usage_outbox.sql) to Billing (RUN_BILLING_URL; unset, they wait and the jobs still run)",
- "truthful readiness (GET /api/ready: db required; valkey, Network JWKS, Events, Bot reachability and the poller's own status reported, never claimed) and the loopback-only /metrics gauges (jobs queued, active, ended, outbox backlog, streams)"
+ "truthful readiness (GET /api/ready: db required; valkey, Network JWKS, Events, Bot reachability and the poller's own status reported, never claimed) and the loopback-only /metrics gauges (jobs queued, active, ended, outbox backlog, streams)",
+ "plan T11 step 4 built, not deployed: /llms.txt and /llms-full.txt (server/web/discovery.js, public pages and docs only, never the API, admin, a job or a stream) and IndexNow (INDEXNOW_KEY: serves GET /<key>.txt and pings the public pages once at boot; unset, the key route is 404 and nothing is sent)"
],
],
"updated": "2026-10-13"
"updated": "2026-10-13"
}
}
@@ -99,6 +99,16 @@ function createApp(opts = {}) {
@@ -99,6 +99,16 @@ function createApp(opts = {}) {
const home = require('./web/home');
const home = require('./web/home');
const appIcon = require('openvibe-shared/app-icon');
const appIcon = require('openvibe-shared/app-icon');
app.use('/shared', require('openvibe-shared/serve').handler());
app.use('/shared', require('openvibe-shared/serve').handler());
+ // IndexNow (openvibe-shared/indexnow): on only with INDEXNOW_KEY, which also mounts GET /<key>.txt. A malformed
+ // key (8–128 alphanumerics) turns it off instead of stopping the service.
+ const discovery = require('./web/discovery');
+ let indexnow = opts.indexnow;
+ if (!indexnow) {
+ const { createIndexNow } = require('openvibe-shared/indexnow');
+ try { indexnow = createIndexNow({ host: config.baseUrl, key: config.indexnow.key, fetch: fetchImpl }); }
+ catch (err) { log.warn(`[Run] IndexNow disabled: ${err.message}`); indexnow = createIndexNow({ host: config.baseUrl, key: '' }); }
+ }
+ if (indexnow.enabled) app.use(indexnow.keyFile);
app.get('/', (req, res) => {
app.get('/', (req, res) => {
res.setHeader('Content-Security-Policy', home.HOME_CSP);
res.setHeader('Content-Security-Policy', home.HOME_CSP);
res.setHeader('Cache-Control', 'public, max-age=300');
res.setHeader('Cache-Control', 'public, max-age=300');
@@ -106,6 +116,8 @@ function createApp(opts = {}) {
@@ -106,6 +116,8 @@ function createApp(opts = {}) {
});
});
{ const legal = require('openvibe-shared/legal');
{ const legal = require('openvibe-shared/legal');
app.get(legal.PATHS, (req, res, next) => { res.setHeader('Content-Security-Policy', home.HOME_CSP); next(); }, legal.handler({ id: 'run', service: 'run', host: 'openvibe.run', name: home.SITE_NAME, profile: 'hosting' })); }
app.get(legal.PATHS, (req, res, next) => { res.setHeader('Content-Security-Policy', home.HOME_CSP); next(); }, legal.handler({ id: 'run', service: 'run', host: 'openvibe.run', name: home.SITE_NAME, profile: 'hosting' })); }
+ app.get('/llms.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(discovery.llmsTxt(config)));
+ app.get('/llms-full.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(discovery.llmsFull(config)));
app.get('/robots.txt', (req, res) => res.type('text/plain').send(['User-agent: *', 'Allow: /$', 'Disallow: /api/', 'Disallow: /internal/', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));
app.get('/robots.txt', (req, res) => res.type('text/plain').send(['User-agent: *', 'Allow: /$', 'Disallow: /api/', 'Disallow: /internal/', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));
app.get('/sitemap.xml', (req, res) => res.type('application/xml').send(require('openvibe-shared/seo').sitemapXml(['/', '/terms', '/privacy', '/dmca'].map((p) => ({ loc: `${config.baseUrl}${p}` })))));
app.get('/sitemap.xml', (req, res) => res.type('application/xml').send(require('openvibe-shared/seo').sitemapXml(['/', '/terms', '/privacy', '/dmca'].map((p) => ({ loc: `${config.baseUrl}${p}` })))));
app.get('/favicon.ico', (req, res) => res.type('image/svg+xml').set('Cache-Control', 'public, max-age=86400').send(appIcon.favicon({ site: 'run' })));
app.get('/favicon.ico', (req, res) => res.type('image/svg+xml').set('Cache-Control', 'public, max-age=86400').send(appIcon.favicon({ site: 'run' })));
@@ -128,7 +140,7 @@ function createApp(opts = {}) {
@@ -128,7 +140,7 @@ function createApp(opts = {}) {
return res.status(500).type('text/plain').send('Something went wrong\n');
return res.status(500).type('text/plain').send('Something went wrong\n');
});
});
- Object.assign(app.locals, { config, db, valkey, keys, outbox, events, stream, tickets, dispatch, apiAuth, metrics });
+ Object.assign(app.locals, { config, db, indexnow, valkey, keys, outbox, events, stream, tickets, dispatch, apiAuth, metrics });
return app;
return app;
}
}
@@ -67,6 +67,8 @@ function loadConfig(env = process.env) {
@@ -67,6 +67,8 @@ function loadConfig(env = process.env) {
clientId: env.OV_OAUTH_CLIENT_ID || 'run',
clientId: env.OV_OAUTH_CLIENT_ID || 'run',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
},
},
+ // IndexNow (openvibe-shared/indexnow): unset, nothing is sent and the key file is not served.
+ indexnow: { key: (env.INDEXNOW_KEY || '').trim() },
cookies: { secure: env.COOKIE_SECURE != null ? bool(env.COOKIE_SECURE) : isProduction },
cookies: { secure: env.COOKIE_SECURE != null ? bool(env.COOKIE_SECURE) : isProduction },
events: {
events: {
@@ -33,7 +33,7 @@ async function main() {
@@ -33,7 +33,7 @@ async function main() {
const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
const app = createApp({ config, db, valkey, registry });
const app = createApp({ config, db, valkey, registry });
- const { keys, outbox, stream, tickets, dispatch } = app.locals;
+ const { keys, outbox, stream, tickets, dispatch, indexnow } = app.locals;
keys.start();
keys.start();
const ticketKey = tickets.start();
const ticketKey = tickets.start();
@@ -50,6 +50,8 @@ async function main() {
@@ -50,6 +50,8 @@ async function main() {
console.log(`[Run] jobs ${config.jobs.enabled ? 'on' : 'off (RUN_JOBS=off)'}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (run.job.* events wait in the outbox)'}; stream tickets ${ticketKey.enabled ? `on (${config.stream.keyId}${ticketKey.ephemeral ? ', ephemeral key' : ''})` : 'off (no stream key)'}; dispatcher ${dispatch.enabled ? `on (poll ${config.dispatch.pollMs} ms): ${dispatch.note}` : `off: ${dispatch.note}`}; ${dispatch.status().usage_relay.note}`);
console.log(`[Run] jobs ${config.jobs.enabled ? 'on' : 'off (RUN_JOBS=off)'}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (run.job.* events wait in the outbox)'}; stream tickets ${ticketKey.enabled ? `on (${config.stream.keyId}${ticketKey.ephemeral ? ', ephemeral key' : ''})` : 'off (no stream key)'}; dispatcher ${dispatch.enabled ? `on (poll ${config.dispatch.pollMs} ms): ${dispatch.note}` : `off: ${dispatch.note}`}; ${dispatch.status().usage_relay.note}`);
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
+ // The public pages change with a deploy: tell the engines once, and only with INDEXNOW_KEY set.
+ require('./web/discovery').notify(indexnow, config);
// systemd sends SIGTERM (SIGINT by hand); openvibe-sdk/service's gracefulStop takes the signal, runs the
// systemd sends SIGTERM (SIGINT by hand); openvibe-sdk/service's gracefulStop takes the signal, runs the
// stop steps in order (nothing new starts), drains the HTTP server — event streams are destroyed, so a
// stop steps in order (nothing new starts), drains the HTTP server — event streams are destroyed, so a
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+'use strict';
+/**
+ * What openvibe.run tells crawlers about itself: /llms.txt and /llms-full.txt (openvibe-shared/seo) and the
+ * IndexNow notifier. Only the public pages are listed — never the API, the admin routes, a job or its stream.
+ */
+const seo = require('openvibe-shared/seo');
+const home = require('./home');
+
+const MAX_BYTES = 512 * 1024;
+const REPO = 'https://github.com/OpenVibers/OpenVibe.Run';
+const NODE_REPO = 'https://github.com/OpenVibers/OpenVibe.Node';
+
+// The public pages, in the order the sitemap lists them.
+const PAGES = ['/', '/terms', '/privacy', '/dmca'];
+
+function sections(base) {
+ return [
+ { title: 'Pages', links: [
+ { title: 'OpenVibe.Run', url: `${base}/`, note: 'what a job is, how it runs, and the API in brief' },
+ { title: 'Terms', url: `${base}/terms` },
+ { title: 'Privacy', url: `${base}/privacy` },
+ { title: 'DMCA', url: `${base}/dmca` },
+ ] },
+ { title: 'Docs', links: [
+ { title: 'OpenVibe.Run on GitHub', url: REPO, note: 'the source and the README: the job API, its states and its capabilities' },
+ { title: 'OpenVibe Node', url: NODE_REPO, note: 'pair your own machine to run jobs' },
+ ] },
+ ];
+}
+
+function llmsTxt(config) {
+ return seo.llmsTxt({ name: home.SITE_NAME, summary: home.DESCRIPTION, sections: sections(config.baseUrl) });
+}
+
+function llmsFull(config) {
+ const page = home.renderHome({ config });
+ const main = /<main id="main">([\s\S]*)<\/main>/.exec(page);
+ return seo.llmsFull({
+ site: home.SITE_NAME,
+ summary: home.DESCRIPTION,
+ base: config.baseUrl,
+ maxBytes: MAX_BYTES,
+ sections: [{ title: 'Pages', pages: [{ title: home.SITE_NAME, url: '/', html: main ? main[1] : '' }] }],
+ });
+}
+
+/**
+ * Tells search engines the public pages changed (once at boot; a deploy is what changes them). Sends only
+ * when IndexNow is enabled, i.e. INDEXNOW_KEY is set; returns whether anything was queued. Never throws.
+ */
+function notify(indexnow, config) {
+ try {
+ if (!indexnow || !indexnow.enabled) return false;
+ return indexnow.pingSoon([...PAGES, '/llms.txt', '/sitemap.xml'].map((p) => `${config.baseUrl}${p}`)) > 0;
+ } catch (err) {
+ console.warn('[IndexNow] notify failed:', err.message);
+ return false;
+ }
+}
+
+module.exports = { llmsTxt, llmsFull, notify, sections, MAX_BYTES, PAGES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+/**
+ * What openvibe.run tells crawlers: /llms.txt, /llms-full.txt and IndexNow (the key file and the boot ping,
+ * both only with INDEXNOW_KEY).
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+const { createIndexNow } = require('openvibe-shared/indexnow');
+const discovery = require('../server/web/discovery');
+
+const KEY = require('crypto').randomBytes(16).toString('hex');
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('/llms.txt answers 200 text/plain with the site name, and lists no API, admin, job or stream URL', async () => {
+ const r = await t.call('GET', '/llms.txt', { token: null });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /^text\/plain/);
+ assert.match(r.text, /^# OpenVibe\.Run\n/);
+ assert.ok(r.text.includes('http://run.test/terms'));
+ assert.ok(!/\/api\/|\/internal\/|\/admin|\/jobs|\/stream/.test(r.text), 'no machine route is listed');
+ });
+ await check('/llms-full.txt answers 200 text/plain within maxBytes and carries the front page text', async () => {
+ const r = await t.call('GET', '/llms-full.txt', { token: null });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /^text\/plain/);
+ assert.ok(Buffer.byteLength(r.text) <= discovery.MAX_BYTES);
+ assert.match(r.text, /^# OpenVibe\.Run\n/);
+ assert.match(r.text, /How a job runs/);
+ assert.ok(!/<[a-z]+[ >]/.test(r.text), 'plain text, no markup');
+ assert.ok(!/\/internal\/|\/admin/.test(r.text));
+ });
+ await check('without INDEXNOW_KEY the key route is 404 and no notify call is made', async () => {
+ assert.strictEqual(t.app.locals.indexnow.enabled, false);
+ assert.strictEqual((await t.call('GET', `/${KEY}.txt`, { token: null })).status, 404);
+ const sent = [];
+ const off = createIndexNow({ host: 'https://run.test', key: '', fetch: async (...a) => { sent.push(a); return { status: 200 }; } });
+ assert.strictEqual(discovery.notify(off, { baseUrl: 'https://run.test' }), false);
+ await off.flush();
+ await off.ping(['https://run.test/']);
+ assert.strictEqual(sent.length, 0);
+ });
+ } finally {
+ await t.close();
+ }
+
+ const sent = [];
+ const indexnow = createIndexNow({ host: 'https://run.test', key: KEY, fetch: async (url, init) => { sent.push(JSON.parse(init.body)); return { status: 200 }; } });
+ const k = await boot({ env: { INDEXNOW_KEY: KEY, BASE_URL: 'https://run.test' }, appOpts: { indexnow } });
+ try {
+ await check('with INDEXNOW_KEY the key route serves the key, and notify sends the public pages in one batch', async () => {
+ const r = await k.call('GET', `/${KEY}.txt`, { token: null });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.text, KEY);
+ assert.strictEqual((await k.call('GET', '/other-key.txt', { token: null })).status, 404);
+ assert.strictEqual(discovery.notify(k.app.locals.indexnow, k.app.locals.config), true);
+ await indexnow.flush();
+ assert.strictEqual(sent.length, 1);
+ assert.strictEqual(sent[0].key, KEY);
+ assert.strictEqual(sent[0].keyLocation, `https://run.test/${KEY}.txt`);
+ assert.deepStrictEqual(sent[0].urlList.slice(0, 2), ['https://run.test/', 'https://run.test/terms']);
+ assert.ok(sent[0].urlList.every((u) => !/\/api\/|\/internal\//.test(u)));
+ });
+ } finally {
+ await k.close();
+ }
+ done();
+})();