feat: canonical subdomain routing — {space}.rspace.online/{moduleId}

Consolidate URL routing so all rApps flow through
{space}.rspace.online/{moduleId} as the canonical URL pattern.

- Subdomain handler now routes all modules (not just canvas)
- Standalone domains (rvote.online etc) → 301 redirect to canonical
- Add shared/url-helpers.ts for subdomain-aware URL generation
- Update app-switcher, space-switcher, identity, tab-bar navigation
- Shell inline scripts use __rspaceNavUrl for all URL generation
- Path-based rspace.online/:space/:moduleId still works as fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-25 16:24:36 -08:00
parent eba0aafc6e
commit eab24e2e39
7 changed files with 154 additions and 67 deletions

View File

@ -645,10 +645,10 @@ const server = Bun.serve<WSData>({
const hostClean = host?.split(":")[0] || "";
const subdomain = getSubdomain(host);
// ── Standalone domain rewrite ──
// ── Standalone domain → 301 redirect to canonical subdomain URL ──
const standaloneModuleId = domainToModule.get(hostClean);
if (standaloneModuleId && !keepStandalone.has(hostClean)) {
// WebSocket upgrade for standalone domains
// WebSocket: rewrite for backward compat (WS can't follow redirects)
if (url.pathname.startsWith("/ws/")) {
const communitySlug = url.pathname.split("/")[2];
if (communitySlug) {
@ -673,17 +673,7 @@ const server = Bun.serve<WSData>({
return new Response("WebSocket upgrade failed", { status: 400 });
}
// Serve static assets from dist (shell.js, shell.css, etc.)
const assetPath = url.pathname.slice(1);
if (assetPath.includes(".") && !url.pathname.startsWith("/api/")) {
const staticResponse = await serveStatic(assetPath);
if (staticResponse) return staticResponse;
}
// Determine space from URL path: /{space} prefix on standalone domains
// / → /demo/{moduleId} (anon default)
// /jeff → /jeff/{moduleId} (personal space)
// /api/... → /demo/{moduleId}/api/... (module API)
// Everything else: 301 redirect to {space}.rspace.online/{moduleId}
const pathParts = url.pathname.split("/").filter(Boolean);
let space = "demo";
let suffix = "";
@ -700,10 +690,8 @@ const server = Bun.serve<WSData>({
suffix = url.pathname;
}
const rewrittenPath = `/${space}/${standaloneModuleId}${suffix}`;
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
const rewrittenReq = new Request(rewrittenUrl, req);
return app.fetch(rewrittenReq);
const canonical = `https://${space}.rspace.online/${standaloneModuleId}${suffix}${url.search}`;
return Response.redirect(canonical, 301);
}
// ── WebSocket upgrade ──
@ -773,30 +761,38 @@ const server = Bun.serve<WSData>({
}
}
// ── Subdomain backward compat: redirect to path-based routing ──
// ── Subdomain routing: {space}.rspace.online/{moduleId}/... ──
if (subdomain) {
const pathSegments = url.pathname.split("/").filter(Boolean);
// If visiting subdomain root, redirect to /:subdomain/canvas
// Root: redirect to default module (canvas)
if (pathSegments.length === 0) {
// First, ensure the community exists
const community = await loadCommunity(subdomain);
if (community) {
// Serve canvas.html directly for backward compat
const canvasHtml = await serveStatic("canvas.html");
if (canvasHtml) return canvasHtml;
}
return new Response("Community not found", { status: 404 });
return Response.redirect(`${url.protocol}//${host}/canvas`, 302);
}
// Subdomain with path: serve canvas.html
const slug = pathSegments.join("-");
const community = await loadCommunity(slug) || await loadCommunity(subdomain);
if (community) {
const canvasHtml = await serveStatic("canvas.html");
if (canvasHtml) return canvasHtml;
// Global routes pass through without subdomain prefix
if (
url.pathname.startsWith("/api/") ||
url.pathname.startsWith("/.well-known/") ||
url.pathname === "/about" ||
url.pathname === "/admin" ||
url.pathname === "/create-space" ||
url.pathname === "/new"
) {
return app.fetch(req);
}
return new Response("Community not found", { status: 404 });
// Static assets (paths with file extensions) pass through
if (pathSegments[0].includes(".")) {
return app.fetch(req);
}
// Rewrite: /{moduleId}/... → /{space}/{moduleId}/...
// e.g. demo.rspace.online/vote/api/polls → /demo/vote/api/polls
const rewrittenPath = `/${subdomain}${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 ──

View File

@ -107,14 +107,7 @@ export function renderShell(opts: ShellOptions): string {
}).then(function(r) { return r.json(); })
.then(function(data) {
if (data.slug) {
var host = window.location.host.split(':')[0];
var isStandalone = host !== 'rspace.online' && host !== 'localhost' && host !== '127.0.0.1';
var moduleId = '${escapeAttr(moduleId)}';
if (isStandalone) {
window.location.replace('/' + data.slug);
} else {
window.location.replace('/' + data.slug + '/' + moduleId);
}
window.location.replace(window.__rspaceNavUrl(data.slug, '${escapeAttr(moduleId)}'));
}
}).catch(function() {});
} catch(e) {}
@ -158,13 +151,13 @@ export function renderShell(opts: ShellOptions): string {
// Listen for tab events
tabBar.addEventListener('layer-switch', (e) => {
const { moduleId } = e.detail;
window.location.href = '/' + spaceSlug + '/' + moduleId;
window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
});
tabBar.addEventListener('layer-add', (e) => {
const { moduleId } = e.detail;
// Navigate to the new module (layer will be persisted when sync connects)
window.location.href = '/' + spaceSlug + '/' + moduleId;
window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
});
tabBar.addEventListener('layer-close', (e) => {

View File

@ -103,6 +103,8 @@ const CATEGORY_ORDER = [
"Identity & Infrastructure",
];
import { rspaceNavUrl, getCurrentSpace } from "../url-helpers";
export class RStackAppSwitcher extends HTMLElement {
#shadow: ShadowRoot;
#modules: AppSwitcherModule[] = [];
@ -189,7 +191,7 @@ export class RStackAppSwitcher extends HTMLElement {
return `
<div class="item-row ${m.id === current ? "active" : ""}">
<a class="item"
href="/${this.#getSpaceSlug()}/${m.id}"
href="${rspaceNavUrl(this.#getSpaceSlug(), m.id)}"
data-id="${m.id}">
${badgeHtml}
<div class="item-text">
@ -246,9 +248,7 @@ export class RStackAppSwitcher extends HTMLElement {
// Read from the space switcher or URL
const spaceSwitcher = document.querySelector("rstack-space-switcher");
if (spaceSwitcher) return spaceSwitcher.getAttribute("current") || "personal";
// Fallback: parse from URL (/:space/:module)
const parts = window.location.pathname.split("/").filter(Boolean);
return parts[0] || "personal";
return getCurrentSpace();
}
static define(tag = "rstack-app-switcher") {

View File

@ -112,11 +112,9 @@ function storeSession(token: string, username: string, did: string): void {
function autoResolveSpace(token: string, username: string): void {
if (!username) return;
const slug = username.toLowerCase();
// Detect current space from URL
const parts = window.location.pathname.split("/").filter(Boolean);
const currentSpace = parts[0] || "demo";
// Detect current space
const currentSpace = _getCurrentSpace();
if (currentSpace !== "demo") return; // Already on a non-demo space
// Provision personal space and redirect
@ -130,21 +128,39 @@ function autoResolveSpace(token: string, username: string): void {
.then((r) => r.json())
.then((data) => {
if (!data.slug) return;
const host = window.location.host.split(":")[0];
const isStandalone =
host !== "rspace.online" &&
host !== "localhost" &&
host !== "127.0.0.1";
const moduleId = parts[1] || "canvas";
if (isStandalone) {
window.location.replace("/" + data.slug);
} else {
window.location.replace("/" + data.slug + "/" + moduleId);
}
const moduleId = _getCurrentModule();
window.location.replace(_navUrl(data.slug, moduleId));
})
.catch(() => {});
}
// ── Inline URL helpers (avoid import cycle with url-helpers) ──
const _RESERVED = ["www", "rspace", "create", "new", "start", "auth"];
function _isSubdomain(): boolean {
const p = window.location.host.split(":")[0].split(".");
return p.length >= 3 && p.slice(-2).join(".") === "rspace.online" && !_RESERVED.includes(p[0]);
}
function _getCurrentSpace(): string {
if (_isSubdomain()) return window.location.host.split(":")[0].split(".")[0];
return window.location.pathname.split("/").filter(Boolean)[0] || "demo";
}
function _getCurrentModule(): string {
const parts = window.location.pathname.split("/").filter(Boolean);
return _isSubdomain() ? (parts[0] || "canvas") : (parts[1] || "canvas");
}
function _navUrl(space: string, moduleId: string): string {
const h = window.location.host.split(":")[0].split(".");
const onSub = h.length >= 3 && h.slice(-2).join(".") === "rspace.online" && !_RESERVED.includes(h[0]);
if (onSub) {
if (h[0] === space) return "/" + moduleId;
return window.location.protocol + "//" + space + "." + h.slice(-2).join(".") + "/" + moduleId;
}
if (window.location.host.includes("rspace.online") && !window.location.host.startsWith("www")) {
return window.location.protocol + "//" + space + ".rspace.online/" + moduleId;
}
return "/" + space + "/" + moduleId;
}
// ── The custom element ──
export class RStackIdentity extends HTMLElement {

View File

@ -10,6 +10,7 @@
*/
import { isAuthenticated, getAccessToken } from "./rstack-identity";
import { rspaceNavUrl, getCurrentModule as getModule } from "../url-helpers";
interface SpaceInfo {
slug: string;
@ -138,7 +139,7 @@ export class RStackSpaceSwitcher extends HTMLElement {
const vis = this.#visibilityInfo(s);
return `
<a class="item ${vis.cls} ${s.slug === current ? "active" : ""}"
href="/${s.slug}/${moduleId}">
href="${rspaceNavUrl(s.slug, moduleId)}">
<span class="item-icon">${s.icon || "🌐"}</span>
<span class="item-name">${s.name}</span>
<span class="item-vis ${vis.cls}">${vis.label}</span>
@ -156,7 +157,7 @@ export class RStackSpaceSwitcher extends HTMLElement {
const vis = this.#visibilityInfo(s);
return `
<a class="item ${vis.cls} ${s.slug === current ? "active" : ""}"
href="/${s.slug}/${moduleId}">
href="${rspaceNavUrl(s.slug, moduleId)}">
<span class="item-icon">${s.icon || "🌐"}</span>
<span class="item-name">${s.name}</span>
<span class="item-vis ${vis.cls}">${vis.label}</span>
@ -173,8 +174,7 @@ export class RStackSpaceSwitcher extends HTMLElement {
}
#getCurrentModule(): string {
const parts = window.location.pathname.split("/").filter(Boolean);
return parts[1] || "canvas";
return getModule();
}
static define(tag = "rstack-space-switcher") {

78
shared/url-helpers.ts Normal file
View File

@ -0,0 +1,78 @@
/**
* Subdomain-aware URL helpers for rSpace navigation.
*
* Canonical URL pattern: {space}.rspace.online/{moduleId}
* Fallback (localhost): /{space}/{moduleId}
*/
const RESERVED_SUBDOMAINS = ["www", "rspace", "create", "new", "start", "auth"];
/** Detect if the current page is on a {space}.rspace.online subdomain */
export function isSubdomain(): boolean {
const parts = window.location.host.split(":")[0].split(".");
return (
parts.length >= 3 &&
parts.slice(-2).join(".") === "rspace.online" &&
!RESERVED_SUBDOMAINS.includes(parts[0])
);
}
/** 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];
}
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) */
export function getCurrentModule(): string {
const parts = window.location.pathname.split("/").filter(Boolean);
if (isSubdomain()) {
return parts[0] || "canvas";
}
return parts[1] || "canvas";
}
/**
* Generate a navigation URL for a given space + module.
*
* 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}.
*/
export function rspaceNavUrl(space: string, moduleId: string): string {
const hostParts = window.location.host.split(":")[0].split(".");
const onSubdomain =
hostParts.length >= 3 &&
hostParts.slice(-2).join(".") === "rspace.online" &&
!RESERVED_SUBDOMAINS.includes(hostParts[0]);
if (onSubdomain) {
// Same space → just change the path
if (hostParts[0] === space) {
return `/${moduleId}`;
}
// Different space → switch subdomain
const baseDomain = hostParts.slice(-2).join(".");
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
return `${window.location.protocol}//${space}.rspace.online/${moduleId}`;
}
// Localhost/dev
return `/${space}/${moduleId}`;
}

View File

@ -12,6 +12,10 @@ import { RStackAppSwitcher } from "../shared/components/rstack-app-switcher";
import { RStackSpaceSwitcher } from "../shared/components/rstack-space-switcher";
import { RStackTabBar } from "../shared/components/rstack-tab-bar";
import { RStackMi } from "../shared/components/rstack-mi";
import { rspaceNavUrl } from "../shared/url-helpers";
// Expose URL helper globally (used by shell inline scripts + components)
(window as any).__rspaceNavUrl = rspaceNavUrl;
// Register all header components
RStackIdentity.define();