rspace-online/server/landing-proxy.ts

207 lines
5.6 KiB
TypeScript

/**
* Landing page proxy.
*
* Fetches pre-rendered HTML from standalone r*.online domains, transforms it
* with Bun's HTMLRewriter (rewrite asset URLs, strip scripts, inject rSpace
* shell header), caches the result, and serves it on rspace.online/{moduleId}.
*
* Falls back to the generic landing page if the fetch fails.
*/
import type { ModuleInfo } from "../shared/module";
import { escapeHtml, escapeAttr } from "./shell";
// ── Cache ──
interface CacheEntry {
html: string;
fetchedAt: number;
}
const cache = new Map<string, CacheEntry>();
const CACHE_TTL = 10 * 60 * 1000; // 10 minutes
// ── Ecosystem link rewriting ──
/** Map standalone domains → rspace.online paths for footer links */
function buildEcosystemMap(modules: ModuleInfo[]): Map<string, string> {
const map = new Map<string, string>();
for (const m of modules) {
if (m.standaloneDomain) {
map.set(`https://${m.standaloneDomain}`, `https://rspace.online/${m.id}`);
// Also catch without trailing slash
map.set(`https://www.${m.standaloneDomain}`, `https://rspace.online/${m.id}`);
}
}
return map;
}
// ── Shell header HTML ──
function renderShellHeader(moduleId: string, modules: ModuleInfo[]): string {
const moduleListJSON = JSON.stringify(modules);
const demoUrl = `https://demo.rspace.online/${escapeAttr(moduleId)}`;
return `<header class="rstack-header" data-theme="dark">
<div class="rstack-header__left">
<rstack-app-switcher current="${escapeAttr(moduleId)}"></rstack-app-switcher>
</div>
<div class="rstack-header__center"></div>
<div class="rstack-header__right">
<a class="rstack-header__demo-btn" href="${demoUrl}">Try Demo</a>
<rstack-identity></rstack-identity>
</div>
</header>
<script type="module">
import '/shell.js';
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
// Logged-in users: redirect CTA to personal space
try {
var raw = localStorage.getItem('encryptid_session');
if (raw) {
var session = JSON.parse(raw);
if (session?.claims?.username) {
var username = session.claims.username.toLowerCase();
var btn = document.querySelector('.rstack-header__demo-btn');
if (btn) {
btn.textContent = 'Go to My Space';
btn.href = 'https://' + username + '.rspace.online/${escapeAttr(moduleId)}';
}
}
}
} catch(e) {}
</script>`;
}
// ── Main export ──
export async function fetchLandingPage(
mod: { id: string; standaloneDomain?: string },
modules: ModuleInfo[],
): Promise<string | null> {
const domain = mod.standaloneDomain;
if (!domain) return null;
const now = Date.now();
const cached = cache.get(domain);
// Return fresh cache
if (cached && now - cached.fetchedAt < CACHE_TTL) {
return cached.html;
}
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(`https://${domain}`, {
headers: { "User-Agent": "rSpace-Proxy/1.0" },
signal: controller.signal,
});
clearTimeout(timeout);
if (!res.ok) {
// Return stale cache if available
if (cached) return cached.html;
return null;
}
const rawHtml = await res.text();
// Transform with HTMLRewriter
const html = await transformWithRewriter(rawHtml, domain, mod.id, modules);
cache.set(domain, { html, fetchedAt: now });
return html;
} catch (e) {
// Network error / timeout — return stale cache if available
if (cached) return cached.html;
console.error(`[LandingProxy] Failed to fetch ${domain}:`, (e as Error).message);
return null;
}
}
/** Async HTMLRewriter transform (Bun's HTMLRewriter works on Response objects) */
async function transformWithRewriter(
rawHtml: string,
domain: string,
moduleId: string,
modules: ModuleInfo[],
): Promise<string> {
const ecosystemMap = buildEcosystemMap(modules);
const origin = `https://${domain}`;
let headerInjected = false;
const rewriter = new HTMLRewriter();
// 1. Rewrite _next asset URLs to absolute (CSS, fonts, images)
rewriter.on('link[href^="/_next/"]', {
element(el) {
const href = el.getAttribute("href");
if (href) el.setAttribute("href", `${origin}${href}`);
},
});
rewriter.on('img[src^="/_next/"]', {
element(el) {
const src = el.getAttribute("src");
if (src) el.setAttribute("src", `${origin}${src}`);
},
});
// 2. Strip all <script> tags
rewriter.on("script", {
element(el) {
el.remove();
},
});
// 3. Replace first <nav> with rSpace shell header
rewriter.on("nav", {
element(el) {
if (!headerInjected) {
headerInjected = true;
el.replace(renderShellHeader(moduleId, modules), { html: true });
}
},
});
// 4. Inject shell.css + analytics into <head>
rewriter.on("head", {
element(el) {
el.append(
`<link rel="stylesheet" href="/shell.css">
<script defer src="https://rdata.online/collect.js" data-website-id="6ee7917b-0ed7-44cb-a4c8-91037638526b"></script>`,
{ html: true },
);
},
});
// 5. If no <nav> was found, prepend shell header to <body>
rewriter.on("body", {
element(el) {
if (!headerInjected) {
headerInjected = true;
el.prepend(renderShellHeader(moduleId, modules), { html: true });
}
},
});
// 6. Rewrite ecosystem links (standalone domain URLs → rspace.online paths)
rewriter.on("a[href]", {
element(el) {
const href = el.getAttribute("href");
if (href) {
for (const [standaloneUrl, rspaceUrl] of ecosystemMap) {
if (href === standaloneUrl || href === standaloneUrl + "/") {
el.setAttribute("href", rspaceUrl);
break;
}
}
}
},
});
const transformed = rewriter.transform(new Response(rawHtml));
return transformed.text();
}