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 { photosModule } from "../modules/photos/mod";
|
||||||
import { spaces } from "./spaces";
|
import { spaces } from "./spaces";
|
||||||
import { renderShell, renderModuleLanding } from "./shell";
|
import { renderShell, renderModuleLanding } from "./shell";
|
||||||
|
import { fetchLandingPage } from "./landing-proxy";
|
||||||
import { syncServer } from "./sync-instance";
|
import { syncServer } from "./sync-instance";
|
||||||
import { loadAllDocs } from "./local-first/doc-persistence";
|
import { loadAllDocs } from "./local-first/doc-persistence";
|
||||||
|
|
||||||
|
|
@ -788,7 +789,14 @@ const server = Bun.serve<WSData>({
|
||||||
const mod = allModules.find((m) => m.id === firstSegment);
|
const mod = allModules.find((m) => m.id === firstSegment);
|
||||||
if (mod) {
|
if (mod) {
|
||||||
if (pathSegments.length === 1) {
|
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({
|
const html = renderModuleLanding({
|
||||||
module: mod,
|
module: mod,
|
||||||
modules: getModuleInfoList(),
|
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; } }
|
@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, """);
|
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, ">");
|
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue