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:
parent
a2f0752fed
commit
25643060e0
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
export function escapeAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue