feat: bare-domain module routing — rspace.online/{moduleId} as default

App dropdown links now go to rspace.online/r* (bare domain) instead of
demo.rspace.online/r*. Only the "Try Demo" button links to the explicit
demo subdomain. Server internally rewrites bare-domain module paths to
/demo/{moduleId} while preserving the browser URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-25 19:22:06 -08:00
parent 92bec8243d
commit 1bedc4e504
3 changed files with 60 additions and 16 deletions

View File

@ -779,6 +779,24 @@ const server = Bun.serve<WSData>({
return app.fetch(rewrittenReq); return app.fetch(rewrittenReq);
} }
// ── Bare-domain module routes: rspace.online/{moduleId} → internal rewrite ──
// When on the bare domain (no subdomain), if the first path segment is a
// known module ID, rewrite internally to /demo/{moduleId}/... so Hono's
// /:space/:moduleId routes handle it. The browser URL stays as-is.
if (!subdomain && hostClean.includes("rspace.online")) {
const pathSegments = url.pathname.split("/").filter(Boolean);
if (pathSegments.length >= 1) {
const firstSegment = pathSegments[0];
const knownModuleIds = new Set(getAllModules().map((m) => m.id));
if (knownModuleIds.has(firstSegment)) {
const rewrittenPath = `/demo${url.pathname}`;
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
const rewrittenReq = new Request(rewrittenUrl, req);
return app.fetch(rewrittenReq);
}
}
}
// ── Hono handles everything else ── // ── Hono handles everything else ──
const response = await app.fetch(req); const response = await app.fetch(req);

View File

@ -78,7 +78,7 @@ export function renderShell(opts: ShellOptions): string {
<rstack-mi></rstack-mi> <rstack-mi></rstack-mi>
</div> </div>
<div class="rstack-header__right"> <div class="rstack-header__right">
${spaceSlug !== "demo" ? `<a class="rstack-header__demo-btn" href="https://demo.rspace.online/${escapeAttr(moduleId)}">Try Demo</a>` : ""} <a class="rstack-header__demo-btn" ${spaceSlug === "demo" ? 'data-hide' : ''} href="https://demo.rspace.online/${escapeAttr(moduleId)}">Try Demo</a>
<rstack-identity></rstack-identity> <rstack-identity></rstack-identity>
</div> </div>
</header> </header>
@ -96,6 +96,17 @@ export function renderShell(opts: ShellOptions): string {
// Provide module list to app switcher // Provide module list to app switcher
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON}); document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
// ── Bare-domain "Try Demo" button visibility ──
// On rspace.online (bare domain), the server internally rewrites to demo space,
// but we still want the "Try Demo" button visible since it links to the explicit demo subdomain.
(function() {
var host = window.location.host.split(':')[0];
if (host === 'rspace.online' || host === 'www.rspace.online') {
var btn = document.querySelector('.rstack-header__demo-btn');
if (btn) btn.removeAttribute('data-hide');
}
})();
// ── Auto-space resolution ── // ── Auto-space resolution ──
// Logged-in users on demo space → redirect to personal space // Logged-in users on demo space → redirect to personal space
(function() { (function() {

View File

@ -2,6 +2,7 @@
* Subdomain-aware URL helpers for rSpace navigation. * Subdomain-aware URL helpers for rSpace navigation.
* *
* Canonical URL pattern: {space}.rspace.online/{moduleId} * Canonical URL pattern: {space}.rspace.online/{moduleId}
* Bare domain pattern: rspace.online/{moduleId} (implicitly demo space)
* Fallback (localhost): /{space}/{moduleId} * Fallback (localhost): /{space}/{moduleId}
*/ */
@ -17,26 +18,37 @@ export function isSubdomain(): boolean {
); );
} }
/** Detect if the current page is on the bare rspace.online domain (no subdomain) */
export function isBareDomain(): boolean {
const host = window.location.host.split(":")[0];
return host === "rspace.online" || host === "www.rspace.online";
}
/** Get the current space from subdomain or path */ /** Get the current space from subdomain or path */
export function getCurrentSpace(): string { export function getCurrentSpace(): string {
const hostParts = window.location.host.split(":")[0].split("."); if (isSubdomain()) {
if ( return window.location.host.split(":")[0].split(".")[0];
hostParts.length >= 3 &&
hostParts.slice(-2).join(".") === "rspace.online" &&
!RESERVED_SUBDOMAINS.includes(hostParts[0])
) {
return hostParts[0];
} }
// Bare domain: space is implicit (demo by default, until auto-provision)
if (isBareDomain()) {
return "demo";
}
// Path-based (localhost): /{space}/{moduleId}
const pathParts = window.location.pathname.split("/").filter(Boolean); const pathParts = window.location.pathname.split("/").filter(Boolean);
return pathParts[0] || "demo"; return pathParts[0] || "demo";
} }
/** Get the current module from the path (works for both subdomain and path routing) */ /** Get the current module from the path (works for subdomain, bare domain, and path routing) */
export function getCurrentModule(): string { export function getCurrentModule(): string {
const parts = window.location.pathname.split("/").filter(Boolean); const parts = window.location.pathname.split("/").filter(Boolean);
if (isSubdomain()) { if (isSubdomain()) {
return parts[0] || "rspace"; return parts[0] || "rspace";
} }
// Bare domain: path is /{moduleId}
if (isBareDomain()) {
return parts[0] || "rspace";
}
// Path-based (localhost): /{space}/{moduleId}
return parts[1] || "rspace"; return parts[1] || "rspace";
} }
@ -45,7 +57,9 @@ export function getCurrentModule(): string {
* *
* On subdomains: same-space links use /{moduleId}, cross-space links * On subdomains: same-space links use /{moduleId}, cross-space links
* switch the subdomain to {newSpace}.rspace.online/{moduleId}. * switch the subdomain to {newSpace}.rspace.online/{moduleId}.
* On bare domain or localhost: uses /{space}/{moduleId}. * On bare domain (rspace.online): stays on bare domain as /{moduleId}
* for default (demo) space, subdomain for explicit spaces.
* On localhost: uses /{space}/{moduleId}.
*/ */
export function rspaceNavUrl(space: string, moduleId: string): string { export function rspaceNavUrl(space: string, moduleId: string): string {
const hostParts = window.location.host.split(":")[0].split("."); const hostParts = window.location.host.split(":")[0].split(".");
@ -64,12 +78,13 @@ export function rspaceNavUrl(space: string, moduleId: string): string {
return `${window.location.protocol}//${space}.${baseDomain}/${moduleId}`; return `${window.location.protocol}//${space}.${baseDomain}/${moduleId}`;
} }
// Bare domain (rspace.online) or localhost → use /{space}/{moduleId} // Bare domain (rspace.online)
const onRspace = if (isBareDomain()) {
window.location.host.includes("rspace.online") && // Default space → stay on bare domain: /{moduleId}
!window.location.host.startsWith("www"); if (space === "demo") {
if (onRspace) { return `/${moduleId}`;
// Prefer subdomain routing }
// Explicit space → switch to subdomain
return `${window.location.protocol}//${space}.rspace.online/${moduleId}`; return `${window.location.protocol}//${space}.rspace.online/${moduleId}`;
} }