207 lines
5.6 KiB
TypeScript
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();
|
|
}
|