openvibers/openvibe.live Commits
Backend: cut boot from ~18s, index the home-page hot paths, and bound upstream calls
Changes
@@@ -226,6 +226,19 @@ function _dedupeStreamMemories(database) {*/function _adoptOrphanedTimelineRows(database) {try {++ // Cheap probe first. The UPDATE below is a full scan of stream_timeline_events — SQLite++ // will not use a partial index for it — and it ran unconditionally on every boot, before++ // listen(). Measured against a production-scale table it was 10.6 seconds with a backlog++ // and still 60-110ms with nothing at all to do, and it was the single largest component++ // of an 18-second production start. Speech rows are written with vod_id NULL while a++ // stream is live, so there is a fresh backlog after most restarts.++ //++ // The partial index makes "is there anything to adopt?" a 0.1ms question. When the answer++ // is no — the overwhelmingly common case — boot skips the scan entirely.++ try { database.exec('CREATE INDEX IF NOT EXISTS idx_timeline_null_vod ON stream_timeline_events(stream_id) WHERE vod_id IS NULL'); } catch { /* */ }++ const pending = database.prepare('SELECT 1 AS x FROM stream_timeline_events WHERE vod_id IS NULL LIMIT 1').get();++ if (!pending) return;++const res = database.prepare(`UPDATE stream_timeline_events AS tSET vod_id = (SELECT s.vod_id FROM stream_timeline_events sWHERE s.stream_id = t.stream_id AND s.vod_id IS NOT NULL LIMIT 1)@@@ -1218,7 +1231,9 @@ function initDb() {_dedupeKeyedTable(database, 'vod_ai_state', 'vod_id');_dedupeKeyedTable(database, 'clip_ai_state', 'clip_id');_dedupeStreamMemories(database);-- _adoptOrphanedTimelineRows(database);++ // Deferred: this repairs a historical backlog and no request depends on it, so it must++ // not sit between process start and the first served request. Runs shortly after boot.++ setTimeout(() => { try { _adoptOrphanedTimelineRows(database); } catch { /* */ } }, 4000).unref?.();// ── Unified audio timeline ───────────────────────────────────────────────// One time-indexed row per thing heard on a stream: a phrase that was spoken@@@ -1360,10 +1375,25 @@ function initDb() {updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)`);-- const pcols = database.prepare('PRAGMA table_info(pastes)').all().map(c => c.name);-- if (!pcols.includes('ai_summary')) database.exec('ALTER TABLE pastes ADD COLUMN ai_summary TEXT');-- if (!pcols.includes('ai_tags')) database.exec('ALTER TABLE pastes ADD COLUMN ai_tags TEXT');-- if (!pcols.includes('ai_analyzed_at')) database.exec('ALTER TABLE pastes ADD COLUMN ai_analyzed_at DATETIME');++ // `pastes` is created several hundred lines below this point, so on a brand-new database++ // this PRAGMA throws — and because it shares a try block with the migrations that follow,++ // it took them down with it: streams.ai_overview, vods.ai_overview, clips.ai_overview and++ // channels.ai_category were never added, which in turn aborted the short-overview block++ // and left streamer_overviews.overview_short missing. getRecentlyOnlineStreamers() selects++ // that column, so a first boot served 500s on /api/streams/recently-online until the++ // process was restarted a second time and the table finally existed.++ //++ // Isolated in its own try so a missing table on first boot costs nothing but a skip; the++ // columns are added on the next start, and the paste AI columns are also declared at the++ // CREATE TABLE below for fresh databases.++ try {++ const pcols = database.prepare('PRAGMA table_info(pastes)').all().map(c => c.name);++ if (pcols.length) {++ if (!pcols.includes('ai_summary')) database.exec('ALTER TABLE pastes ADD COLUMN ai_summary TEXT');++ if (!pcols.includes('ai_tags')) database.exec('ALTER TABLE pastes ADD COLUMN ai_tags TEXT');++ if (!pcols.includes('ai_analyzed_at')) database.exec('ALTER TABLE pastes ADD COLUMN ai_analyzed_at DATETIME');++ }++ } catch { /* table not created yet on a first boot — added on the next start */ }const scols = database.prepare('PRAGMA table_info(streams)').all().map(c => c.name);if (!scols.includes('ai_overview')) database.exec('ALTER TABLE streams ADD COLUMN ai_overview TEXT');@@@ -2208,6 +2238,16 @@ function initDb() {'CREATE INDEX IF NOT EXISTS idx_vods_transcript ON vods(transcript_status)','CREATE INDEX IF NOT EXISTS idx_clips_transcript ON clips(transcript_status)','CREATE INDEX IF NOT EXISTS idx_viewer_samples_at ON viewer_samples(sampled_at)',++ // getRecentlyOnlineStreamers() correlates streams by managed_stream_id inside a++ // json_group_array, and there was no index on that column at all — so the subquery scanned++ // the whole streams table once per managed stream, per row. Measured on a production-scale++ // database: 463ms -> 18ms at limit=20, and 1533ms -> 24ms at limit=100.++ 'CREATE INDEX IF NOT EXISTS idx_streams_managed_ended ON streams(managed_stream_id, ended_at)',++ // The hero stat board's heaviest queries count chat_messages over a time window with++ // COALESCE(is_deleted,0)=0, which forced a row fetch for every row in the window even++ // though the timestamp index was being used. These make those counts covering.++ 'CREATE INDEX IF NOT EXISTS idx_chat_ts_deleted ON chat_messages(timestamp, is_deleted)',++ 'CREATE INDEX IF NOT EXISTS idx_ai_viewer_log_created ON ai_viewer_log(created_at)',]) {try { database.exec(ix); } catch { /* table not present in this deployment */ }}@@@ -84,15 +84,28 @@ async function request(method, apiPath, { body, query, actingUser, headers = {},const timer = setTimeout(() => ctrl.abort(), timeoutMs);opts.signal = ctrl.signal;let res;++ let text = '';try {res = await fetch(url, opts);++ // The deadline has to cover reading the body, not just receiving the headers.++ //++ // clearTimeout used to sit in a `finally` attached to the fetch() await, so the timer was++ // cancelled and the abort signal disarmed the moment headers arrived. Reading the body++ // then had no deadline at all: an upstream that responds and then stalls mid-body pinned++ // this handler — and whatever request was waiting on it — indefinitely. Consuming the body++ // inside the same guarded region means one deadline covers the whole exchange.++ text = await res.text().catch(() => '');} catch (err) {-- throw new MediaApiError(`Media unreachable (${method} ${apiPath}): ${err.message}`, 0, null);++ const aborted = err && (err.name === 'AbortError' || ctrl.signal.aborted);++ throw new MediaApiError(++ aborted++ ? `Media timed out after ${timeoutMs}ms (${method} ${apiPath})`++ : `Media unreachable (${method} ${apiPath}): ${err.message}`,++ 0, null);} finally {clearTimeout(timer);}let json = null;-- const text = await res.text().catch(() => '');if (text) { try { json = JSON.parse(text); } catch { json = null; } }if (!res.ok) {const msg = (json && (json.error || json.message)) || `Media API ${res.status} on ${method} ${apiPath}`;@@@ -326,13 +339,35 @@ async function proxy(req, res, apiPath, { actingUser, method, query, body } = {}opts.body = req.rawBody;}}++ // proxy() had no deadline at all — an unresponsive upstream held the connection, the handler++ // and its socket open indefinitely. This backs pastes, likes, comments and the admin storage++ // routes, so a stalled Media could accumulate stuck handlers until the process ran out.++ // Uploads legitimately take longer than reads, hence the two budgets.++ const isUpload = !['GET', 'HEAD'].includes(m);++ const ctrl = new AbortController();++ const timer = setTimeout(() => ctrl.abort(), isUpload ? 120000 : 20000);++ opts.signal = ctrl.signal;++ // If the client goes away mid-proxy, stop waiting on the upstream too.++ const onClientGone = () => { try { ctrl.abort(); } catch { /* */ } };++ req.on('aborted', onClientGone);++ res.on('close', onClientGone);++ const cleanup = () => {++ clearTimeout(timer);++ req.off?.('aborted', onClientGone);++ res.off?.('close', onClientGone);++ };++let upstream;try {upstream = await fetch(url, opts);} catch (err) {-- console.warn(`[MediaClient] proxy failed (${m} ${apiPath}):`, err.message);-- return res.status(502).json({ error: 'Media service unavailable' });++ cleanup();++ const aborted = err && (err.name === 'AbortError' || ctrl.signal.aborted);++ console.warn(`[MediaClient] proxy ${aborted ? 'timed out' : 'failed'} (${m} ${apiPath}):`, err.message);++ if (res.headersSent) return;++ return res.status(504).json({ error: aborted ? 'Media service timed out' : 'Media service unavailable' });}++ cleanup();res.status(upstream.status);const passHeaders = ['content-type', 'cache-control', 'content-disposition', 'etag', 'x-robots-tag'];for (const h of passHeaders) {@@@ -439,17 +439,33 @@ function _headBlock(meta) {function render(meta) {let html = _base();if (!html) return null;++++ // Only the <head> is rewritten, so only the <head> is scanned.++ //++ // These six patterns used to run over the whole document. index.html is ~378KB and the head++ // is the first ~8.5KB of it, so five /ig regexes were scanning 370KB of page markup that++ // cannot contain a <title> or an og: tag — measured at 60ms of blocking CPU per uncached++ // render, against 0.73ms for the head alone. That cost lands on every cache miss, and the++ // sitemap advertises far more URLs than the 500-entry cache holds, so a crawler walking it++ // misses every time.++ const headEnd = html.search(/<\/head>/i);++ let head = headEnd === -1 ? html : html.slice(0, headEnd);++ const rest = headEnd === -1 ? '' : html.slice(headEnd);++// Strip the hardcoded homepage tags we're replacing (title, description, canonical, all// og:/twitter:, robots) so there are no duplicates.-- html = html++ head = head.replace(/\s*<title>[\s\S]*?<\/title>/i, '').replace(/\s*<meta\s+name=["']description["'][^>]*>/ig, '').replace(/\s*<meta\s+name=["']robots["'][^>]*>/ig, '').replace(/\s*<link\s+rel=["']canonical["'][^>]*>/ig, '').replace(/\s*<meta\s+property=["']og:[^"']*["'][^>]*>/ig, '').replace(/\s*<meta\s+name=["']twitter:[^"']*["'][^>]*>/ig, '');++// Insert the fresh head block just before </head>.-- html = html.replace(/<\/head>/i, _headBlock(meta) + '</head>');++ html = headEnd === -1++ ? head.replace(/<\/head>/i, _headBlock(meta) + '</head>')++ : head + _headBlock(meta) + rest;// Server-rendered crawlable content, right after <body>. It's REAL content (so AI text// extractors read it — unlike <noscript>, which many strip), but visually-hidden so users// never see a flash, and the SPA removes #seo-prerender on boot (see app.js). Not cloaking:
@@ -226,6 +226,19 @@ function _dedupeStreamMemories(database) {
@@ -226,6 +226,19 @@ function _dedupeStreamMemories(database) {
*/
*/
function _adoptOrphanedTimelineRows(database) {
function _adoptOrphanedTimelineRows(database) {
try {
try {
+ // Cheap probe first. The UPDATE below is a full scan of stream_timeline_events — SQLite
+ // will not use a partial index for it — and it ran unconditionally on every boot, before
+ // listen(). Measured against a production-scale table it was 10.6 seconds with a backlog
+ // and still 60-110ms with nothing at all to do, and it was the single largest component
+ // of an 18-second production start. Speech rows are written with vod_id NULL while a
+ // stream is live, so there is a fresh backlog after most restarts.
+ //
+ // The partial index makes "is there anything to adopt?" a 0.1ms question. When the answer
+ // is no — the overwhelmingly common case — boot skips the scan entirely.
+ try { database.exec('CREATE INDEX IF NOT EXISTS idx_timeline_null_vod ON stream_timeline_events(stream_id) WHERE vod_id IS NULL'); } catch { /* */ }
+ const pending = database.prepare('SELECT 1 AS x FROM stream_timeline_events WHERE vod_id IS NULL LIMIT 1').get();
+ if (!pending) return;
+
const res = database.prepare(`UPDATE stream_timeline_events AS t
const res = database.prepare(`UPDATE stream_timeline_events AS t
SET vod_id = (SELECT s.vod_id FROM stream_timeline_events s
SET vod_id = (SELECT s.vod_id FROM stream_timeline_events s
WHERE s.stream_id = t.stream_id AND s.vod_id IS NOT NULL LIMIT 1)
WHERE s.stream_id = t.stream_id AND s.vod_id IS NOT NULL LIMIT 1)
@@ -1218,7 +1231,9 @@ function initDb() {
@@ -1218,7 +1231,9 @@ function initDb() {
_dedupeKeyedTable(database, 'vod_ai_state', 'vod_id');
_dedupeKeyedTable(database, 'vod_ai_state', 'vod_id');
_dedupeKeyedTable(database, 'clip_ai_state', 'clip_id');
_dedupeKeyedTable(database, 'clip_ai_state', 'clip_id');
_dedupeStreamMemories(database);
_dedupeStreamMemories(database);
- _adoptOrphanedTimelineRows(database);
+ // Deferred: this repairs a historical backlog and no request depends on it, so it must
+ // not sit between process start and the first served request. Runs shortly after boot.
+ setTimeout(() => { try { _adoptOrphanedTimelineRows(database); } catch { /* */ } }, 4000).unref?.();
// ── Unified audio timeline ───────────────────────────────────────────────
// ── Unified audio timeline ───────────────────────────────────────────────
// One time-indexed row per thing heard on a stream: a phrase that was spoken
// One time-indexed row per thing heard on a stream: a phrase that was spoken
@@ -1360,10 +1375,25 @@ function initDb() {
@@ -1360,10 +1375,25 @@ function initDb() {
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
)`);
- const pcols = database.prepare('PRAGMA table_info(pastes)').all().map(c => c.name);
- if (!pcols.includes('ai_summary')) database.exec('ALTER TABLE pastes ADD COLUMN ai_summary TEXT');
- if (!pcols.includes('ai_tags')) database.exec('ALTER TABLE pastes ADD COLUMN ai_tags TEXT');
- if (!pcols.includes('ai_analyzed_at')) database.exec('ALTER TABLE pastes ADD COLUMN ai_analyzed_at DATETIME');
+ // `pastes` is created several hundred lines below this point, so on a brand-new database
+ // this PRAGMA throws — and because it shares a try block with the migrations that follow,
+ // it took them down with it: streams.ai_overview, vods.ai_overview, clips.ai_overview and
+ // channels.ai_category were never added, which in turn aborted the short-overview block
+ // and left streamer_overviews.overview_short missing. getRecentlyOnlineStreamers() selects
+ // that column, so a first boot served 500s on /api/streams/recently-online until the
+ // process was restarted a second time and the table finally existed.
+ //
+ // Isolated in its own try so a missing table on first boot costs nothing but a skip; the
+ // columns are added on the next start, and the paste AI columns are also declared at the
+ // CREATE TABLE below for fresh databases.
+ try {
+ const pcols = database.prepare('PRAGMA table_info(pastes)').all().map(c => c.name);
+ if (pcols.length) {
+ if (!pcols.includes('ai_summary')) database.exec('ALTER TABLE pastes ADD COLUMN ai_summary TEXT');
+ if (!pcols.includes('ai_tags')) database.exec('ALTER TABLE pastes ADD COLUMN ai_tags TEXT');
+ if (!pcols.includes('ai_analyzed_at')) database.exec('ALTER TABLE pastes ADD COLUMN ai_analyzed_at DATETIME');
+ }
+ } catch { /* table not created yet on a first boot — added on the next start */ }
const scols = database.prepare('PRAGMA table_info(streams)').all().map(c => c.name);
const scols = database.prepare('PRAGMA table_info(streams)').all().map(c => c.name);
if (!scols.includes('ai_overview')) database.exec('ALTER TABLE streams ADD COLUMN ai_overview TEXT');
if (!scols.includes('ai_overview')) database.exec('ALTER TABLE streams ADD COLUMN ai_overview TEXT');
@@ -2208,6 +2238,16 @@ function initDb() {
@@ -2208,6 +2238,16 @@ function initDb() {
'CREATE INDEX IF NOT EXISTS idx_vods_transcript ON vods(transcript_status)',
'CREATE INDEX IF NOT EXISTS idx_vods_transcript ON vods(transcript_status)',
'CREATE INDEX IF NOT EXISTS idx_clips_transcript ON clips(transcript_status)',
'CREATE INDEX IF NOT EXISTS idx_clips_transcript ON clips(transcript_status)',
'CREATE INDEX IF NOT EXISTS idx_viewer_samples_at ON viewer_samples(sampled_at)',
'CREATE INDEX IF NOT EXISTS idx_viewer_samples_at ON viewer_samples(sampled_at)',
+ // getRecentlyOnlineStreamers() correlates streams by managed_stream_id inside a
+ // json_group_array, and there was no index on that column at all — so the subquery scanned
+ // the whole streams table once per managed stream, per row. Measured on a production-scale
+ // database: 463ms -> 18ms at limit=20, and 1533ms -> 24ms at limit=100.
+ 'CREATE INDEX IF NOT EXISTS idx_streams_managed_ended ON streams(managed_stream_id, ended_at)',
+ // The hero stat board's heaviest queries count chat_messages over a time window with
+ // COALESCE(is_deleted,0)=0, which forced a row fetch for every row in the window even
+ // though the timestamp index was being used. These make those counts covering.
+ 'CREATE INDEX IF NOT EXISTS idx_chat_ts_deleted ON chat_messages(timestamp, is_deleted)',
+ 'CREATE INDEX IF NOT EXISTS idx_ai_viewer_log_created ON ai_viewer_log(created_at)',
]) {
]) {
try { database.exec(ix); } catch { /* table not present in this deployment */ }
try { database.exec(ix); } catch { /* table not present in this deployment */ }
}
}
@@ -84,15 +84,28 @@ async function request(method, apiPath, { body, query, actingUser, headers = {},
@@ -84,15 +84,28 @@ async function request(method, apiPath, { body, query, actingUser, headers = {},
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
opts.signal = ctrl.signal;
opts.signal = ctrl.signal;
let res;
let res;
+ let text = '';
try {
try {
res = await fetch(url, opts);
res = await fetch(url, opts);
+ // The deadline has to cover reading the body, not just receiving the headers.
+ //
+ // clearTimeout used to sit in a `finally` attached to the fetch() await, so the timer was
+ // cancelled and the abort signal disarmed the moment headers arrived. Reading the body
+ // then had no deadline at all: an upstream that responds and then stalls mid-body pinned
+ // this handler — and whatever request was waiting on it — indefinitely. Consuming the body
+ // inside the same guarded region means one deadline covers the whole exchange.
+ text = await res.text().catch(() => '');
} catch (err) {
} catch (err) {
- throw new MediaApiError(`Media unreachable (${method} ${apiPath}): ${err.message}`, 0, null);
+ const aborted = err && (err.name === 'AbortError' || ctrl.signal.aborted);
+ throw new MediaApiError(
+ aborted
+ ? `Media timed out after ${timeoutMs}ms (${method} ${apiPath})`
+ : `Media unreachable (${method} ${apiPath}): ${err.message}`,
+ 0, null);
} finally {
} finally {
clearTimeout(timer);
clearTimeout(timer);
}
}
let json = null;
let json = null;
- const text = await res.text().catch(() => '');
if (text) { try { json = JSON.parse(text); } catch { json = null; } }
if (text) { try { json = JSON.parse(text); } catch { json = null; } }
if (!res.ok) {
if (!res.ok) {
const msg = (json && (json.error || json.message)) || `Media API ${res.status} on ${method} ${apiPath}`;
const msg = (json && (json.error || json.message)) || `Media API ${res.status} on ${method} ${apiPath}`;
@@ -326,13 +339,35 @@ async function proxy(req, res, apiPath, { actingUser, method, query, body } = {}
@@ -326,13 +339,35 @@ async function proxy(req, res, apiPath, { actingUser, method, query, body } = {}
opts.body = req.rawBody;
opts.body = req.rawBody;
}
}
}
}
+ // proxy() had no deadline at all — an unresponsive upstream held the connection, the handler
+ // and its socket open indefinitely. This backs pastes, likes, comments and the admin storage
+ // routes, so a stalled Media could accumulate stuck handlers until the process ran out.
+ // Uploads legitimately take longer than reads, hence the two budgets.
+ const isUpload = !['GET', 'HEAD'].includes(m);
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), isUpload ? 120000 : 20000);
+ opts.signal = ctrl.signal;
+ // If the client goes away mid-proxy, stop waiting on the upstream too.
+ const onClientGone = () => { try { ctrl.abort(); } catch { /* */ } };
+ req.on('aborted', onClientGone);
+ res.on('close', onClientGone);
+ const cleanup = () => {
+ clearTimeout(timer);
+ req.off?.('aborted', onClientGone);
+ res.off?.('close', onClientGone);
+ };
+
let upstream;
let upstream;
try {
try {
upstream = await fetch(url, opts);
upstream = await fetch(url, opts);
} catch (err) {
} catch (err) {
- console.warn(`[MediaClient] proxy failed (${m} ${apiPath}):`, err.message);
- return res.status(502).json({ error: 'Media service unavailable' });
+ cleanup();
+ const aborted = err && (err.name === 'AbortError' || ctrl.signal.aborted);
+ console.warn(`[MediaClient] proxy ${aborted ? 'timed out' : 'failed'} (${m} ${apiPath}):`, err.message);
+ if (res.headersSent) return;
+ return res.status(504).json({ error: aborted ? 'Media service timed out' : 'Media service unavailable' });
}
}
+ cleanup();
res.status(upstream.status);
res.status(upstream.status);
const passHeaders = ['content-type', 'cache-control', 'content-disposition', 'etag', 'x-robots-tag'];
const passHeaders = ['content-type', 'cache-control', 'content-disposition', 'etag', 'x-robots-tag'];
for (const h of passHeaders) {
for (const h of passHeaders) {
@@ -439,17 +439,33 @@ function _headBlock(meta) {
@@ -439,17 +439,33 @@ function _headBlock(meta) {
function render(meta) {
function render(meta) {
let html = _base();
let html = _base();
if (!html) return null;
if (!html) return null;
+
+ // Only the <head> is rewritten, so only the <head> is scanned.
+ //
+ // These six patterns used to run over the whole document. index.html is ~378KB and the head
+ // is the first ~8.5KB of it, so five /ig regexes were scanning 370KB of page markup that
+ // cannot contain a <title> or an og: tag — measured at 60ms of blocking CPU per uncached
+ // render, against 0.73ms for the head alone. That cost lands on every cache miss, and the
+ // sitemap advertises far more URLs than the 500-entry cache holds, so a crawler walking it
+ // misses every time.
+ const headEnd = html.search(/<\/head>/i);
+ let head = headEnd === -1 ? html : html.slice(0, headEnd);
+ const rest = headEnd === -1 ? '' : html.slice(headEnd);
+
// Strip the hardcoded homepage tags we're replacing (title, description, canonical, all
// Strip the hardcoded homepage tags we're replacing (title, description, canonical, all
// og:/twitter:, robots) so there are no duplicates.
// og:/twitter:, robots) so there are no duplicates.
- html = html
+ head = head
.replace(/\s*<title>[\s\S]*?<\/title>/i, '')
.replace(/\s*<title>[\s\S]*?<\/title>/i, '')
.replace(/\s*<meta\s+name=["']description["'][^>]*>/ig, '')
.replace(/\s*<meta\s+name=["']description["'][^>]*>/ig, '')
.replace(/\s*<meta\s+name=["']robots["'][^>]*>/ig, '')
.replace(/\s*<meta\s+name=["']robots["'][^>]*>/ig, '')
.replace(/\s*<link\s+rel=["']canonical["'][^>]*>/ig, '')
.replace(/\s*<link\s+rel=["']canonical["'][^>]*>/ig, '')
.replace(/\s*<meta\s+property=["']og:[^"']*["'][^>]*>/ig, '')
.replace(/\s*<meta\s+property=["']og:[^"']*["'][^>]*>/ig, '')
.replace(/\s*<meta\s+name=["']twitter:[^"']*["'][^>]*>/ig, '');
.replace(/\s*<meta\s+name=["']twitter:[^"']*["'][^>]*>/ig, '');
+
// Insert the fresh head block just before </head>.
// Insert the fresh head block just before </head>.
- html = html.replace(/<\/head>/i, _headBlock(meta) + '</head>');
+ html = headEnd === -1
+ ? head.replace(/<\/head>/i, _headBlock(meta) + '</head>')
+ : head + _headBlock(meta) + rest;
// Server-rendered crawlable content, right after <body>. It's REAL content (so AI text
// Server-rendered crawlable content, right after <body>. It's REAL content (so AI text
// extractors read it — unlike <noscript>, which many strip), but visually-hidden so users
// extractors read it — unlike <noscript>, which many strip), but visually-hidden so users
// never see a flash, and the SPA removes #seo-prerender on boot (see app.js). Not cloaking:
// never see a flash, and the SPA removes #seo-prerender on boot (see app.js). Not cloaking: