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:
parent
eba0aafc6e
commit
eab24e2e39
|
|
@ -645,10 +645,10 @@ const server = Bun.serve<WSData>({
|
||||||
const hostClean = host?.split(":")[0] || "";
|
const hostClean = host?.split(":")[0] || "";
|
||||||
const subdomain = getSubdomain(host);
|
const subdomain = getSubdomain(host);
|
||||||
|
|
||||||
// ── Standalone domain rewrite ──
|
// ── Standalone domain → 301 redirect to canonical subdomain URL ──
|
||||||
const standaloneModuleId = domainToModule.get(hostClean);
|
const standaloneModuleId = domainToModule.get(hostClean);
|
||||||
if (standaloneModuleId && !keepStandalone.has(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/")) {
|
if (url.pathname.startsWith("/ws/")) {
|
||||||
const communitySlug = url.pathname.split("/")[2];
|
const communitySlug = url.pathname.split("/")[2];
|
||||||
if (communitySlug) {
|
if (communitySlug) {
|
||||||
|
|
@ -673,17 +673,7 @@ const server = Bun.serve<WSData>({
|
||||||
return new Response("WebSocket upgrade failed", { status: 400 });
|
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve static assets from dist (shell.js, shell.css, etc.)
|
// Everything else: 301 redirect to {space}.rspace.online/{moduleId}
|
||||||
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)
|
|
||||||
const pathParts = url.pathname.split("/").filter(Boolean);
|
const pathParts = url.pathname.split("/").filter(Boolean);
|
||||||
let space = "demo";
|
let space = "demo";
|
||||||
let suffix = "";
|
let suffix = "";
|
||||||
|
|
@ -700,10 +690,8 @@ const server = Bun.serve<WSData>({
|
||||||
suffix = url.pathname;
|
suffix = url.pathname;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rewrittenPath = `/${space}/${standaloneModuleId}${suffix}`;
|
const canonical = `https://${space}.rspace.online/${standaloneModuleId}${suffix}${url.search}`;
|
||||||
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
|
return Response.redirect(canonical, 301);
|
||||||
const rewrittenReq = new Request(rewrittenUrl, req);
|
|
||||||
return app.fetch(rewrittenReq);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── WebSocket upgrade ──
|
// ── 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) {
|
if (subdomain) {
|
||||||
const pathSegments = url.pathname.split("/").filter(Boolean);
|
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) {
|
if (pathSegments.length === 0) {
|
||||||
// First, ensure the community exists
|
return Response.redirect(`${url.protocol}//${host}/canvas`, 302);
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subdomain with path: serve canvas.html
|
// Global routes pass through without subdomain prefix
|
||||||
const slug = pathSegments.join("-");
|
if (
|
||||||
const community = await loadCommunity(slug) || await loadCommunity(subdomain);
|
url.pathname.startsWith("/api/") ||
|
||||||
if (community) {
|
url.pathname.startsWith("/.well-known/") ||
|
||||||
const canvasHtml = await serveStatic("canvas.html");
|
url.pathname === "/about" ||
|
||||||
if (canvasHtml) return canvasHtml;
|
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 ──
|
// ── Hono handles everything else ──
|
||||||
|
|
|
||||||
|
|
@ -107,14 +107,7 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
}).then(function(r) { return r.json(); })
|
}).then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (data.slug) {
|
if (data.slug) {
|
||||||
var host = window.location.host.split(':')[0];
|
window.location.replace(window.__rspaceNavUrl(data.slug, '${escapeAttr(moduleId)}'));
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}).catch(function() {});
|
}).catch(function() {});
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
|
|
@ -158,13 +151,13 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
// Listen for tab events
|
// Listen for tab events
|
||||||
tabBar.addEventListener('layer-switch', (e) => {
|
tabBar.addEventListener('layer-switch', (e) => {
|
||||||
const { moduleId } = e.detail;
|
const { moduleId } = e.detail;
|
||||||
window.location.href = '/' + spaceSlug + '/' + moduleId;
|
window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId);
|
||||||
});
|
});
|
||||||
|
|
||||||
tabBar.addEventListener('layer-add', (e) => {
|
tabBar.addEventListener('layer-add', (e) => {
|
||||||
const { moduleId } = e.detail;
|
const { moduleId } = e.detail;
|
||||||
// Navigate to the new module (layer will be persisted when sync connects)
|
// 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) => {
|
tabBar.addEventListener('layer-close', (e) => {
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,8 @@ const CATEGORY_ORDER = [
|
||||||
"Identity & Infrastructure",
|
"Identity & Infrastructure",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
import { rspaceNavUrl, getCurrentSpace } from "../url-helpers";
|
||||||
|
|
||||||
export class RStackAppSwitcher extends HTMLElement {
|
export class RStackAppSwitcher extends HTMLElement {
|
||||||
#shadow: ShadowRoot;
|
#shadow: ShadowRoot;
|
||||||
#modules: AppSwitcherModule[] = [];
|
#modules: AppSwitcherModule[] = [];
|
||||||
|
|
@ -189,7 +191,7 @@ export class RStackAppSwitcher extends HTMLElement {
|
||||||
return `
|
return `
|
||||||
<div class="item-row ${m.id === current ? "active" : ""}">
|
<div class="item-row ${m.id === current ? "active" : ""}">
|
||||||
<a class="item"
|
<a class="item"
|
||||||
href="/${this.#getSpaceSlug()}/${m.id}"
|
href="${rspaceNavUrl(this.#getSpaceSlug(), m.id)}"
|
||||||
data-id="${m.id}">
|
data-id="${m.id}">
|
||||||
${badgeHtml}
|
${badgeHtml}
|
||||||
<div class="item-text">
|
<div class="item-text">
|
||||||
|
|
@ -246,9 +248,7 @@ export class RStackAppSwitcher extends HTMLElement {
|
||||||
// Read from the space switcher or URL
|
// Read from the space switcher or URL
|
||||||
const spaceSwitcher = document.querySelector("rstack-space-switcher");
|
const spaceSwitcher = document.querySelector("rstack-space-switcher");
|
||||||
if (spaceSwitcher) return spaceSwitcher.getAttribute("current") || "personal";
|
if (spaceSwitcher) return spaceSwitcher.getAttribute("current") || "personal";
|
||||||
// Fallback: parse from URL (/:space/:module)
|
return getCurrentSpace();
|
||||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
|
||||||
return parts[0] || "personal";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static define(tag = "rstack-app-switcher") {
|
static define(tag = "rstack-app-switcher") {
|
||||||
|
|
|
||||||
|
|
@ -112,11 +112,9 @@ function storeSession(token: string, username: string, did: string): void {
|
||||||
|
|
||||||
function autoResolveSpace(token: string, username: string): void {
|
function autoResolveSpace(token: string, username: string): void {
|
||||||
if (!username) return;
|
if (!username) return;
|
||||||
const slug = username.toLowerCase();
|
|
||||||
|
|
||||||
// Detect current space from URL
|
// Detect current space
|
||||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
const currentSpace = _getCurrentSpace();
|
||||||
const currentSpace = parts[0] || "demo";
|
|
||||||
if (currentSpace !== "demo") return; // Already on a non-demo space
|
if (currentSpace !== "demo") return; // Already on a non-demo space
|
||||||
|
|
||||||
// Provision personal space and redirect
|
// Provision personal space and redirect
|
||||||
|
|
@ -130,21 +128,39 @@ function autoResolveSpace(token: string, username: string): void {
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!data.slug) return;
|
if (!data.slug) return;
|
||||||
const host = window.location.host.split(":")[0];
|
const moduleId = _getCurrentModule();
|
||||||
const isStandalone =
|
window.location.replace(_navUrl(data.slug, moduleId));
|
||||||
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);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.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 ──
|
// ── The custom element ──
|
||||||
|
|
||||||
export class RStackIdentity extends HTMLElement {
|
export class RStackIdentity extends HTMLElement {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { isAuthenticated, getAccessToken } from "./rstack-identity";
|
import { isAuthenticated, getAccessToken } from "./rstack-identity";
|
||||||
|
import { rspaceNavUrl, getCurrentModule as getModule } from "../url-helpers";
|
||||||
|
|
||||||
interface SpaceInfo {
|
interface SpaceInfo {
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|
@ -138,7 +139,7 @@ export class RStackSpaceSwitcher extends HTMLElement {
|
||||||
const vis = this.#visibilityInfo(s);
|
const vis = this.#visibilityInfo(s);
|
||||||
return `
|
return `
|
||||||
<a class="item ${vis.cls} ${s.slug === current ? "active" : ""}"
|
<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-icon">${s.icon || "🌐"}</span>
|
||||||
<span class="item-name">${s.name}</span>
|
<span class="item-name">${s.name}</span>
|
||||||
<span class="item-vis ${vis.cls}">${vis.label}</span>
|
<span class="item-vis ${vis.cls}">${vis.label}</span>
|
||||||
|
|
@ -156,7 +157,7 @@ export class RStackSpaceSwitcher extends HTMLElement {
|
||||||
const vis = this.#visibilityInfo(s);
|
const vis = this.#visibilityInfo(s);
|
||||||
return `
|
return `
|
||||||
<a class="item ${vis.cls} ${s.slug === current ? "active" : ""}"
|
<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-icon">${s.icon || "🌐"}</span>
|
||||||
<span class="item-name">${s.name}</span>
|
<span class="item-name">${s.name}</span>
|
||||||
<span class="item-vis ${vis.cls}">${vis.label}</span>
|
<span class="item-vis ${vis.cls}">${vis.label}</span>
|
||||||
|
|
@ -173,8 +174,7 @@ export class RStackSpaceSwitcher extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
#getCurrentModule(): string {
|
#getCurrentModule(): string {
|
||||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
return getModule();
|
||||||
return parts[1] || "canvas";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static define(tag = "rstack-space-switcher") {
|
static define(tag = "rstack-space-switcher") {
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,10 @@ import { RStackAppSwitcher } from "../shared/components/rstack-app-switcher";
|
||||||
import { RStackSpaceSwitcher } from "../shared/components/rstack-space-switcher";
|
import { RStackSpaceSwitcher } from "../shared/components/rstack-space-switcher";
|
||||||
import { RStackTabBar } from "../shared/components/rstack-tab-bar";
|
import { RStackTabBar } from "../shared/components/rstack-tab-bar";
|
||||||
import { RStackMi } from "../shared/components/rstack-mi";
|
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
|
// Register all header components
|
||||||
RStackIdentity.define();
|
RStackIdentity.define();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue