feat: proxy rich r*.online landing pages to rspace.online/{moduleId}

Fetches pre-rendered HTML from standalone domains at request time,
transforms with HTMLRewriter (strip scripts, rewrite asset URLs,
inject rSpace shell header), caches 10min with stale-on-error fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-27 13:31:24 -08:00
parent a2f0752fed
commit 25643060e0
3 changed files with 217 additions and 3 deletions

View File

@ -64,6 +64,7 @@ import { splatModule } from "../modules/splat/mod";
import { photosModule } from "../modules/photos/mod";
import { spaces } from "./spaces";
import { renderShell, renderModuleLanding } from "./shell";
import { fetchLandingPage } from "./landing-proxy";
import { syncServer } from "./sync-instance";
import { loadAllDocs } from "./local-first/doc-persistence";
@ -788,7 +789,14 @@ const server = Bun.serve<WSData>({
const mod = allModules.find((m) => m.id === firstSegment);
if (mod) {
if (pathSegments.length === 1) {
// Exact module path → show landing page
// Try proxying the rich standalone landing page
const proxyHtml = await fetchLandingPage(mod, getModuleInfoList());
if (proxyHtml) {
return new Response(proxyHtml, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
// Fallback to generic landing page
const html = renderModuleLanding({
module: mod,
modules: getModuleInfoList(),

206
server/landing-proxy.ts Normal file
View File

@ -0,0 +1,206 @@
/**
* 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();
}

View File

@ -526,10 +526,10 @@ body {
@media (max-width: 600px) { .ml-name { font-size: 2rem; } .ml-icon { font-size: 3rem; } }
`;
function escapeHtml(s: string): string {
export function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function escapeAttr(s: string): string {
export function escapeAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}