From cc116e6be8f10b00304104eae855b5986d0b1e53 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Feb 2026 16:24:36 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20canonical=20subdomain=20routing=20?= =?UTF-8?q?=E2=80=94=20{space}.rspace.online/{moduleId}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/index.ts | 64 +++++++++--------- server/shell.ts | 13 +--- shared/components/rstack-app-switcher.ts | 8 +-- shared/components/rstack-identity.ts | 46 ++++++++----- shared/components/rstack-space-switcher.ts | 8 +-- shared/url-helpers.ts | 78 ++++++++++++++++++++++ website/shell.ts | 4 ++ 7 files changed, 154 insertions(+), 67 deletions(-) create mode 100644 shared/url-helpers.ts diff --git a/server/index.ts b/server/index.ts index 36c01f7..200df3d 100644 --- a/server/index.ts +++ b/server/index.ts @@ -645,10 +645,10 @@ const server = Bun.serve({ 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({ 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({ 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({ } } - // ── 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 ── diff --git a/server/shell.ts b/server/shell.ts index 54c4afe..79be254 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -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) => { diff --git a/shared/components/rstack-app-switcher.ts b/shared/components/rstack-app-switcher.ts index ac31eb1..64b4aa6 100644 --- a/shared/components/rstack-app-switcher.ts +++ b/shared/components/rstack-app-switcher.ts @@ -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 `
${badgeHtml}
@@ -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") { diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index 52fe440..9172528 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -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 { diff --git a/shared/components/rstack-space-switcher.ts b/shared/components/rstack-space-switcher.ts index c7ff94d..4001753 100644 --- a/shared/components/rstack-space-switcher.ts +++ b/shared/components/rstack-space-switcher.ts @@ -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 ` + href="${rspaceNavUrl(s.slug, moduleId)}"> ${s.icon || "🌐"} ${s.name} ${vis.label} @@ -156,7 +157,7 @@ export class RStackSpaceSwitcher extends HTMLElement { const vis = this.#visibilityInfo(s); return ` + href="${rspaceNavUrl(s.slug, moduleId)}"> ${s.icon || "🌐"} ${s.name} ${vis.label} @@ -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") { diff --git a/shared/url-helpers.ts b/shared/url-helpers.ts new file mode 100644 index 0000000..7b01d64 --- /dev/null +++ b/shared/url-helpers.ts @@ -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}`; +} diff --git a/website/shell.ts b/website/shell.ts index d071159..edd44b9 100644 --- a/website/shell.ts +++ b/website/shell.ts @@ -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();