diff --git a/server/index.ts b/server/index.ts index 724ed89..bef3e66 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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({ 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(), diff --git a/server/landing-proxy.ts b/server/landing-proxy.ts new file mode 100644 index 0000000..4bbc8c7 --- /dev/null +++ b/server/landing-proxy.ts @@ -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(); +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 { + const map = new Map(); + 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 `
+
+ +
+
+
+ Try Demo + +
+
+`; +} + +// ── Main export ── + +export async function fetchLandingPage( + mod: { id: string; standaloneDomain?: string }, + modules: ModuleInfo[], +): Promise { + 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 { + 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 `, + { html: true }, + ); + }, + }); + + // 5. If no