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 5ae8aeec02
commit a732478f85
3 changed files with 60 additions and 16 deletions

View File

@ -779,6 +779,24 @@ const server = Bun.serve<WSData>({
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 ──
const response = await app.fetch(req);

View File

@ -78,7 +78,7 @@ export function renderShell(opts: ShellOptions): string {
<rstack-mi></rstack-mi>
</div>
<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>
</div>
</header>
@ -96,6 +96,17 @@ export function renderShell(opts: ShellOptions): string {
// Provide module list to app switcher
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 ──
// Logged-in users on demo space → redirect to personal space
(function() {

View File

@ -2,6 +2,7 @@
* Subdomain-aware URL helpers for rSpace navigation.
*
* Canonical URL pattern: {space}.rspace.online/{moduleId}
* Bare domain pattern: rspace.online/{moduleId} (implicitly demo space)
* 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 */
export function getCurrentSpace(): string {
const hostParts = window.location.host.split(":")[0].split(".");
if (
hostParts.length >= 3 &&
hostParts.slice(-2).join(".") === "rspace.online" &&
!RESERVED_SUBDOMAINS.includes(hostParts[0])
) {
return hostParts[0];
if (isSubdomain()) {
return window.location.host.split(":")[0].split(".")[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);
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 {
const parts = window.location.pathname.split("/").filter(Boolean);
if (isSubdomain()) {
return parts[0] || "rspace";
}
// Bare domain: path is /{moduleId}
if (isBareDomain()) {
return parts[0] || "rspace";
}
// Path-based (localhost): /{space}/{moduleId}
return parts[1] || "rspace";
}
@ -45,7 +57,9 @@ export function getCurrentModule(): string {
*
* On subdomains: same-space links use /{moduleId}, cross-space links
* 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 {
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}`;
}
// Bare domain (rspace.online) or localhost → use /{space}/{moduleId}
const onRspace =
window.location.host.includes("rspace.online") &&
!window.location.host.startsWith("www");
if (onRspace) {
// Prefer subdomain routing
// Bare domain (rspace.online)
if (isBareDomain()) {
// Default space → stay on bare domain: /{moduleId}
if (space === "demo") {
return `/${moduleId}`;
}
// Explicit space → switch to subdomain
return `${window.location.protocol}//${space}.rspace.online/${moduleId}`;
}